diff --git a/build-logic/src/main/kotlin/java.gradle.kts b/build-logic/src/main/kotlin/java.gradle.kts index adc7151..61dfcea 100644 --- a/build-logic/src/main/kotlin/java.gradle.kts +++ b/build-logic/src/main/kotlin/java.gradle.kts @@ -31,7 +31,7 @@ repositories { tasks { processResources { - val hytaleVersion = libs.findVersion("hytale").get().toString() + val hytaleVersion = rootProject.property("hytale_server_version") as String inputs.property("version", version) inputs.property("hytaleVersion", hytaleVersion) diff --git a/build.gradle.kts b/build.gradle.kts index 0fa67d8..c6ef9c7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ plugins { /* Project Properties */ val modGroup = project.property("mod_group") as String val modVersion = project.property("mod_version") as String +val hytaleServerVersion = project.property("hytale_server_version") as String allprojects { group = modGroup @@ -14,10 +15,13 @@ allprojects { tasks { register("cleanBundle") { delete(layout.buildDirectory.dir("bundle")) + delete(layout.buildDirectory.dir("singlejar")) } register("copyBundleManifest") { dependsOn("cleanBundle") + inputs.property("version", modVersion) + inputs.property("hytaleVersion", hytaleServerVersion) from("bundle") { include( "manifest.json", @@ -28,7 +32,7 @@ tasks { into(layout.buildDirectory.dir("bundle")) expand( "version" to modVersion, - "hytaleVersion" to libs.versions.hytale.get() + "hytaleVersion" to hytaleServerVersion ) duplicatesStrategy = DuplicatesStrategy.INCLUDE } @@ -53,4 +57,48 @@ tasks { destinationDirectory.set(file("bundle")) from(layout.buildDirectory.dir("bundle")) } + + // Hyinit single-jar: one archive containing both Main plugin classes and Mixin + // configs, declared via `manifest-singlejar.json` (Main + Mixins). Drops in + // `earlyplugins/` and Hyinit auto-discovers both the runtime plugin and Mixins. + register("copySingleJarManifest") { + dependsOn("cleanBundle") + inputs.property("modVersion", modVersion) + inputs.property("hytaleVersion", hytaleServerVersion) + from("bundle") { + include("manifest-singlejar.json") + rename("manifest-singlejar.json", "manifest.json") + } + into(layout.buildDirectory.dir("singlejar")) + expand( + "modVersion" to modVersion, + "hytaleVersion" to hytaleServerVersion + ) + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + + register("singleJar") { + val earlyShadow = project(":early").tasks.named("shadowJar") + val pluginShadow = project(":plugin").tasks.named("shadowJar") + dependsOn( + "copySingleJarManifest", + earlyShadow, + pluginShadow, + project(":early").tasks.named("jar"), + project(":plugin").tasks.named("jar")) + archiveFileName.set("${project.name}-${version}.jar") + destinationDirectory.set(layout.buildDirectory.dir("bundle")) + // Per-module manifest.json is dropped; the merged manifest from copySingleJarManifest + // is added last and wins via DuplicatesStrategy.EXCLUDE (first-source-wins). + from(layout.buildDirectory.dir("singlejar")) { + include("manifest.json") + } + from(earlyShadow.flatMap { it.archiveFile }.map { zipTree(it) }) { + exclude("manifest.json", "META-INF/MANIFEST.MF") + } + from(pluginShadow.flatMap { it.archiveFile }.map { zipTree(it) }) { + exclude("manifest.json", "META-INF/MANIFEST.MF") + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + } } diff --git a/bundle/manifest-singlejar.json b/bundle/manifest-singlejar.json new file mode 100644 index 0000000..dedde33 --- /dev/null +++ b/bundle/manifest-singlejar.json @@ -0,0 +1,20 @@ +{ + "Group": "IroriPowered", + "Name": "Refixes", + "Version": "${modVersion}", + "Website": "https://www.irori.cc/", + "Description": "Patches and optimizations for Hytale server (single-jar build).", + "Main": "cc.irori.refixes.Refixes", + "Authors": [ + { + "Name": "KabanFriends", + "Url": "https://github.com/KabanFriends" + } + ], + "ServerVersion": ">=${hytaleVersion}", + "Dependencies": {}, + "OptionalDependencies": {}, + "Mixins": [ + "refixes.mixins.json" + ] +} diff --git a/bundle/manifest.json b/bundle/manifest.json index c3a26d7..6fb9624 100644 --- a/bundle/manifest.json +++ b/bundle/manifest.json @@ -10,7 +10,7 @@ "Url": "https://github.com/KabanFriends" } ], - "ServerVersion": "${hytaleVersion}", + "ServerVersion": ">=${hytaleVersion}", "Dependencies": {}, "OptionalDependencies": {}, "SubPlugins": { diff --git a/early/src/main/java/cc/irori/refixes/early/EarlyOptions.java b/early/src/main/java/cc/irori/refixes/early/EarlyOptions.java index 3f708d2..b370247 100644 --- a/early/src/main/java/cc/irori/refixes/early/EarlyOptions.java +++ b/early/src/main/java/cc/irori/refixes/early/EarlyOptions.java @@ -7,48 +7,37 @@ public final class EarlyOptions { private static boolean available = false; /* Force Skip Mod Validation */ - public static final Value FORCE_SKIP_MOD_VALIDATION = new Value<>(); - - /* Fluid Pre-processing */ - public static final Value DISABLE_FLUID_PRE_PROCESS = new Value<>(); - - /* Block Pre-processing */ - public static final Value ASYNC_BLOCK_PRE_PROCESS = new Value<>(); + public static final Value FORCE_SKIP_MOD_VALIDATION = new Value<>(false); /* Cylinder Visibility */ - public static final Value CYLINDER_VISIBILITY_ENABLED = new Value<>(); - public static final Value CYLINDER_VISIBILITY_HEIGHT_MULTIPLIER = new Value<>(); - - /* Parallel Entity Ticking */ - public static final Value PARALLEL_ENTITY_TICKING = new Value<>(); + public static final Value CYLINDER_VISIBILITY_HEIGHT_MULTIPLIER = new Value<>(2.0); /* ChunkTracker Rate Limits */ - public static final Value MAX_CHUNKS_PER_SECOND = new Value<>(); - public static final Value MAX_CHUNKS_PER_TICK = new Value<>(); + public static final Value MAX_CHUNKS_PER_SECOND = new Value<>(36); + public static final Value MAX_CHUNKS_PER_TICK = new Value<>(4); + public static final Value CHUNK_UNLOAD_OFFSET = new Value<>(4); + public static final Value VANILLA_KEEP_SPAWN_LOADED = new Value<>(true); /* KDTree Optimization */ - public static final Value KDTREE_OPTIMIZATION_OPTIMIZE_SORT = new Value<>(); - public static final Value KDTREE_OPTIMIZATION_THRESHOLD = new Value<>(); + public static final Value KDTREE_OPTIMIZATION_THRESHOLD = new Value<>(64); /* Shared Instances */ - public static final Value SHARED_INSTANCES_ENABLED = new Value<>(); - public static final Value SHARED_INSTANCES_EXCLUDED_PREFIXES = new Value<>(); + public static final Value SHARED_INSTANCES_EXCLUDED_PREFIXES = new Value<>(new String[0]); /* Block Entity Sleep */ - public static final Value BLOCK_ENTITY_SLEEP_ENABLED = new Value<>(); - public static final Value BLOCK_ENTITY_SLEEP_INTERVAL = new Value<>(); + public static final Value BLOCK_ENTITY_SLEEP_INTERVAL = new Value<>(4); /* Stat Recalculation Throttle */ - public static final Value STAT_RECALC_THROTTLE_ENABLED = new Value<>(); - public static final Value STAT_RECALC_INTERVAL = new Value<>(); + public static final Value STAT_RECALC_INTERVAL = new Value<>(4); - /* Block Section Cache */ - public static final Value SECTION_CACHE_ENABLED = new Value<>(); + /* Pathfinding */ + public static final Value PATHFINDING_MAX_PATH_LENGTH = new Value<>(200); + public static final Value PATHFINDING_OPEN_NODES_LIMIT = new Value<>(80); + public static final Value PATHFINDING_TOTAL_NODES_LIMIT = new Value<>(400); - /* Skip Empty Light Sections */ - public static final Value SKIP_EMPTY_LIGHT_SECTIONS = new Value<>(); + /* Parallel Steering Threshold */ + public static final Value PARALLEL_STEERING_THRESHOLD = new Value<>(64); - // Private constructor to prevent instantiation private EarlyOptions() {} public static void setAvailable(boolean available) { @@ -61,19 +50,26 @@ public static boolean isAvailable() { public static final class Value { + private final T defaultValue; private Supplier supplier = null; - private Value() {} + private Value(T defaultValue) { + this.defaultValue = defaultValue; + } public void setSupplier(Supplier supplier) { this.supplier = supplier; } + public boolean isSet() { + return supplier != null; + } + + // Falls back to the default when no supplier was wired (e.g. classloader split + // between the early and main plugin halves means the main plugin's setSupplier + // wrote to a different copy of this class). public T get() { - if (supplier == null) { - throw new IllegalStateException("Value supplier has not been set"); - } - return supplier.get(); + return supplier != null ? supplier.get() : defaultValue; } } } diff --git a/early/src/main/java/cc/irori/refixes/early/RefixesMixinPlugin.java b/early/src/main/java/cc/irori/refixes/early/RefixesMixinPlugin.java new file mode 100644 index 0000000..4cb9c24 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/RefixesMixinPlugin.java @@ -0,0 +1,353 @@ +package cc.irori.refixes.early; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.objectweb.asm.tree.ClassNode; +import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin; +import org.spongepowered.asm.mixin.extensibility.IMixinInfo; + +public class RefixesMixinPlugin implements IMixinConfigPlugin { + + private static final Path CONFIG_PATH = Paths.get("mods", "IroriPowered_Refixes", "Refixes.json"); + + private record MixinToggle(String[] jsonPath, boolean enabledWhen, boolean defaultEnabled, List mixins) { + MixinToggle(String[] jsonPath, boolean enabledWhen, List mixins) { + this(jsonPath, enabledWhen, true, mixins); + } + } + + private static final List TOGGLES = List.of( + new MixinToggle(new String[] {"Mixins", "Optimizations", "FluidPlugin"}, true, List.of("MixinFluidPlugin")), + new MixinToggle(new String[] {"Mixins", "Optimizations", "BlockModule"}, true, List.of("MixinBlockModule")), + new MixinToggle( + new String[] {"Mixins", "Optimizations", "CollectVisible"}, true, List.of("MixinCollectVisible")), + new MixinToggle(new String[] {"Mixins", "Optimizations", "KDTree"}, true, List.of("MixinKDTree")), + new MixinToggle( + new String[] {"Mixins", "Optimizations", "ChunkSavingSystems"}, + true, + List.of("MixinChunkSavingSystems")), + new MixinToggle( + new String[] {"Mixins", "Optimizations", "ChunkUnloadingSystem"}, + true, + List.of( + "MixinChunkUnloadingSystem", + "MixinChunkUnloadingSystem$ChunkTrackerAccessor", + "MixinChunkUnloadingSystem$DataAccessor")), + new MixinToggle( + new String[] {"Mixins", "Optimizations", "PlayerChunkTrackerSystems"}, + true, + List.of("MixinPlayerChunkTrackerSystems")), + + // ParallelEntityTicking gates 3 Mixins; disabling restores Store's write-processing assertion. + new MixinToggle( + new String[] {"Mixins", "Experimental", "ParallelEntityTicking"}, + true, + false, + List.of("MixinEntityTickingSystem", "MixinSteeringSystem", "MixinStore")), + new MixinToggle( + new String[] {"Mixins", "Experimental", "ParallelSpatialCollection"}, + true, + false, + List.of("MixinSpatialSystem")), + new MixinToggle( + new String[] {"Mixins", "Experimental", "BlockEntitySleep"}, + true, + false, + List.of("MixinBlockSection")), + new MixinToggle( + new String[] {"Mixins", "Experimental", "BlockSectionCache"}, + true, + false, + List.of("MixinBlockChunk")), + new MixinToggle( + new String[] {"Mixins", "Experimental", "SkipEmptyLightSections"}, + true, + false, + List.of("MixinFloodLightCalculation")), + new MixinToggle( + new String[] {"Mixins", "Experimental", "StatRecalcThrottle"}, + true, + false, + List.of("MixinStatModifiersManager")), + new MixinToggle( + new String[] {"Mixins", "Experimental", "SharedInstances"}, + true, + false, + List.of("MixinInstancesPlugin")), + new MixinToggle( + new String[] {"Mixins", "Experimental", "FluidReplicateChanges"}, + true, + false, + List.of("MixinFluidReplicateChanges")), + new MixinToggle( + new String[] {"Mixins", "Experimental", "ChunkReplicateChanges"}, + true, + false, + List.of("MixinChunkReplicateChanges")), + new MixinToggle( + new String[] {"Mixins", "Crashfixes", "BlockSectionSafety"}, + true, + List.of("MixinBlockSectionSafety")), + new MixinToggle( + new String[] {"Mixins", "Crashfixes", "MotionControllerBase"}, + true, + List.of("MixinMotionControllerBase")), + new MixinToggle(new String[] {"Mixins", "Crashfixes", "Player"}, true, List.of("MixinPlayer")), + new MixinToggle( + new String[] {"Mixins", "Crashfixes", "TurnOffTeleportersSystem"}, + true, + List.of("MixinTurnOffTeleportersSystem")), + new MixinToggle( + new String[] {"Mixins", "Crashfixes", "EntityChunkLoadingSystem"}, + true, + List.of("MixinEntityChunkLoadingSystem")), + new MixinToggle(new String[] {"Mixins", "Crashfixes", "AStarBase"}, true, List.of("MixinAStarBase")), + new MixinToggle( + new String[] {"Mixins", "Crashfixes", "RepulsionTicker"}, true, List.of("MixinRepulsionTicker")), + new MixinToggle(new String[] {"Mixins", "Crashfixes", "UpdateModule"}, true, List.of("MixinUpdateModule")), + new MixinToggle( + new String[] {"Mixins", "Crashfixes", "FillerBlockUtil"}, true, List.of("MixinFillerBlockUtil")), + new MixinToggle( + new String[] {"Mixins", "Crashfixes", "HideEntitySystems"}, + true, + List.of("MixinHideEntitySystems")), + new MixinToggle( + new String[] {"Mixins", "Crashfixes", "TriggerVolumesPlugin"}, + true, + List.of("MixinTriggerVolumesPlugin")), + new MixinToggle(new String[] {"Mixins", "Crashfixes", "VoiceModule"}, true, List.of("MixinVoiceModule")), + new MixinToggle( + new String[] {"Mixins", "Crashfixes", "LegacySemverRange"}, true, List.of("MixinPluginManager")), + + // Helpers: accessor / infrastructure mixins. Disabling them breaks other Mixins. + new MixinToggle(new String[] {"Mixins", "Helpers", "ArchetypeChunk"}, true, List.of("MixinArchetypeChunk")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "BeaconAddRemoveSystem"}, + true, + List.of("MixinBeaconAddRemoveSystem")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "BlockComponentChunk"}, + true, + List.of("MixinBlockComponentChunk")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "BlockHealthSystem"}, true, List.of("MixinBlockHealthSystem")), + new MixinToggle(new String[] {"Mixins", "Helpers", "CommandBuffer"}, true, List.of("MixinCommandBuffer")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "CraftingManagerAccessor"}, + true, + List.of("MixinCraftingManagerAccessor")), + new MixinToggle(new String[] {"Mixins", "Helpers", "EntityViewer"}, true, List.of("MixinEntityViewer")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "GamePacketHandler"}, true, List.of("MixinGamePacketHandler")), + new MixinToggle(new String[] {"Mixins", "Helpers", "HytaleServer"}, true, List.of("MixinHytaleServer")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "InteractionChain"}, true, List.of("MixinInteractionChain")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "InteractionManager"}, true, List.of("MixinInteractionManager")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "MarkerAddRemoveSystem"}, + true, + List.of("MixinMarkerAddRemoveSystem")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "NPCKillsEntitySystem"}, + true, + List.of("MixinNPCKillsEntitySystem")), + new MixinToggle(new String[] {"Mixins", "Helpers", "Options"}, true, List.of("MixinOptions")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "PlayerViewRadius"}, true, List.of("MixinPlayerViewRadius")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "PortalDeviceSummonPage"}, + true, + List.of("MixinPortalDeviceSummonPage")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "PortalWorldAccessor"}, + true, + List.of("MixinPortalWorldAccessor")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "PrefabListExtraRoots"}, + true, + List.of("MixinAssetPrefabFileProvider", "MixinPrefabPage")), + new MixinToggle(new String[] {"Mixins", "Helpers", "PrefabLoader"}, true, List.of("MixinPrefabLoader")), + new MixinToggle(new String[] {"Mixins", "Helpers", "RemovalSystem"}, true, List.of("MixinRemovalSystem")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "ServerAuthManager"}, true, List.of("MixinServerAuthManager")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "SetMemoriesCapacityInteraction"}, + true, + List.of("MixinSetMemoriesCapacityInteraction")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "SpawnMarkerBlockStateHeartbeat"}, + true, + List.of("MixinSpawnMarkerBlockStateHeartbeat")), + new MixinToggle(new String[] {"Mixins", "Helpers", "StateSupport"}, true, List.of("MixinStateSupport")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "TickingSpawnMarkerSystem"}, + true, + List.of("MixinTickingSpawnMarkerSystem")), + new MixinToggle(new String[] {"Mixins", "Helpers", "TickingThread"}, true, List.of("MixinTickingThread")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "TickingThreadAssert"}, + true, + List.of("MixinTickingThreadAssert")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "TrackedPlacementAccessor"}, + true, + List.of("MixinTrackedPlacementAccessor")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "TrackedPlacementOnAddRemove"}, + true, + List.of("MixinTrackedPlacementOnAddRemove")), + new MixinToggle(new String[] {"Mixins", "Helpers", "UUIDSystem"}, true, List.of("MixinUUIDSystem")), + new MixinToggle(new String[] {"Mixins", "Helpers", "Universe"}, true, List.of("MixinUniverse")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "UpdateCheckCommand"}, true, List.of("MixinUpdateCheckCommand")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "UpdateDownloadCommand"}, + true, + List.of("MixinUpdateDownloadCommand")), + new MixinToggle(new String[] {"Mixins", "Helpers", "World"}, true, List.of("MixinWorld")), + new MixinToggle(new String[] {"Mixins", "Helpers", "WorldConfig"}, true, List.of("MixinWorldConfig")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "WorldMapTracker"}, true, List.of("MixinWorldMapTracker")), + new MixinToggle( + new String[] {"Mixins", "Helpers", "WorldSpawningSystem"}, + true, + List.of("MixinWorldSpawningSystem")), + + // Inverted: `Telemetry.Enabled = true` -> telemetry runs (Mixin doesn't apply). + new MixinToggle( + new String[] {"HypixelServices", "Telemetry"}, false, true, List.of("MixinTelemetryModule")), + new MixinToggle( + new String[] {"HypixelServices", "LiveConfig"}, false, true, List.of("MixinLiveConfigModule"))); + + private String mixinPackage; + private Set disabledMixins = Collections.emptySet(); + + @Override + public void onLoad(String mixinPackage) { + this.mixinPackage = mixinPackage; + + JsonObject config = readConfig(); + if (config == null) { + config = new JsonObject(); + } + + Set disabled = new HashSet<>(); + for (MixinToggle toggle : TOGGLES) { + Boolean value = getBoolean(config, toggle.jsonPath); + boolean enabled = value == null ? toggle.defaultEnabled : (value == toggle.enabledWhen); + if (!enabled) { + disabled.addAll(toggle.mixins); + } + setBoolean(config, toggle.jsonPath, enabled == toggle.enabledWhen); + } + + disabledMixins = disabled; + writeConfig(config); + + System.out.println("[Refixes] === Early mixin patches ==="); + for (MixinToggle toggle : TOGGLES) { + for (String mixin : toggle.mixins) { + String marker = disabled.contains(mixin) ? "[ ]" : "[x]"; + System.out.println("[Refixes] - " + marker + " " + mixin); + } + } + } + + @Override + public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { + String simpleName = mixinClassName; + if (mixinPackage != null && mixinClassName.startsWith(mixinPackage + ".")) { + simpleName = mixinClassName.substring(mixinPackage.length() + 1); + } + return !disabledMixins.contains(simpleName); + } + + @Override + public String getRefMapperConfig() { + return null; + } + + @Override + public void acceptTargets(Set myTargets, Set otherTargets) {} + + @Override + public List getMixins() { + return null; + } + + @Override + public void preApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {} + + @Override + public void postApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {} + + private static JsonObject readConfig() { + if (!Files.isRegularFile(CONFIG_PATH)) { + return null; + } + try (Reader reader = Files.newBufferedReader(CONFIG_PATH, StandardCharsets.UTF_8)) { + JsonElement el = JsonParser.parseReader(reader); + return el.isJsonObject() ? el.getAsJsonObject() : null; + } catch (Exception e) { + System.out.println("[Refixes] Failed to read config: " + e.getMessage()); + return null; + } + } + + private static void writeConfig(JsonObject config) { + try { + Files.createDirectories(CONFIG_PATH.getParent()); + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + try (Writer writer = Files.newBufferedWriter(CONFIG_PATH, StandardCharsets.UTF_8)) { + gson.toJson(config, writer); + } + } catch (Exception e) { + System.out.println("[Refixes] Failed to write config: " + e.getMessage()); + } + } + + private static Boolean getBoolean(JsonObject root, String[] path) { + JsonObject current = root; + for (int i = 0; i < path.length - 1; i++) { + JsonElement el = current.get(path[i]); + if (el == null || !el.isJsonObject()) { + return null; + } + current = el.getAsJsonObject(); + } + JsonElement leaf = current.get(path[path.length - 1]); + if (leaf != null && leaf.isJsonPrimitive() && leaf.getAsJsonPrimitive().isBoolean()) { + return leaf.getAsBoolean(); + } + return null; + } + + private static void setBoolean(JsonObject root, String[] path, boolean value) { + JsonObject current = root; + for (int i = 0; i < path.length - 1; i++) { + JsonElement el = current.get(path[i]); + if (el == null || !el.isJsonObject()) { + JsonObject created = new JsonObject(); + current.add(path[i], created); + current = created; + } else { + current = el.getAsJsonObject(); + } + } + current.addProperty(path[path.length - 1], value); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/RefixesOptions.java b/early/src/main/java/cc/irori/refixes/early/RefixesOptions.java new file mode 100644 index 0000000..a90e832 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/RefixesOptions.java @@ -0,0 +1,36 @@ +package cc.irori.refixes.early; + +import com.hypixel.hytale.server.core.Options; +import joptsimple.OptionSpec; + +public class RefixesOptions { + + public static final OptionSpec OAUTH_ACCESS_TOKEN = Options.PARSER + .accepts("oauth-access-token", "OAuth access token") + .withRequiredArg() + .ofType(String.class); + + public static final OptionSpec OAUTH_REFRESH_TOKEN = Options.PARSER + .accepts("oauth-refresh-token", "OAuth refresh token for automatic renewal") + .withRequiredArg() + .ofType(String.class); + + public static final OptionSpec OAUTH_ACCESS_EXPIRES = Options.PARSER + .accepts("oauth-access-expires", "OAuth access token expiry (epoch seconds)") + .withRequiredArg() + .ofType(Long.class); + + public static final OptionSpec PROFILE_UUID = Options.PARSER + .accepts("profile-uuid", "Game profile UUID") + .withRequiredArg() + .ofType(String.class); + + public static final OptionSpec PROFILE_NAME = Options.PARSER + .accepts("profile-name", "Game profile display name") + .withRequiredArg() + .ofType(String.class); + + public static void init() { + // triggers class loading / static init + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinAStarBase.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinAStarBase.java new file mode 100644 index 0000000..165b4eb --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinAStarBase.java @@ -0,0 +1,66 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.EarlyOptions; +import com.hypixel.hytale.server.npc.navigation.AStarBase; +import com.hypixel.hytale.server.npc.navigation.AStarNode; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import java.util.List; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * Optimizes A* pathfinding: + * Binary search insert for open nodes (replaces O(n) linear scan) + * Configurable limits for maxPathLength, openNodesLimit, totalNodesLimit + */ +@Mixin(AStarBase.class) +public class MixinAStarBase { + + @Shadow + protected List openNodes; + + @Shadow + protected Long2ObjectMap visitedBlocks; + + @Shadow + protected int maxPathLength; + + @Shadow + protected int openNodesLimit; + + @Shadow + protected int totalNodesLimit; + + @Inject( + method = "addOpenNode(Lcom/hypixel/hytale/server/npc/navigation/AStarNode;J)V", + at = @At("HEAD"), + cancellable = true) + private void refixes$binarySearchInsert(AStarNode node, long index, CallbackInfo ci) { + float totalCost = node.getTotalCost(); + + int lo = 0, hi = openNodes.size(); + while (lo < hi) { + int mid = (lo + hi) >>> 1; + if (openNodes.get(mid).getTotalCost() < totalCost) { + hi = mid; + } else { + lo = mid + 1; + } + } + openNodes.add(lo, node); + visitedBlocks.put(index, node); + + ci.cancel(); + } + + @Inject(method = "initComputePath", at = @At("HEAD")) + private void refixes$applyPathfindingLimits(CallbackInfoReturnable cir) { + this.maxPathLength = EarlyOptions.PATHFINDING_MAX_PATH_LENGTH.get(); + this.openNodesLimit = EarlyOptions.PATHFINDING_OPEN_NODES_LIMIT.get(); + this.totalNodesLimit = EarlyOptions.PATHFINDING_TOTAL_NODES_LIMIT.get(); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinAssetPrefabFileProvider.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinAssetPrefabFileProvider.java new file mode 100644 index 0000000..d531399 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinAssetPrefabFileProvider.java @@ -0,0 +1,178 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.builtin.buildertools.prefablist.AssetPrefabFileProvider; +import com.hypixel.hytale.common.util.PathUtil; +import com.hypixel.hytale.common.util.StringCompareUtil; +import com.hypixel.hytale.server.core.prefab.PrefabStore; +import com.hypixel.hytale.server.core.ui.browser.FileListProvider; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import javax.annotation.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +@Mixin(AssetPrefabFileProvider.class) +public abstract class MixinAssetPrefabFileProvider { + + @Unique + private static final String K_SERVER = "__server"; + + @Unique + private static final String K_WORLDGEN = "__worldgen"; + + @Unique + private static final String K_ASSETROOT = "__assets"; + + @Unique + private static final String[] EXTRA_KEYS = {K_SERVER, K_WORLDGEN, K_ASSETROOT}; + + @Unique + private static final int MAX_SEARCH_RESULTS = 50; + + @Unique + private static final String PREFAB_EXT = ".prefab.json"; + + @Unique + @Nullable + private static Path refixes$baseFor(String key) { + PrefabStore s = PrefabStore.get(); + return switch (key) { + case K_SERVER -> s.getServerPrefabsPath(); + case K_WORLDGEN -> s.getWorldGenPrefabsPath(); + case K_ASSETROOT -> s.getAssetRootPath(); + default -> null; + }; + } + + @Unique + private static String refixes$displayFor(String key) { + return switch (key) { + case K_SERVER -> "Server"; + case K_WORLDGEN -> "WorldGen"; + case K_ASSETROOT -> "AssetRoot"; + default -> key; + }; + } + + @WrapMethod(method = "buildPackListings") + private List refixes$wrapListings(Operation> orig) { + List out = new ObjectArrayList<>(orig.call()); + for (String key : EXTRA_KEYS) { + Path base = refixes$baseFor(key); + if (base != null && Files.isDirectory(base, new LinkOption[0])) { + out.add(new FileListProvider.FileEntry(key, refixes$displayFor(key), true)); + } + } + return out; + } + + @WrapMethod(method = "resolveVirtualPath") + private Path refixes$wrapResolve(String virtualPath, Operation orig) { + if (virtualPath.isEmpty()) return orig.call(virtualPath); + String[] parts = virtualPath.split("/", 2); + Path base = refixes$baseFor(parts[0]); + if (base == null) return orig.call(virtualPath); + String sub = parts.length > 1 ? parts[1] : ""; + return sub.isEmpty() ? base : PathUtil.resolvePathWithinDir(base, sub); + } + + @WrapMethod(method = "buildPackDirectoryListing") + private List refixes$wrapList( + String currentDirStr, Operation> orig) { + String[] parts = currentDirStr.split("/", 2); + Path base = refixes$baseFor(parts[0]); + if (base == null) return orig.call(currentDirStr); + String sub = parts.length > 1 ? parts[1] : ""; + Path target = sub.isEmpty() ? base : PathUtil.resolvePathWithinDir(base, sub); + return refixes$walkDir(target); + } + + @WrapMethod(method = "buildSearchResults") + private List refixes$wrapSearch( + String currentDirStr, String searchQuery, Operation> orig) { + String[] parts = currentDirStr.split("/", 2); + Path syntheticBase = currentDirStr.isEmpty() ? null : refixes$baseFor(parts[0]); + if (syntheticBase != null) { + String sub = parts.length > 1 ? parts[1] : ""; + Path root = sub.isEmpty() ? syntheticBase : PathUtil.resolvePathWithinDir(syntheticBase, sub); + List results = new ObjectArrayList<>(); + if (root != null) refixes$searchInRoot(root, parts[0], sub, searchQuery.toLowerCase(), results); + results.sort(Comparator.comparingInt(FileListProvider.FileEntry::matchScore) + .reversed()); + return results.size() > MAX_SEARCH_RESULTS + ? new ObjectArrayList<>(results.subList(0, MAX_SEARCH_RESULTS)) + : results; + } + List out = new ObjectArrayList<>(orig.call(currentDirStr, searchQuery)); + if (currentDirStr.isEmpty()) { + String lowerQuery = searchQuery.toLowerCase(); + for (String key : EXTRA_KEYS) { + Path base = refixes$baseFor(key); + if (base != null) refixes$searchInRoot(base, key, "", lowerQuery, out); + } + out.sort(Comparator.comparingInt(FileListProvider.FileEntry::matchScore) + .reversed()); + if (out.size() > MAX_SEARCH_RESULTS) out = new ObjectArrayList<>(out.subList(0, MAX_SEARCH_RESULTS)); + } + return out; + } + + @Unique + private static List refixes$walkDir(@Nullable Path dir) { + List out = new ObjectArrayList<>(); + if (dir == null || !Files.isDirectory(dir, new LinkOption[0])) return out; + try (DirectoryStream stream = Files.newDirectoryStream(dir)) { + for (Path file : stream) { + String name = file.getFileName().toString(); + if (name.startsWith(".")) continue; + boolean isDir = Files.isDirectory(file, new LinkOption[0]); + if (!isDir && !name.endsWith(PREFAB_EXT)) continue; + String display = isDir ? name : name.substring(0, name.length() - PREFAB_EXT.length()); + out.add(new FileListProvider.FileEntry(name, display, isDir)); + } + } catch (IOException ignored) { + } + out.sort((a, b) -> a.isDirectory() == b.isDirectory() + ? a.displayName().compareToIgnoreCase(b.displayName()) + : a.isDirectory() ? -1 : 1); + return out; + } + + @Unique + private static void refixes$searchInRoot( + Path root, String key, String basePath, String lowerQuery, List out) { + if (!Files.isDirectory(root, new LinkOption[0])) return; + try { + Files.walkFileTree(root, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + String fn = file.getFileName().toString(); + if (!fn.endsWith(PREFAB_EXT)) return FileVisitResult.CONTINUE; + String base = fn.substring(0, fn.length() - PREFAB_EXT.length()); + int score = StringCompareUtil.getFuzzyDistance(base.toLowerCase(), lowerQuery, Locale.ENGLISH); + if (score > 0) { + Path rel = root.relativize(file); + String full = basePath.isEmpty() + ? key + "/" + rel.toString().replace('\\', '/') + : key + "/" + basePath + "/" + rel.toString().replace('\\', '/'); + out.add(new FileListProvider.FileEntry(full, base, false, false, score)); + } + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException ignored) { + } + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockChunk.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockChunk.java index e5a5894..ddb99e7 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockChunk.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockChunk.java @@ -1,6 +1,5 @@ package cc.irori.refixes.early.mixin; -import cc.irori.refixes.early.EarlyOptions; import com.hypixel.hytale.math.util.ChunkUtil; import com.hypixel.hytale.server.core.universe.world.chunk.BlockChunk; import com.hypixel.hytale.server.core.universe.world.chunk.section.BlockSection; @@ -23,9 +22,6 @@ public class MixinBlockChunk { @Inject(method = "getSectionAtBlockY", at = @At("HEAD"), cancellable = true) private void refixes$sectionCacheCheck(int y, CallbackInfoReturnable cir) { - if (!EarlyOptions.isAvailable() || !EarlyOptions.SECTION_CACHE_ENABLED.get()) { - return; - } int index = ChunkUtil.indexSection(y); if (index == refixes$cachedSectionIndex && refixes$cachedSectionRef != null) { cir.setReturnValue(refixes$cachedSectionRef); @@ -34,9 +30,6 @@ public class MixinBlockChunk { @Inject(method = "getSectionAtBlockY", at = @At("RETURN")) private void refixes$sectionCacheUpdate(int y, CallbackInfoReturnable cir) { - if (!EarlyOptions.isAvailable() || !EarlyOptions.SECTION_CACHE_ENABLED.get()) { - return; - } refixes$cachedSectionIndex = ChunkUtil.indexSection(y); refixes$cachedSectionRef = cir.getReturnValue(); } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockModule.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockModule.java index b60b4d2..5d12c06 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockModule.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockModule.java @@ -1,6 +1,5 @@ package cc.irori.refixes.early.mixin; -import cc.irori.refixes.early.EarlyOptions; import com.hypixel.hytale.server.core.modules.block.BlockModule; import com.hypixel.hytale.server.core.universe.world.events.ChunkPreLoadProcessEvent; import java.util.function.Consumer; @@ -23,11 +22,6 @@ public class MixinBlockModule { index = 2) private Consumer refixes$asyncBlockChunkPreProcess( Consumer consumer) { - return event -> { - if (EarlyOptions.isAvailable() && EarlyOptions.ASYNC_BLOCK_PRE_PROCESS.get()) { - return; - } - consumer.accept(event); - }; + return event -> {}; } } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockSection.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockSection.java index ecfef80..d606d11 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockSection.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockSection.java @@ -3,11 +3,17 @@ import cc.irori.refixes.early.EarlyOptions; import com.hypixel.hytale.function.predicate.ObjectPositionBlockFunction; import com.hypixel.hytale.server.core.universe.world.chunk.section.BlockSection; +import com.hypixel.hytale.server.core.universe.world.chunk.section.ChunkLightData; +import com.hypixel.hytale.server.core.universe.world.chunk.section.ChunkLightDataBuilder; +import java.util.Objects; +import java.util.concurrent.locks.StampedLock; +import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; /** @@ -21,19 +27,52 @@ public abstract class MixinBlockSection { @Shadow private int tickingBlocksCountCopy; + @Shadow + @Final + private StampedLock chunkSectionLock; + + @Shadow + private ChunkLightData localLight; + + @Shadow + private ChunkLightData globalLight; + @Unique private int refixes$sleepTickCounter; + @Inject(method = "setLocalLight", at = @At("HEAD"), cancellable = true) + private void refixes$setLocalLightLocked(ChunkLightDataBuilder localLight, CallbackInfo ci) { + Objects.requireNonNull(localLight); + ChunkLightData built = localLight.build(); + long stamp = this.chunkSectionLock.writeLock(); + try { + this.localLight = built; + } finally { + this.chunkSectionLock.unlockWrite(stamp); + } + ci.cancel(); + } + + @Inject(method = "setGlobalLight", at = @At("HEAD"), cancellable = true) + private void refixes$setGlobalLightLocked(ChunkLightDataBuilder globalLight, CallbackInfo ci) { + Objects.requireNonNull(globalLight); + ChunkLightData built = globalLight.build(); + long stamp = this.chunkSectionLock.writeLock(); + try { + this.globalLight = built; + } finally { + this.chunkSectionLock.unlockWrite(stamp); + } + ci.cancel(); + } + @Inject(method = "forEachTicking", at = @At("HEAD"), cancellable = true) private void refixes$blockEntitySleep( Object t, Object v, int sectionIndex, - ObjectPositionBlockFunction acceptor, + ObjectPositionBlockFunction acceptor, CallbackInfoReturnable cir) { - if (!EarlyOptions.isAvailable() || !EarlyOptions.BLOCK_ENTITY_SLEEP_ENABLED.get()) { - return; - } if (tickingBlocksCountCopy == 0) { return; } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockSectionSafety.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockSectionSafety.java new file mode 100644 index 0000000..59cba68 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinBlockSectionSafety.java @@ -0,0 +1,46 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.util.Logs; +import com.hypixel.hytale.codec.ExtraInfo; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.universe.world.chunk.section.BlockSection; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Guards BlockSection.deserialize() against corrupt section data (#14). + * If deserialization fails, the section is left empty rather than crashing the server. + */ +@Mixin(BlockSection.class) +public abstract class MixinBlockSectionSafety { + + @Unique + private static final HytaleLogger refixes$LOGGER = Logs.logger(); + + @Unique + private static final ThreadLocal refixes$WRAPPING = ThreadLocal.withInitial(() -> false); + + @Shadow + public abstract void deserialize(byte[] bytes, ExtraInfo extraInfo); + + @Inject(method = "deserialize([BLcom/hypixel/hytale/codec/ExtraInfo;)V", at = @At("HEAD"), cancellable = true) + private void refixes$safeDeserialize(byte[] bytes, ExtraInfo extraInfo, CallbackInfo ci) { + if (refixes$WRAPPING.get()) { + return; + } + ci.cancel(); + refixes$WRAPPING.set(true); + try { + deserialize(bytes, extraInfo); + } catch (Exception e) { + refixes$LOGGER.atWarning().withCause(e).log( + "BlockSection#deserialize(): Corrupt block section data, leaving section empty"); + } finally { + refixes$WRAPPING.set(false); + } + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinChunkLightDataSerializeSafety.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinChunkLightDataSerializeSafety.java new file mode 100644 index 0000000..17d4759 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinChunkLightDataSerializeSafety.java @@ -0,0 +1,133 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.util.Logs; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.universe.world.chunk.section.ChunkLightData; +import com.hypixel.hytale.server.core.util.io.MemorySegmentUtil; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.ValueLayout; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(ChunkLightData.class) +public abstract class MixinChunkLightDataSerializeSafety { + + @Unique + private static final HytaleLogger refixes$LOGGER = Logs.logger(); + + @Unique + private static final ThreadLocal refixes$WRAPPING = ThreadLocal.withInitial(() -> false); + + @Shadow + @Final + private short changeId; + + @Shadow + @Final + private MemorySegment lightData; + + @Shadow + public abstract int serialize(MemorySegment data, int offset); + + @Shadow + public abstract int serializeForPacket(MemorySegment data, int offset); + + @Inject(method = "serialize(Ljava/lang/foreign/MemorySegment;I)I", at = @At("HEAD"), cancellable = true) + private void refixes$safeSerialize(MemorySegment data, int offset, CallbackInfoReturnable cir) { + if (refixes$WRAPPING.get()) { + return; + } + refixes$WRAPPING.set(true); + try { + cir.setReturnValue(serialize(data, offset)); + } catch (RuntimeException e) { + MemorySegment ld = this.lightData; + int n = ld == null ? 0 : (int) (ld.byteSize() / 17L); + refixes$LOGGER.atWarning().withCause(e).log( + "ChunkLightData#serialize(): octree write overflowed buffer (nodes=%d), writing neutral chain fallback", + n); + cir.setReturnValue(refixes$writeNeutralChain(data, offset, true, n)); + } finally { + refixes$WRAPPING.set(false); + } + } + + @Inject(method = "serializeForPacket(Ljava/lang/foreign/MemorySegment;I)I", at = @At("HEAD"), cancellable = true) + private void refixes$safeSerializeForPacket(MemorySegment data, int offset, CallbackInfoReturnable cir) { + if (refixes$WRAPPING.get()) { + return; + } + refixes$WRAPPING.set(true); + try { + cir.setReturnValue(serializeForPacket(data, offset)); + } catch (RuntimeException e) { + MemorySegment ld = this.lightData; + int n = ld == null ? 0 : (int) (ld.byteSize() / 17L); + refixes$LOGGER.atWarning().withCause(e).log( + "ChunkLightData#serializeForPacket(): octree write overflowed buffer (nodes=%d), writing neutral chain fallback", + n); + cir.setReturnValue(refixes$writeNeutralChain(data, offset, false, n)); + } finally { + refixes$WRAPPING.set(false); + } + } + + @Unique + private int refixes$writeNeutralChain(MemorySegment data, int offset, boolean withHeader, int n) { + if (n <= 0) { + return refixes$writeNoLight(data, offset, withHeader); + } + + int octreeSize = 15 * n + 2; + int headerSize = withHeader ? 7 : 1; + int totalSize = headerSize + octreeSize; + + long remaining = data.byteSize() - offset; + if (remaining < totalSize) { + refixes$LOGGER.atWarning().log( + "ChunkLightData fallback does not fit (remaining=%d, required=%d), writing no-light fallback", + remaining, totalSize); + return refixes$writeNoLight(data, offset, withHeader); + } + data.asSlice((long) offset, (long) totalSize).fill((byte) 0); + + if (withHeader) { + data.set(MemorySegmentUtil.SHORT_BE, (long) offset, this.changeId); + data.set(ValueLayout.JAVA_BOOLEAN, (long) (offset + 2), true); + data.set(MemorySegmentUtil.INT_BE, (long) (offset + 3), octreeSize); + } else { + data.set(ValueLayout.JAVA_BOOLEAN, (long) offset, true); + } + + int octreeStart = offset + headerSize; + for (int i = 0; i < n - 1; i++) { + data.set(ValueLayout.JAVA_BYTE, (long) (octreeStart + i), (byte) 0x01); + } + data.set(ValueLayout.JAVA_BYTE, (long) (octreeStart + (n - 1)), (byte) 0x00); + + return totalSize; + } + + @Unique + private int refixes$writeNoLight(MemorySegment data, int offset, boolean withHeader) { + int totalSize = withHeader ? 3 : 1; + long remaining = data.byteSize() - offset; + if (remaining < totalSize) { + throw new IndexOutOfBoundsException( + "Cannot write no-light fallback at offset " + offset + " with " + remaining + " bytes remaining"); + } + + if (withHeader) { + data.set(MemorySegmentUtil.SHORT_BE, (long) offset, (short) 0); + data.set(ValueLayout.JAVA_BOOLEAN, (long) (offset + 2), false); + } else { + data.set(ValueLayout.JAVA_BOOLEAN, (long) offset, false); + } + return totalSize; + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinChunkReplicateChanges.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinChunkReplicateChanges.java index b7e1c3f..a35f492 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinChunkReplicateChanges.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinChunkReplicateChanges.java @@ -29,6 +29,10 @@ @Mixin(ChunkSystems.ReplicateChanges.class) public class MixinChunkReplicateChanges { + /** + * @author Refixes + * @reason + */ @Overwrite public void tick( float dt, diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinChunkUnloadingSystem.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinChunkUnloadingSystem.java new file mode 100644 index 0000000..f7dcdbd --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinChunkUnloadingSystem.java @@ -0,0 +1,83 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.EarlyOptions; +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.modules.entity.player.ChunkTracker; +import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; +import com.hypixel.hytale.server.core.universe.world.storage.ChunkStore; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.core.universe.world.storage.component.ChunkUnloadingSystem; +import java.util.List; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Overwrite; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +// Fixes vanilla bug where paused players' chunks unload after 7.5 seconds +@Mixin(ChunkUnloadingSystem.class) +public class MixinChunkUnloadingSystem { + + @Inject( + method = "tryUnload", + at = + @At( + value = "INVOKE", + target = + "Lcom/hypixel/hytale/server/core/universe/world/chunk/WorldChunk;pollKeepAlive(I)I"), + cancellable = true) + private static void refixes$protectSpawnChunk( + int index, + ArchetypeChunk archetypeChunk, + CommandBuffer commandBuffer, + CallbackInfo ci) { + if (!EarlyOptions.isAvailable() || !EarlyOptions.VANILLA_KEEP_SPAWN_LOADED.get()) { + return; + } + + WorldChunk worldChunk = archetypeChunk.getComponent(index, WorldChunk.getComponentType()); + + // Protect spawn chunk at origin (0,0) + if (worldChunk.getX() == 0 && worldChunk.getZ() == 0) { + worldChunk.resetKeepAlive(); + ci.cancel(); + } + } + + /** + * @author Refixes + * @reason + */ + @Overwrite + private static void collectTrackers( + ArchetypeChunk archetypeChunk, CommandBuffer commandBuffer) { + Store chunkStore = ((EntityStore) commandBuffer.getExternalData()) + .getWorld() + .getChunkStore() + .getStore(); + DataAccessor dataResource = (DataAccessor) chunkStore.getResource(ChunkStore.UNLOAD_RESOURCE); + + for (int index = 0; index < archetypeChunk.size(); ++index) { + ChunkTracker chunkTracker = archetypeChunk.getComponent(index, ChunkTracker.getComponentType()); + if (chunkTracker != null && ((ChunkTrackerAccessor) chunkTracker).getTransformComponent() != null) { + dataResource.getChunkTrackers().add(chunkTracker); + } + } + } + + @Mixin(ChunkUnloadingSystem.Data.class) + interface DataAccessor { + @Accessor + List getChunkTrackers(); + } + + @Mixin(ChunkTracker.class) + interface ChunkTrackerAccessor { + @Accessor("transformComponent") + TransformComponent getTransformComponent(); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinCollectVisible.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinCollectVisible.java index 669ca2c..7710a67 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinCollectVisible.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinCollectVisible.java @@ -7,13 +7,13 @@ import com.hypixel.hytale.component.Store; import com.hypixel.hytale.component.spatial.SpatialResource; import com.hypixel.hytale.component.spatial.SpatialStructure; -import com.hypixel.hytale.math.vector.Vector3d; import com.hypixel.hytale.server.core.modules.entity.EntityModule; import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; import com.hypixel.hytale.server.core.modules.entity.tracker.EntityTrackerSystems; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; -import it.unimi.dsi.fastutil.objects.ObjectList; +import java.util.List; import javax.annotation.Nonnull; +import org.joml.Vector3d; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Overwrite; @@ -25,7 +25,10 @@ @Mixin(EntityTrackerSystems.CollectVisible.class) public abstract class MixinCollectVisible { - // Use cylindrical query instead of spherical for entity visibility collection + /** + * @author Refixes + * @reason Use cylindrical query instead of spherical for entity visibility collection + */ @Overwrite public void tick( float dt, @@ -46,15 +49,11 @@ public void tick( EntityModule.get().getNetworkSendableSpatialResourceType()) .getSpatialStructure(); - ObjectList> results = SpatialResource.getThreadLocalReferenceList(); + List> results = SpatialResource.getThreadLocalReferenceList(); - if (EarlyOptions.isAvailable() && EarlyOptions.CYLINDER_VISIBILITY_ENABLED.get()) { - double radius = entityViewerComponent.viewRadiusBlocks; - double height = radius * EarlyOptions.CYLINDER_VISIBILITY_HEIGHT_MULTIPLIER.get(); - spatialStructure.collectCylinder(position, radius, height, results); - } else { - spatialStructure.collect(position, entityViewerComponent.viewRadiusBlocks, results); - } + double radius = entityViewerComponent.viewRadiusBlocks; + double height = radius * EarlyOptions.CYLINDER_VISIBILITY_HEIGHT_MULTIPLIER.get(); + spatialStructure.collectCylinder(position, radius, height, results); entityViewerComponent.visible.addAll(results); } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinEntityChunkLoadingSystem.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinEntityChunkLoadingSystem.java new file mode 100644 index 0000000..bfe6ede --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinEntityChunkLoadingSystem.java @@ -0,0 +1,104 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.util.Logs; +import com.hypixel.hytale.component.AddReason; +import com.hypixel.hytale.component.Archetype; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Holder; +import com.hypixel.hytale.component.NonTicking; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.entity.nameplate.Nameplate; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.chunk.EntityChunk; +import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; +import com.hypixel.hytale.server.core.universe.world.storage.ChunkStore; +import javax.annotation.Nonnull; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Overwrite; +import org.spongepowered.asm.mixin.Unique; + +/** + * Fixes NPE in EntityChunkLoadingSystem#onComponentRemoved by adding null checks + * for WorldChunk, EntityChunk, entity holders, and TransformComponent. + * Entities with null TransformComponent are skipped and their chunk is marked dirty. + */ +@Mixin(targets = "com.hypixel.hytale.server.core.universe.world.chunk.EntityChunk$EntityChunkLoadingSystem") +public class MixinEntityChunkLoadingSystem { + + @Unique + private static final HytaleLogger refixes$LOGGER = Logs.logger(); + + @Overwrite + public void onComponentRemoved( + @Nonnull Ref ref, + @Nonnull NonTicking component, + @Nonnull Store store, + @Nonnull CommandBuffer commandBuffer) { + World world = ((ChunkStore) store.getExternalData()).getWorld(); + + WorldChunk worldChunkComponent = (WorldChunk) store.getComponent(ref, WorldChunk.getComponentType()); + if (worldChunkComponent == null) { + return; + } + + EntityChunk entityChunkComponent = (EntityChunk) store.getComponent(ref, EntityChunk.getComponentType()); + if (entityChunkComponent == null) { + return; + } + + Store entityStore = world.getEntityStore().getStore(); + Holder[] holders = entityChunkComponent.takeEntityHolders(); + if (holders == null) { + return; + } + + int holderCount = holders.length; + for (int i = holderCount - 1; i >= 0; --i) { + Holder holder = holders[i]; + Archetype archetype = holder.getArchetype(); + if (archetype == null) { + holders[i] = holders[--holderCount]; + holders[holderCount] = holder; + continue; + } + + if (archetype.isEmpty()) { + refixes$LOGGER.atSevere().log("Empty archetype entity holder: %s (#%d)", holder, i); + holders[i] = holders[--holderCount]; + holders[holderCount] = holder; + worldChunkComponent.markNeedsSaving(); + continue; + } + + if (archetype.count() == 1 && archetype.contains(Nameplate.getComponentType())) { + refixes$LOGGER.atSevere().log("Nameplate only entity holder: %s (#%d)", holder, i); + holders[i] = holders[--holderCount]; + holders[holderCount] = holder; + worldChunkComponent.markNeedsSaving(); + continue; + } + + TransformComponent transformComponent = + (TransformComponent) holder.getComponent(TransformComponent.getComponentType()); + if (transformComponent == null) { + refixes$LOGGER.atWarning().log( + "EntityChunkLoadingSystem#onComponentRemoved(): skipping entity holder with null TransformComponent (chunk ref: %s)", + ref); + holders[i] = holders[--holderCount]; + holders[holderCount] = holder; + worldChunkComponent.markNeedsSaving(); + continue; + } + + transformComponent.setChunkLocation(ref, worldChunkComponent); + } + + Ref[] refs = entityStore.addEntities(holders, 0, holderCount, AddReason.LOAD); + for (int i = 0; i < refs.length && refs[i].isValid(); ++i) { + entityChunkComponent.loadEntityReference(refs[i]); + } + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinEntityTickingSystem.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinEntityTickingSystem.java index 91523e9..75b56f7 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinEntityTickingSystem.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinEntityTickingSystem.java @@ -1,6 +1,5 @@ package cc.irori.refixes.early.mixin; -import cc.irori.refixes.early.EarlyOptions; import com.hypixel.hytale.component.system.tick.EntityTickingSystem; import com.hypixel.hytale.component.task.ParallelRangeTask; import org.spongepowered.asm.mixin.Mixin; @@ -18,9 +17,7 @@ public class MixinEntityTickingSystem { // Enable parallel entity ticking when chunk size is large enough @Inject(method = "maybeUseParallel", at = @At("HEAD"), cancellable = true) private static void maybeUseParallel(int archetypeChunkSize, int taskCount, CallbackInfoReturnable cir) { - if (EarlyOptions.isAvailable() && EarlyOptions.PARALLEL_ENTITY_TICKING.get()) { - cir.cancel(); - cir.setReturnValue(taskCount > 0 || archetypeChunkSize > ParallelRangeTask.PARALLELISM); - } + cir.cancel(); + cir.setReturnValue(taskCount > 0 || archetypeChunkSize > ParallelRangeTask.PARALLELISM); } } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinEntityViewer.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinEntityViewer.java new file mode 100644 index 0000000..92a50cb --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinEntityViewer.java @@ -0,0 +1,39 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.protocol.ComponentUpdate; +import com.hypixel.hytale.protocol.ComponentUpdateType; +import com.hypixel.hytale.server.core.modules.entity.tracker.EntityTrackerSystems; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import java.util.Set; +import javax.annotation.Nonnull; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +// Fixes "Entity is not visible!" crash in EntityViewer.queueUpdate/queueRemove. + +@Mixin(EntityTrackerSystems.EntityViewer.class) +public abstract class MixinEntityViewer { + + @Shadow + public Set> visible; + + @Inject(method = "queueUpdate", at = @At("HEAD"), cancellable = true) + private void refixes$fixInvisibleUpdate( + @Nonnull Ref ref, @Nonnull ComponentUpdate update, CallbackInfo ci) { + if (!this.visible.contains(ref)) { + ci.cancel(); + } + } + + @Inject(method = "queueRemove", at = @At("HEAD"), cancellable = true) + private void refixes$fixInvisibleRemove( + @Nonnull Ref ref, @Nonnull ComponentUpdateType type, CallbackInfo ci) { + if (!this.visible.contains(ref)) { + ci.cancel(); + } + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinFillerBlockUtil.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinFillerBlockUtil.java new file mode 100644 index 0000000..c8e17e0 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinFillerBlockUtil.java @@ -0,0 +1,35 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.component.ComponentAccessor; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.server.core.util.FillerBlockUtil; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +// Skip the per-Ref filler-removal lambda when asyncRef is null (pre-release NPE spam). +@Mixin(FillerBlockUtil.class) +public class MixinFillerBlockUtil { + + @Inject(method = "lambda$removeOrphanedFillers$0", at = @At("HEAD"), cancellable = true) + private static void refixes$skipNullAsyncRef( + int p0, + ComponentAccessor accessor, + int p2, + int p3, + int p4, + int p5, + int p6, + int p7, + int p8, + int p9, + int p10, + FillerBlockUtil.ChangeReason reason, + Ref asyncRef, + CallbackInfo ci) { + if (asyncRef == null) { + ci.cancel(); + } + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinFloodLightCalculation.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinFloodLightCalculation.java index 1bfc034..421dc16 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinFloodLightCalculation.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinFloodLightCalculation.java @@ -1,6 +1,5 @@ package cc.irori.refixes.early.mixin; -import cc.irori.refixes.early.EarlyOptions; import com.hypixel.hytale.server.core.universe.world.chunk.section.BlockSection; import com.hypixel.hytale.server.core.universe.world.chunk.section.ChunkLightData; import com.hypixel.hytale.server.core.universe.world.chunk.section.ChunkLightDataBuilder; @@ -26,9 +25,6 @@ public class MixinFloodLightCalculation { IntBinaryOperator fromIndex, IntBinaryOperator toIndex, CallbackInfo ci) { - if (!EarlyOptions.isAvailable() || !EarlyOptions.SKIP_EMPTY_LIGHT_SECTIONS.get()) { - return; - } if (fromSection != null && fromSection.isSolidAir() && fromSection.getLocalLight() == ChunkLightData.EMPTY) { ci.cancel(); } @@ -43,9 +39,6 @@ public class MixinFloodLightCalculation { Int2IntFunction fromIndex, Int2IntFunction toIndex, CallbackInfo ci) { - if (!EarlyOptions.isAvailable() || !EarlyOptions.SKIP_EMPTY_LIGHT_SECTIONS.get()) { - return; - } if (fromSection != null && fromSection.isSolidAir() && fromSection.getLocalLight() == ChunkLightData.EMPTY) { ci.cancel(); } @@ -60,9 +53,6 @@ public class MixinFloodLightCalculation { int fromBlockIndex, int toBlockIndex, CallbackInfo ci) { - if (!EarlyOptions.isAvailable() || !EarlyOptions.SKIP_EMPTY_LIGHT_SECTIONS.get()) { - return; - } if (fromSection != null && fromSection.isSolidAir() && fromSection.getLocalLight() == ChunkLightData.EMPTY) { ci.cancel(); } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinFluidPlugin.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinFluidPlugin.java index f65f337..9840bb9 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinFluidPlugin.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinFluidPlugin.java @@ -1,6 +1,5 @@ package cc.irori.refixes.early.mixin; -import cc.irori.refixes.early.EarlyOptions; import com.hypixel.hytale.builtin.fluid.FluidPlugin; import com.hypixel.hytale.server.core.universe.world.events.ChunkPreLoadProcessEvent; import java.util.function.Consumer; @@ -21,11 +20,6 @@ public class MixinFluidPlugin { index = 2) private Consumer refixes$disableFluidChunkPreProcess( Consumer consumer) { - return event -> { - if (EarlyOptions.isAvailable() && EarlyOptions.DISABLE_FLUID_PRE_PROCESS.get()) { - return; - } - consumer.accept(event); - }; + return event -> {}; } } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinFluidReplicateChanges.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinFluidReplicateChanges.java index 3a3958f..f459587 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinFluidReplicateChanges.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinFluidReplicateChanges.java @@ -49,6 +49,10 @@ public class MixinFluidReplicateChanges { @Nonnull private ComponentType worldChunkComponentType; + /** + * @author Refixes + * @reason + */ @Overwrite public void tick( float dt, @@ -70,12 +74,15 @@ public void tick( World world = commandBuffer.getExternalData().getWorld(); WorldChunk worldChunk = commandBuffer.getComponent(section.getChunkColumnReference(), this.worldChunkComponentType); - int sectionY = section.getY(); // Defer light invalidation to merge phase commandBuffer.run(s -> { if (worldChunk == null || worldChunk.getWorld() == null) return; - worldChunk.getWorld().getChunkLighting().invalidateLightInChunkSection(worldChunk, sectionY); + worldChunk + .getWorld() + .getChunkLighting() + .invalidateLightInChunkSection( + store.getExternalData(), section.getX(), section.getY(), section.getZ()); }); Collection playerRefs = store.getExternalData().getWorld().getPlayerRefs(); diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinHideEntitySystems.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinHideEntitySystems.java new file mode 100644 index 0000000..11cd4c5 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinHideEntitySystems.java @@ -0,0 +1,58 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.util.Logs; +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.modules.entity.system.HideEntitySystems; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +// Catch IllegalStateException from invalid entity refs during cross-world teleports. +@Mixin(HideEntitySystems.AdventurePlayerSystem.class) +public abstract class MixinHideEntitySystems { + + @Unique + private static final HytaleLogger refixes$LOGGER = Logs.logger(); + + @Unique + private static final ThreadLocal refixes$WRAPPING = ThreadLocal.withInitial(() -> false); + + @Shadow + public abstract void tick( + float deltaTime, + int entityIndex, + ArchetypeChunk chunk, + Store store, + CommandBuffer commandBuffer); + + @Inject(method = "tick", at = @At("HEAD"), cancellable = true) + private void refixes$wrapTick( + float deltaTime, + int entityIndex, + ArchetypeChunk chunk, + Store store, + CommandBuffer commandBuffer, + CallbackInfo ci) { + if (refixes$WRAPPING.get()) { + return; + } + + ci.cancel(); + refixes$WRAPPING.set(true); + try { + tick(deltaTime, entityIndex, chunk, store, commandBuffer); + } catch (IllegalStateException e) { + refixes$LOGGER.atWarning().log( + "HideEntitySystems$AdventurePlayerSystem.tick(): Skipping tick for invalid entity reference (likely mid-teleport), discarding"); + } finally { + refixes$WRAPPING.set(false); + } + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinHytaleServer.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinHytaleServer.java index e8dceb0..abeeede 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinHytaleServer.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinHytaleServer.java @@ -1,6 +1,5 @@ package cc.irori.refixes.early.mixin; -import cc.irori.refixes.early.EarlyOptions; import cc.irori.refixes.early.util.Logs; import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.server.core.HytaleServer; @@ -17,16 +16,29 @@ public class MixinHytaleServer { @Unique private static final HytaleLogger refixes$LOGGER = Logs.logger(); + @Unique + private static final String MAIN_PLUGIN_CLASS = "cc.irori.refixes.Refixes"; + @Inject( method = "", at = @At(value = "INVOKE", target = "Lio/netty/handler/codec/quic/Quic;ensureAvailability()V")) private void refixes$setupBootEvent(CallbackInfo ci) { HytaleServer server = (HytaleServer) (Object) this; server.getEventBus().register(BootEvent.class, event -> { - if (!EarlyOptions.isAvailable()) { + if (!refixes$mainPluginPresent()) { refixes$LOGGER.atWarning().log( - "Refixes Main Plugin is not installed! Some Mixin patches will not be applied."); + "Refixes Main Plugin is not installed! Some runtime fixes will not be applied."); } }); } + + @Unique + private static boolean refixes$mainPluginPresent() { + try { + Class.forName(MAIN_PLUGIN_CLASS, false, MixinHytaleServer.class.getClassLoader()); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinHytaleServerConfig.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinHytaleServerConfig.java deleted file mode 100644 index 9d20985..0000000 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinHytaleServerConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package cc.irori.refixes.early.mixin; - -import cc.irori.refixes.early.EarlyOptions; -import com.hypixel.hytale.server.core.HytaleServerConfig; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; - -@Mixin(HytaleServerConfig.class) -public class MixinHytaleServerConfig { - - @Inject(method = "shouldSkipModValidation", at = @At("HEAD"), cancellable = true) - private void refixes$forceSkipModValidation(CallbackInfoReturnable cir) { - if (EarlyOptions.isAvailable() && EarlyOptions.FORCE_SKIP_MOD_VALIDATION.get()) { - cir.setReturnValue(true); - } - } -} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinInstancesPlugin.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinInstancesPlugin.java index fe9e0d9..478dd72 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinInstancesPlugin.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinInstancesPlugin.java @@ -31,10 +31,6 @@ public abstract CompletableFuture spawnInstance( @Overwrite public CompletableFuture spawnInstance( @Nonnull String name, @Nonnull World forWorld, @Nonnull Transform returnPoint) { - if (!EarlyOptions.isAvailable() || !EarlyOptions.SHARED_INSTANCES_ENABLED.get()) { - return spawnInstance(name, null, forWorld, returnPoint); - } - String[] excludedPrefixes = EarlyOptions.SHARED_INSTANCES_EXCLUDED_PREFIXES.get(); for (String prefix : excludedPrefixes) { if (name.startsWith(prefix)) { diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinKDTree.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinKDTree.java index 679a64a..847a63d 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinKDTree.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinKDTree.java @@ -19,9 +19,7 @@ public class MixinKDTree { method = "rebuild", at = @At(value = "INVOKE", target = "Lcom/hypixel/hytale/component/spatial/SpatialData;sortMorton()V")) private void refixes$fastSort(SpatialData spatialData) { - if (EarlyOptions.isAvailable() - && EarlyOptions.KDTREE_OPTIMIZATION_OPTIMIZE_SORT.get() - && spatialData.size() < EarlyOptions.KDTREE_OPTIMIZATION_THRESHOLD.get()) { + if (spatialData.size() < EarlyOptions.KDTREE_OPTIMIZATION_THRESHOLD.get()) { spatialData.sort(); } else { spatialData.sortMorton(); diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinLiveConfigModule.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinLiveConfigModule.java new file mode 100644 index 0000000..0815701 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinLiveConfigModule.java @@ -0,0 +1,17 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.server.core.liveconfig.LiveConfigModule; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +// Skip LiveConfig refresh; flags fall back to local defaults. +@Mixin(LiveConfigModule.class) +public class MixinLiveConfigModule { + + @Inject(method = "start", at = @At("HEAD"), cancellable = true) + private void refixes$skipLiveConfigRefresh(CallbackInfo ci) { + ci.cancel(); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinMarkerAddRemoveSystem.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinMarkerAddRemoveSystem.java index 4adfacd..f23f2dd 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinMarkerAddRemoveSystem.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinMarkerAddRemoveSystem.java @@ -1,6 +1,7 @@ package cc.irori.refixes.early.mixin; import cc.irori.refixes.early.util.Logs; +import com.hypixel.hytale.component.AddReason; import com.hypixel.hytale.component.CommandBuffer; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.RemoveReason; @@ -11,6 +12,7 @@ import com.hypixel.hytale.server.npc.systems.SpawnReferenceSystems; import com.hypixel.hytale.server.spawning.spawnmarkers.SpawnMarkerEntity; import com.llamalad7.mixinextras.sugar.Local; +import java.util.UUID; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; @@ -28,9 +30,16 @@ public abstract class MixinMarkerAddRemoveSystem { @Unique private static final ThreadLocal refixes$NPC_REFERENCES = new ThreadLocal<>(); + @Unique + private static final ThreadLocal refixes$REMOVED_UUID = new ThreadLocal<>(); + @Unique private static final ThreadLocal refixes$WRAPPING = ThreadLocal.withInitial(() -> false); + @Shadow + public abstract void onEntityAdded( + Ref ref, AddReason reason, Store store, CommandBuffer commandBuffer); + @Shadow public abstract void onEntityRemove( Ref ref, @@ -38,6 +47,31 @@ public abstract void onEntityRemove( Store store, CommandBuffer commandBuffer); + @Inject(method = "onEntityAdded", at = @At("HEAD"), cancellable = true) + private void refixes$wrapOnEntityAdded( + Ref ref, + AddReason reason, + Store store, + CommandBuffer commandBuffer, + CallbackInfo ci) { + if (reason != AddReason.LOAD) { + return; + } + if (refixes$WRAPPING.get()) { + return; + } + ci.cancel(); + refixes$WRAPPING.set(true); + try { + onEntityAdded(ref, reason, store, commandBuffer); + } catch (Exception e) { + refixes$LOGGER.atWarning().withCause(e).log( + "MarkerAddRemoveSystem#onEntityAdded(): Failed to process spawn marker on load, discarding"); + } finally { + refixes$WRAPPING.set(false); + } + } + @Inject(method = "onEntityRemove", at = @At("HEAD"), cancellable = true) private void refixes$wrapOnEntityRemove( Ref ref, @@ -52,14 +86,15 @@ public abstract void onEntityRemove( refixes$WRAPPING.set(true); try { onEntityRemove(ref, reason, store, commandBuffer); - } catch (ArrayIndexOutOfBoundsException e) { + } catch (Exception e) { refixes$LOGGER.atWarning().withCause(e).log( - "MarkerAddRemoveSystem#onEntityRemove(): Array index out of bounds while removing NPC references"); + "MarkerAddRemoveSystem#onEntityRemove(): Unhandled exception while removing NPC references"); } finally { refixes$WRAPPING.set(false); } } + // Redirects getNpcReferences() to fix the AIOOBE crash @Redirect( method = "onEntityRemove", at = @@ -67,10 +102,68 @@ public abstract void onEntityRemove( value = "INVOKE", target = "Lcom/hypixel/hytale/server/spawning/spawnmarkers/SpawnMarkerEntity;getNpcReferences()[Lcom/hypixel/hytale/server/core/entity/reference/InvalidatablePersistentRef;")) - private InvalidatablePersistentRef[] refixes$storeNpcReferences(SpawnMarkerEntity instance) { + private InvalidatablePersistentRef[] refixes$storeAndFilterNpcReferences(SpawnMarkerEntity instance) { InvalidatablePersistentRef[] refs = instance.getNpcReferences(); refixes$NPC_REFERENCES.set(refs); - return refs; + + if (refs == null) { + return null; + } + + UUID removedUuid = refixes$REMOVED_UUID.get(); + if (removedUuid == null) { + return refs; + } + + // find and remove the entry matching the removed entity's UUID. + int matchIndex = -1; + for (int i = 0; i < refs.length; i++) { + if (refs[i] != null && refs[i].getUuid().equals(removedUuid)) { + matchIndex = i; + break; + } + } + + if (matchIndex == -1) { + // return a copy with one fewer element to match the allocation size. + refixes$LOGGER.atWarning().log( + "MarkerAddRemoveSystem#onEntityRemove(): UUID %s not found in npcReferences (length=%d) for marker %s, " + + "returning truncated array to prevent AIOOBE", + removedUuid, refs.length, instance.getSpawnMarkerId()); + if (refs.length <= 1) { + return new InvalidatablePersistentRef[0]; + } + InvalidatablePersistentRef[] truncated = new InvalidatablePersistentRef[refs.length - 1]; + System.arraycopy(refs, 0, truncated, 0, truncated.length); + return truncated; + } + + // if uuid found, copy every element except the one with the matching uuid + InvalidatablePersistentRef[] filtered = new InvalidatablePersistentRef[refs.length - 1]; + System.arraycopy(refs, 0, filtered, 0, matchIndex); + System.arraycopy(refs, matchIndex + 1, filtered, matchIndex, refs.length - matchIndex - 1); + return filtered; + } + + /** + * Captures the UUID of the entity being removed into a ThreadLocal, + * to make it available in refixes$storeAndFilterNpcReferences + */ + @Inject( + method = "onEntityRemove", + at = + @At( + value = "INVOKE", + target = + "Lcom/hypixel/hytale/server/spawning/spawnmarkers/SpawnMarkerEntity;getNpcReferences()[Lcom/hypixel/hytale/server/core/entity/reference/InvalidatablePersistentRef;")) + private void refixes$captureRemovedUuid( + Ref ref, + RemoveReason reason, + Store store, + CommandBuffer commandBuffer, + CallbackInfo ci, + @Local(name = "uuid") UUID uuid) { + refixes$REMOVED_UUID.set(uuid); } @Inject( @@ -91,6 +184,7 @@ public abstract void onEntityRemove( @Local(name = "spawnMarkerComponent") SpawnMarkerEntity spawnMarkerComponent) { InvalidatablePersistentRef[] refs = refixes$NPC_REFERENCES.get(); refixes$NPC_REFERENCES.remove(); + refixes$REMOVED_UUID.remove(); if (refs == null) { refixes$LOGGER.atWarning().log( diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinMotionControllerBase.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinMotionControllerBase.java new file mode 100644 index 0000000..eefac7d --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinMotionControllerBase.java @@ -0,0 +1,29 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.server.npc.movement.controllers.MotionControllerBase; +import org.joml.Vector3d; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(MotionControllerBase.class) +public class MixinMotionControllerBase { + + @Shadow + protected Vector3d translation; + + @Inject( + method = "steer0", + at = + @At( + value = "INVOKE", + target = + "Lcom/hypixel/hytale/server/npc/movement/controllers/MotionControllerBase;executeMove(Lcom/hypixel/hytale/component/Ref;Lcom/hypixel/hytale/server/npc/role/Role;DLorg/joml/Vector3d;Lcom/hypixel/hytale/component/ComponentAccessor;)D")) + private void refixes$guardNaNTranslation(CallbackInfoReturnable cir) { + if (!Double.isFinite(translation.x) || !Double.isFinite(translation.y) || !Double.isFinite(translation.z)) { + translation.set(0.0); + } + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinNPCKillsEntitySystem.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinNPCKillsEntitySystem.java new file mode 100644 index 0000000..71965bb --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinNPCKillsEntitySystem.java @@ -0,0 +1,31 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.component.Component; +import com.hypixel.hytale.component.ComponentType; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.npc.systems.NPCDeathSystems; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(NPCDeathSystems.NPCKillsEntitySystem.class) +public class MixinNPCKillsEntitySystem { + + @Redirect( + method = "onComponentAdded", + at = + @At( + value = "INVOKE", + target = + "Lcom/hypixel/hytale/component/Store;getComponent(Lcom/hypixel/hytale/component/Ref;Lcom/hypixel/hytale/component/ComponentType;)Lcom/hypixel/hytale/component/Component;", + ordinal = 0)) + private > T refixes$guardKillerRef( + Store store, Ref ref, ComponentType type) { + if (!ref.isValid()) { + return null; + } + return store.getComponent(ref, type); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinOptions.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinOptions.java new file mode 100644 index 0000000..de1eb5f --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinOptions.java @@ -0,0 +1,17 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.RefixesOptions; +import com.hypixel.hytale.server.core.Options; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(Options.class) +public class MixinOptions { + + @Inject(method = "parse", at = @At("HEAD")) + private static void refixes$registerOptions(String[] args, CallbackInfoReturnable cir) { + RefixesOptions.init(); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinPageManager.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinPageManager.java new file mode 100644 index 0000000..af31f88 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinPageManager.java @@ -0,0 +1,35 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.util.Logs; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.protocol.packets.interface_.CustomPageEvent; +import com.hypixel.hytale.server.core.entity.entities.player.pages.PageManager; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(PageManager.class) +public abstract class MixinPageManager { + + @Unique + private static final HytaleLogger refixes$LOGGER = Logs.logger(); + + @Inject( + method = "handleEvent", + at = @At(value = "NEW", target = "java/lang/IllegalArgumentException"), + cancellable = true) + private void refixes$swallowUnexpectedAck( + Ref ref, + Store store, + CustomPageEvent event, + CallbackInfo ci) { + refixes$LOGGER.atWarning().log( + "PageManager#handleEvent: ignoring unexpected client acknowledgement"); + ci.cancel(); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinPlayer.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinPlayer.java new file mode 100644 index 0000000..369ee29 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinPlayer.java @@ -0,0 +1,42 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.util.Logs; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.entity.Entity; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.universe.world.World; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +// Fixes PlayerReadyEvent being dispatched on the Scheduler thread instead of the World thread. + +@Mixin(Player.class) +public abstract class MixinPlayer { + + @Shadow + public abstract void handleClientReady(boolean forced); + + @Unique + private static final HytaleLogger refixes$LOGGER = Logs.logger(); + + @Inject(method = "handleClientReady", at = @At("HEAD"), cancellable = true) + private void refixes$redirectToWorldThread(boolean forced, CallbackInfo ci) { + World world = ((Entity) (Object) this).getWorld(); + if (world == null) { + return; + } + if (!world.isInThread()) { + ci.cancel(); + if (world.isAlive()) { + world.execute(() -> handleClientReady(forced)); + } else { + refixes$LOGGER.atWarning().log( + "Player#handleClientReady(): World %s is not alive, discarding event", world.getName()); + } + } + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinPlayerChunkTrackerSystems.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinPlayerChunkTrackerSystems.java index 6f6c571..4f16a87 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinPlayerChunkTrackerSystems.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinPlayerChunkTrackerSystems.java @@ -4,6 +4,7 @@ import com.hypixel.hytale.component.AddReason; import com.hypixel.hytale.component.Holder; import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.modules.entity.player.ChunkTracker; import com.hypixel.hytale.server.core.modules.entity.player.PlayerChunkTrackerSystems; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; @@ -12,7 +13,7 @@ import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -// Applies configurable MaxChunksPerSecond and MaxChunksPerTick to ChunkTracker on player add +// Applies configurable MaxChunksPerSecond, MaxChunksPerTick, and MinLoadedChunksRadius to ChunkTracker on player add @Mixin(PlayerChunkTrackerSystems.AddSystem.class) public class MixinPlayerChunkTrackerSystems { @@ -30,5 +31,12 @@ public class MixinPlayerChunkTrackerSystems { chunkTracker.setMaxChunksPerSecond(EarlyOptions.MAX_CHUNKS_PER_SECOND.get()); chunkTracker.setMaxChunksPerTick(EarlyOptions.MAX_CHUNKS_PER_TICK.get()); + + Player player = holder.getComponent(Player.getComponentType()); + if (player != null) { + int viewRadius = player.getViewRadius(); + int offset = EarlyOptions.CHUNK_UNLOAD_OFFSET.get(); + chunkTracker.setMinLoadedChunksRadius(viewRadius + offset); + } } } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinPlayerViewRadius.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinPlayerViewRadius.java new file mode 100644 index 0000000..3749f75 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinPlayerViewRadius.java @@ -0,0 +1,38 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.EarlyOptions; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.modules.entity.player.ChunkTracker; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Player.class) +public abstract class MixinPlayerViewRadius { + + @Shadow + private PlayerRef playerRef; + + @Inject(method = "setClientViewRadius", at = @At("TAIL")) + private void refixes$updateMinLoadedRadius(int viewRadius, CallbackInfo ci) { + if (!EarlyOptions.isAvailable()) { + return; + } + + Ref ref = this.playerRef.getReference(); + if (ref == null) { + return; + } + + ChunkTracker chunkTracker = ref.getStore().getComponent(ref, ChunkTracker.getComponentType()); + if (chunkTracker != null) { + int offset = EarlyOptions.CHUNK_UNLOAD_OFFSET.get(); + chunkTracker.setMinLoadedChunksRadius(viewRadius + offset); + } + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinPortalDeviceSummonPage.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinPortalDeviceSummonPage.java index 877f615..28aa61d 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinPortalDeviceSummonPage.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinPortalDeviceSummonPage.java @@ -1,10 +1,13 @@ package cc.irori.refixes.early.mixin; +import cc.irori.refixes.early.util.Logs; import cc.irori.refixes.early.util.SharedInstanceConstants; import com.hypixel.hytale.builtin.instances.removal.InstanceDataResource; import com.hypixel.hytale.builtin.portals.resources.PortalWorld; import com.hypixel.hytale.builtin.portals.ui.PortalDeviceSummonPage; +import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.math.vector.Transform; +import com.hypixel.hytale.server.core.asset.type.portalworld.PortalType; import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.WorldConfig; import com.hypixel.hytale.server.core.universe.world.spawn.ISpawnProvider; @@ -12,6 +15,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; @@ -19,6 +23,9 @@ @Mixin(PortalDeviceSummonPage.class) public class MixinPortalDeviceSummonPage { + @Unique + private static final HytaleLogger refixes$LOGGER = Logs.logger(); + @Inject(method = "spawnReturnPortal", at = @At("HEAD"), cancellable = true) private static void refixes$preventDuplicateReturnPortal( World world, @@ -63,4 +70,26 @@ public class MixinPortalDeviceSummonPage { } } } + + /** + * Guards against PortalSpawnFinder.computeSpawnTransform() returning null, + * which causes NPE in spawnReturnPortal when calling spawnTransform.getPosition(). + */ + @Inject(method = "getSpawnTransform", at = @At("RETURN"), cancellable = true) + private static void refixes$nullGuardSpawnTransform( + PortalType portalType, + World world, + UUID sampleUuid, + CallbackInfoReturnable> cir) { + CompletableFuture future = cir.getReturnValue(); + cir.setReturnValue(future.thenApply(transform -> { + if (transform == null) { + refixes$LOGGER.atWarning().log( + "PortalDeviceSummonPage#getSpawnTransform(): null for world %s, using fallback spawn", + world.getName()); + return new Transform(0.0, 128.0, 0.0); + } + return transform; + })); + } } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinPrefabPage.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinPrefabPage.java new file mode 100644 index 0000000..dac506f --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinPrefabPage.java @@ -0,0 +1,40 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.builtin.buildertools.prefablist.PrefabPage; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.Slice; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(PrefabPage.class) +public abstract class MixinPrefabPage { + + @Shadow + private Path assetsCurrentDir; + + @Inject(method = "", at = @At("RETURN")) + private void refixes$startTopLevel(CallbackInfo ci) { + this.assetsCurrentDir = Paths.get(""); + } + + @Redirect( + method = "handleAssetsNavigation", + at = + @At( + value = "INVOKE", + target = + "Ljava/nio/file/Paths;get(Ljava/lang/String;[Ljava/lang/String;)Ljava/nio/file/Path;", + ordinal = 1), + slice = + @Slice( + from = @At(value = "CONSTANT", args = "stringValue=~"), + to = @At(value = "CONSTANT", args = "stringValue=.."))) + private Path refixes$homeToTopLevel(String first, String[] more) { + return Paths.get(""); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinRepulsionTicker.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinRepulsionTicker.java new file mode 100644 index 0000000..a5519ff --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinRepulsionTicker.java @@ -0,0 +1,24 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.server.core.modules.entity.repulsion.RepulsionSystems; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +// Pools the ObjectArrayList allocated per entity per tick to reduce GC pressure +@Mixin(RepulsionSystems.RepulsionTicker.class) +public class MixinRepulsionTicker { + + @Unique + private static final ThreadLocal refixes$resultList = + ThreadLocal.withInitial(ObjectArrayList::new); + + @Redirect(method = "tick", at = @At(value = "NEW", target = "it/unimi/dsi/fastutil/objects/ObjectArrayList")) + private ObjectArrayList refixes$poolResults() { + ObjectArrayList list = refixes$resultList.get(); + list.clear(); + return list; + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinServerAuthManager.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinServerAuthManager.java new file mode 100644 index 0000000..8967558 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinServerAuthManager.java @@ -0,0 +1,374 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.RefixesOptions; +import cc.irori.refixes.early.util.Logs; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.Options; +import com.hypixel.hytale.server.core.auth.EncryptedAuthCredentialStoreProvider; +import com.hypixel.hytale.server.core.auth.IAuthCredentialStore; +import com.hypixel.hytale.server.core.auth.ServerAuthManager; +import com.hypixel.hytale.server.core.auth.SessionServiceClient; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import joptsimple.OptionSet; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +// Controls auth store priority and OAuth token support for external auth. +@Mixin(ServerAuthManager.class) +public abstract class MixinServerAuthManager { + + @Shadow + public abstract ServerAuthManager.AuthMode getAuthMode(); + + @Shadow + public abstract boolean hasSessionToken(); + + @Shadow + public abstract boolean hasIdentityToken(); + + @Shadow + public abstract String getSessionToken(); + + @Shadow + public abstract String getIdentityToken(); + + @Shadow + private AtomicReference credentialStore; + + @Shadow + private Map availableProfiles; + + @Shadow + private SessionServiceClient sessionServiceClient; + + @Shadow + private void setExpiryAndScheduleRefresh(Instant expiry) {} + + @Unique + private static final HytaleLogger refixes$LOGGER = Logs.logger(); + + @Unique + private static final Gson refixes$GSON = new Gson(); + + @Unique + private static final String refixes$AUTH_FILE = ".hytale-auth.json"; + + @Inject(method = "initializeCredentialStore", at = @At("HEAD"), cancellable = true) + private void refixes$initCredentialStoreForExternalSession(CallbackInfo ci) { + if (getAuthMode() != ServerAuthManager.AuthMode.EXTERNAL_SESSION) { + return; + } + + // Force encrypted storage for external session to persist tokens + EncryptedAuthCredentialStoreProvider encryptedProvider = new EncryptedAuthCredentialStoreProvider(); + credentialStore.set(encryptedProvider.createStore()); + refixes$LOGGER.atInfo().log( + "Auth credential store (external session): Encrypted (forced for token persistence)"); + + IAuthCredentialStore store = credentialStore.get(); + + String envAccessToken = + refixes$readToken(RefixesOptions.OAUTH_ACCESS_TOKEN, "HYTALE_SERVER_OAUTH_ACCESS_TOKEN"); + String envRefreshToken = + refixes$readToken(RefixesOptions.OAUTH_REFRESH_TOKEN, "HYTALE_SERVER_OAUTH_REFRESH_TOKEN"); + + if (envAccessToken != null || envRefreshToken != null) { + refixes$LOGGER.atInfo().log("Loading fresh tokens from environment variables"); + Instant seededExpiry = refixes$seedOAuthTokensImpl(); + if (seededExpiry != null) { + try { + setExpiryAndScheduleRefresh(seededExpiry); + refixes$LOGGER.atInfo().log("Token refresh scheduler started (expires: %s)", seededExpiry); + } catch (Exception e) { + refixes$LOGGER.atWarning().log("Failed to schedule token refresh: %s", e.getMessage()); + } + } + } else { + IAuthCredentialStore.OAuthTokens existingTokens = store != null ? store.getTokens() : null; + if (existingTokens != null + && (existingTokens.accessToken() != null || existingTokens.refreshToken() != null)) { + Instant expiresAt = existingTokens.accessTokenExpiresAt(); + boolean accessValid = + expiresAt != null && expiresAt.isAfter(Instant.now().plusSeconds(60)); + + if (accessValid) { + refixes$LOGGER.atInfo().log( + "Using persisted tokens from credential store (expires: %s)", expiresAt); + try { + setExpiryAndScheduleRefresh(expiresAt); + } catch (Exception e) { + refixes$LOGGER.atWarning().log("Failed to schedule token refresh: %s", e.getMessage()); + } + } else if (existingTokens.refreshToken() != null) { + refixes$LOGGER.atInfo().log( + "Access token expired, but refresh token available. Attempting refresh"); + if (expiresAt != null) { + try { + setExpiryAndScheduleRefresh(expiresAt); + } catch (Exception e) { + refixes$LOGGER.atWarning().log("Failed to schedule token refresh: %s", e.getMessage()); + } + } + } else { + refixes$LOGGER.atWarning().log("Stored tokens expired and no refresh token available"); + } + } else { + refixes$LOGGER.atWarning().log("No tokens available from environment or storage"); + } + } + + String profileUuid = refixes$readToken(RefixesOptions.PROFILE_UUID, "HYTALE_PROFILE_UUID"); + if (profileUuid != null && !profileUuid.isEmpty()) { + UUID uuid = UUID.fromString(profileUuid); + if (store != null && store.getProfile() == null) { + store.setProfile(uuid); + } + String profileName = refixes$readToken(RefixesOptions.PROFILE_NAME, "HYTALE_PROFILE_NAME"); + SessionServiceClient.GameProfile profile = new SessionServiceClient.GameProfile(); + profile.uuid = uuid; + profile.username = profileName != null ? profileName : uuid.toString(); + availableProfiles.put(uuid, profile); + refixes$LOGGER.atInfo().log("Profile set from environment: %s (%s)", profile.username, profileUuid); + } + + ci.cancel(); + } + + @Inject(method = "initializeCredentialStore", at = @At("TAIL")) + private void refixes$seedOAuthTokens(CallbackInfo ci) { + refixes$seedOAuthTokensImpl(); + } + + @Shadow + private AtomicReference gameSession; + + @Shadow + private SessionServiceClient.GameSessionResponse createGameSession(UUID profileUuid) { + return null; + } + + @Shadow + private Instant getEffectiveExpiry(SessionServiceClient.GameSessionResponse response) { + return null; + } + + @Inject(method = "refreshGameSessionViaOAuth", at = @At("HEAD"), cancellable = true) + private void refixes$allowExternalSessionRefresh(CallbackInfoReturnable cir) { + if (getAuthMode() != ServerAuthManager.AuthMode.EXTERNAL_SESSION) { + return; + } + + UUID currentProfile = credentialStore.get().getProfile(); + if (currentProfile == null) { + refixes$LOGGER.atWarning().log("No current profile, cannot refresh game session via OAuth"); + cir.setReturnValue(false); + return; + } + + // Retry session creation with exponential backoff to avoid unnecessary OAuth fallback + int maxRetries = 3; + long baseDelay = 1000; + SessionServiceClient.GameSessionResponse newSession = null; + + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + newSession = createGameSession(currentProfile); + if (newSession != null) { + break; + } + } catch (Exception e) { + refixes$LOGGER.atWarning().log( + "Game session creation attempt %d/%d failed: %s", attempt, maxRetries, e.getMessage()); + } + + if (attempt < maxRetries) { + long delay = baseDelay * (1L << (attempt - 1)); + refixes$LOGGER.atInfo().log("Retrying in %dms...", delay); + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + + if (newSession == null) { + refixes$LOGGER.atWarning().log( + "Failed to create new game session after %d attempts, falling back to OAuth refresh", maxRetries); + return; + } + + gameSession.set(newSession); + Instant effectiveExpiry = getEffectiveExpiry(newSession); + if (effectiveExpiry != null) { + setExpiryAndScheduleRefresh(effectiveExpiry); + } + + refixes$LOGGER.atInfo().log("Game session refreshed via OAuth for external session"); + cir.setReturnValue(true); + } + + @Inject(method = "refreshGameSessionViaOAuth", at = @At("RETURN")) + private void refixes$syncTokensAfterOAuthRefresh(CallbackInfoReturnable cir) { + if (getAuthMode() == ServerAuthManager.AuthMode.EXTERNAL_SESSION && cir.getReturnValue() == Boolean.TRUE) { + refixes$syncTokensToEntrypoint(); + } + } + + @Inject(method = "refreshOAuthTokens(Z)Z", at = @At("RETURN")) + private void refixes$syncTokensAfterRefresh(boolean force, CallbackInfoReturnable cir) { + if (getAuthMode() == ServerAuthManager.AuthMode.EXTERNAL_SESSION && cir.getReturnValue() == Boolean.TRUE) { + refixes$syncTokensToEntrypoint(); + } + } + + @Unique + private Instant refixes$seedOAuthTokensImpl() { + String accessToken = refixes$readToken(RefixesOptions.OAUTH_ACCESS_TOKEN, "HYTALE_SERVER_OAUTH_ACCESS_TOKEN"); + String refreshToken = + refixes$readToken(RefixesOptions.OAUTH_REFRESH_TOKEN, "HYTALE_SERVER_OAUTH_REFRESH_TOKEN"); + + if (accessToken == null && refreshToken == null) { + return null; + } + + Instant expiresAt = null; + OptionSet optionSet = Options.getOptionSet(); + if (optionSet != null && optionSet.has(RefixesOptions.OAUTH_ACCESS_EXPIRES)) { + expiresAt = Instant.ofEpochSecond(optionSet.valueOf(RefixesOptions.OAUTH_ACCESS_EXPIRES)); + refixes$LOGGER.atInfo().log("OAuth access expiry loaded from CLI"); + } else { + String expiresStr = System.getenv("HYTALE_SERVER_OAUTH_ACCESS_EXPIRES"); + if (expiresStr != null) { + try { + expiresAt = Instant.ofEpochSecond(Long.parseLong(expiresStr)); + refixes$LOGGER.atInfo().log("OAuth access expiry loaded from environment"); + } catch (NumberFormatException e) { + refixes$LOGGER.atWarning().log("Invalid HYTALE_SERVER_OAUTH_ACCESS_EXPIRES value: %s", expiresStr); + } + } + } + + IAuthCredentialStore store = credentialStore.get(); + if (store != null) { + store.setTokens(new IAuthCredentialStore.OAuthTokens(accessToken, refreshToken, expiresAt)); + refixes$LOGGER.atInfo().log( + "Seeded credential store with OAuth tokens (access: %s, refresh: %s, expires: %s)", + accessToken != null ? "present" : "missing", + refreshToken != null ? "present" : "missing", + expiresAt != null ? expiresAt.toString() : "not set"); + } + + return expiresAt; + } + + @Unique + private static String refixes$readToken(joptsimple.OptionSpec cliOption, String envVar) { + OptionSet optionSet = Options.getOptionSet(); + if (optionSet != null && optionSet.has(cliOption)) { + String value = optionSet.valueOf(cliOption); + refixes$LOGGER.atInfo().log("Token loaded from CLI: %s", cliOption.toString()); + return value; + } + String envValue = System.getenv(envVar); + if (envValue != null && !envValue.isEmpty()) { + refixes$LOGGER.atInfo().log("Token loaded from environment: %s", envVar); + return envValue; + } + return null; + } + + @Unique + private void refixes$syncTokensToEntrypoint() { + try { + IAuthCredentialStore store = credentialStore.get(); + if (store == null) { + return; + } + + IAuthCredentialStore.OAuthTokens tokens = store.getTokens(); + if (tokens == null) { + return; + } + + Path authFile = Paths.get("..", refixes$AUTH_FILE); + if (!Files.exists(authFile)) { + refixes$LOGGER.atWarning().log("Auth file not found: %s", authFile); + return; + } + + String content = Files.readString(authFile); + JsonObject json = refixes$GSON.fromJson(content, JsonObject.class); + + if (tokens.accessToken() != null) { + json.addProperty("access_token", tokens.accessToken()); + } + if (tokens.refreshToken() != null) { + json.addProperty("refresh_token", tokens.refreshToken()); + } + if (tokens.accessTokenExpiresAt() != null) { + json.addProperty("access_expires", tokens.accessTokenExpiresAt().getEpochSecond()); + } + + String sessionToken = getSessionToken(); + if (sessionToken != null && !sessionToken.isEmpty()) { + json.addProperty("session_token", sessionToken); + } + + String identityToken = getIdentityToken(); + if (identityToken != null && !identityToken.isEmpty()) { + json.addProperty("identity_token", identityToken); + } + + SessionServiceClient.GameSessionResponse session = gameSession.get(); + if (session != null && session.expiresAt != null) { + try { + Instant expiresAt = Instant.parse(session.expiresAt); + json.addProperty("session_expires", expiresAt.getEpochSecond()); + } catch (Exception e) { + refixes$LOGGER.atWarning().log("Failed to parse session expiry: %s", e.getMessage()); + } + } + + UUID profileUuid = store.getProfile(); + if (profileUuid != null) { + json.addProperty("profile_uuid", profileUuid.toString()); + SessionServiceClient.GameProfile profile = availableProfiles.get(profileUuid); + if (profile != null && profile.username != null) { + json.addProperty("profile_name", profile.username); + } + } + + Files.writeString(authFile, refixes$GSON.toJson(json)); + refixes$LOGGER.atInfo().log("Synced OAuth tokens to external auth file"); + } catch (Exception e) { + refixes$LOGGER.atWarning().log("Failed to sync tokens to external auth file: %s", e.getMessage()); + } + } + + @Inject(method = "shutdown", at = @At("TAIL")) + private void refixes$terminateExternalSession(CallbackInfo ci) { + String currentSessionToken = getSessionToken(); + if (currentSessionToken != null && !currentSessionToken.isEmpty()) { + if (sessionServiceClient == null) { + sessionServiceClient = new SessionServiceClient("https://sessions.hytale.com"); + } + sessionServiceClient.terminateSession(currentSessionToken); + refixes$LOGGER.atInfo().log("Terminated session on shutdown"); + } + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinSpatialSystem.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinSpatialSystem.java new file mode 100644 index 0000000..7a1c4b6 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinSpatialSystem.java @@ -0,0 +1,58 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.util.ParallelSpatialCollector; +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.ResourceType; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.spatial.SpatialData; +import com.hypixel.hytale.component.spatial.SpatialResource; +import com.hypixel.hytale.component.spatial.SpatialSystem; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.joml.Vector3d; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Parallelizes the entity position collection phase of SpatialSystem.tick(). + * The sequential forEachChunk loop is replaced with parallel per-chunk collection + * into thread-local buffers, followed by a sequential merge into SpatialData. + * The KDTree rebuild() remains single-threaded. + */ +@Mixin(SpatialSystem.class) +public abstract class MixinSpatialSystem { + + @Shadow + @Final + private ResourceType, ECS_TYPE>> resourceType; + + @Shadow + public abstract Vector3d getPosition(@Nonnull ArchetypeChunk chunk, int index); + + @Inject(method = "tick(FILcom/hypixel/hytale/component/Store;)V", at = @At("HEAD"), cancellable = true) + private void refixes$parallelCollect(float dt, int systemIndex, Store store, CallbackInfo ci) { + ci.cancel(); + + SpatialResource, ECS_TYPE> spatialResource = store.getResource(this.resourceType); + SpatialData> spatialData = spatialResource.getSpatialData(); + spatialData.clear(); + + // Collect matching archetype chunks via forEachChunk API + List> chunks = new ArrayList<>(); + store.forEachChunk(systemIndex, (archetypeChunk, commandBuffer) -> { + chunks.add(new ParallelSpatialCollector.ChunkWork<>(archetypeChunk, this::getPosition)); + }); + + // Parallel collection and sequential merge into SpatialData + ParallelSpatialCollector.collectParallel(chunks, spatialData); + + // Rebuild the spatial structure (KDTree) single-threaded + spatialResource.getSpatialStructure().rebuild(spatialData); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinSpawnMarkerBlockStateHeartbeat.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinSpawnMarkerBlockStateHeartbeat.java new file mode 100644 index 0000000..6acf656 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinSpawnMarkerBlockStateHeartbeat.java @@ -0,0 +1,57 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.util.Logs; +import com.hypixel.hytale.component.ComponentAccessor; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.entity.reference.PersistentRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.spawning.blockstates.SpawnMarkerBlockStateSystems; +import java.util.UUID; +import java.util.logging.Level; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(SpawnMarkerBlockStateSystems.TickHeartbeat.class) +public class MixinSpawnMarkerBlockStateHeartbeat { + + @Unique + private static final HytaleLogger refixes$LOGGER = Logs.logger(); + + @Redirect( + method = "tick", + at = + @At( + value = "INVOKE", + target = + "Lcom/hypixel/hytale/server/core/entity/reference/PersistentRef;getEntity(Lcom/hypixel/hytale/component/ComponentAccessor;)Lcom/hypixel/hytale/component/Ref;")) + private Ref refixes$retryResolve(PersistentRef instance, ComponentAccessor accessor) { + Ref result = instance.getEntity(accessor); + if (result != null) { + return result; + } + UUID uuid = instance.getUuid(); + if (uuid != null) { + instance.setUuid(uuid); + result = instance.getEntity(accessor); + if (result != null) { + refixes$LOGGER.at(Level.FINE).log( + "SpawnMarkerBlockStateSystems: Recovered stale reference via UUID re-resolve"); + } + } + return result; + } + + @Redirect( + method = "tick", + at = + @At( + value = "INVOKE", + target = + "Lcom/hypixel/hytale/logger/HytaleLogger;at(Ljava/util/logging/Level;)Lcom/hypixel/hytale/logger/HytaleLogger$Api;")) + private HytaleLogger.Api refixes$downgradeLogLevel(HytaleLogger instance, Level level) { + return instance.at(Level.WARNING); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinStatModifiersManager.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinStatModifiersManager.java index 16cda71..a89fdeb 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinStatModifiersManager.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinStatModifiersManager.java @@ -17,9 +17,6 @@ public class MixinStatModifiersManager { @Inject(method = "recalculateEntityStatModifiers", at = @At("HEAD"), cancellable = true) private void refixes$throttleRecalc(CallbackInfo ci) { - if (!EarlyOptions.isAvailable() || !EarlyOptions.STAT_RECALC_THROTTLE_ENABLED.get()) { - return; - } refixes$recalcTickCounter++; if (refixes$recalcTickCounter < EarlyOptions.STAT_RECALC_INTERVAL.get()) { ci.cancel(); diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinSteeringSystem.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinSteeringSystem.java new file mode 100644 index 0000000..28898fa --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinSteeringSystem.java @@ -0,0 +1,27 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.EarlyOptions; +import com.hypixel.hytale.component.task.ParallelRangeTask; +import com.hypixel.hytale.server.npc.systems.SteeringSystem; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * Enables parallel steering when PARALLEL_ENTITY_TICKING is on. + * + * Only activates for archetype chunks exceeding the configurable threshold + * (default 64) to ensure the parallelism payoff exceeds the overhead and risk. + */ +@Mixin(SteeringSystem.class) +public class MixinSteeringSystem { + + @Inject(method = "isParallel", at = @At("HEAD"), cancellable = true) + private void refixes$parallelSteering(int archetypeChunkSize, int taskCount, CallbackInfoReturnable cir) { + int threshold = EarlyOptions.PARALLEL_STEERING_THRESHOLD.get(); + if (archetypeChunkSize >= threshold) { + cir.setReturnValue(taskCount > 0 || archetypeChunkSize > ParallelRangeTask.PARALLELISM); + } + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinStore.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinStore.java index 5099ae2..4eb79e5 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinStore.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinStore.java @@ -1,6 +1,5 @@ package cc.irori.refixes.early.mixin; -import cc.irori.refixes.early.EarlyOptions; import cc.irori.refixes.early.util.Logs; import com.hypixel.hytale.component.CommandBuffer; import com.hypixel.hytale.component.Component; @@ -10,7 +9,10 @@ import com.hypixel.hytale.logger.HytaleLogger; import java.lang.reflect.Constructor; import java.util.Deque; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ForkJoinWorkerThread; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import javax.annotation.Nonnull; import org.checkerframework.checker.nullness.compatqual.NonNullDecl; import org.spongepowered.asm.mixin.Final; @@ -20,6 +22,7 @@ import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; /** @@ -82,13 +85,7 @@ public void assertThread() { @Inject(method = "assertWriteProcessing", at = @At("HEAD"), cancellable = true) private void refixes$disableProcessingAssert(CallbackInfo ci) { - if (refixes$SUPPRESS_WRITE_ASSERT.get()) { - ci.cancel(); - return; - } - if (EarlyOptions.isAvailable() && EarlyOptions.PARALLEL_ENTITY_TICKING.get()) { - ci.cancel(); - } + ci.cancel(); } // synchronize the isEmpty + pop sequence @@ -124,4 +121,27 @@ public > void tryRemoveComponent( refixes$SUPPRESS_WRITE_ASSERT.set(false); } } + + // Redirects the CompletableFuture.join() call in shutdown0() to use a timeout + @Redirect( + method = "shutdown0", + at = @At(value = "INVOKE", target = "Ljava/util/concurrent/CompletableFuture;join()Ljava/lang/Object;")) + private Object refixes$joinWithTimeout(CompletableFuture future) { + boolean wasInterrupted = Thread.interrupted(); // clear interrupt flag + try { + return future.get(10, TimeUnit.SECONDS); + } catch (TimeoutException e) { + refixes$LOGGER.atWarning().log( + "Store#shutdown0(): saveAllResources timed out after 10s, continuing shutdown"); + return null; + } catch (Exception e) { + refixes$LOGGER.atWarning().withCause(e).log( + "Store#shutdown0(): saveAllResources failed, continuing shutdown"); + return null; + } finally { + if (wasInterrupted) { + Thread.currentThread().interrupt(); + } + } + } } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinTelemetryModule.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinTelemetryModule.java new file mode 100644 index 0000000..8d61e6e --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinTelemetryModule.java @@ -0,0 +1,23 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.server.core.HytaleServerConfig; +import com.hypixel.hytale.server.core.telemetry.TelemetryModule; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +// Default Telemetry to off; explicit `"Telemetry": {"Enabled": true}` re-enables. +@Mixin(TelemetryModule.class) +public class MixinTelemetryModule { + + @Redirect( + method = "setup", + at = + @At( + value = "INVOKE", + target = "Lcom/hypixel/hytale/server/core/HytaleServerConfig$Module;isEnabled(Z)Z")) + private boolean refixes$forceDisableTelemetry(HytaleServerConfig.Module module, boolean defaultValue) { + Boolean explicit = module.getEnabled(); + return explicit != null && explicit; + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinTeleportToPlayerCommand.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinTeleportToPlayerCommand.java new file mode 100644 index 0000000..51095dd --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinTeleportToPlayerCommand.java @@ -0,0 +1,71 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.builtin.teleport.commands.teleport.variant.TeleportToPlayerCommand; +import com.hypixel.hytale.builtin.teleport.components.TeleportHistory; +import com.hypixel.hytale.component.Component; +import com.hypixel.hytale.component.ComponentType; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.math.vector.Rotation3f; +import com.hypixel.hytale.server.core.universe.world.World; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import org.joml.Vector3d; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +/** + * Guards the deferred teleport-history append in the built-in /tp command. + * + * Race condition: the lambda's preceding store.addComponent(ref, Teleport, ...) call + * triggers a cross-world move when source and target worlds differ. The move nulls the + * source ref's archetypeChunk before this lambda completes, so the subsequent + * store.ensureAndGetComponent(ref, TeleportHistory.getComponentType()) NPEs on + * archetypeChunk.__internal_getComponent(...). A HEAD ref.isValid() check is insufficient + * because invalidation happens mid-lambda. + * + * If the lookup or append would fail, the player has already left this world; skipping the + * history entry is the correct behavior (the teleport itself has already succeeded). + */ +@Mixin(TeleportToPlayerCommand.class) +public class MixinTeleportToPlayerCommand { + + @WrapOperation( + method = "lambda$execute$1", + at = + @At( + value = "INVOKE", + target = + "Lcom/hypixel/hytale/component/Store;ensureAndGetComponent(Lcom/hypixel/hytale/component/Ref;Lcom/hypixel/hytale/component/ComponentType;)Lcom/hypixel/hytale/component/Component;")) + private static Component refixes$safeEnsureHistory( + Store store, Ref ref, ComponentType type, Operation original) { + if (ref == null || !ref.isValid()) { + return null; + } + try { + return original.call(store, ref, type); + } catch (NullPointerException e) { + return null; + } + } + + @WrapOperation( + method = "lambda$execute$1", + at = + @At( + value = "INVOKE", + target = + "Lcom/hypixel/hytale/builtin/teleport/components/TeleportHistory;append(Lcom/hypixel/hytale/server/core/universe/world/World;Lorg/joml/Vector3d;Lcom/hypixel/hytale/math/vector/Rotation3f;Ljava/lang/String;)V")) + private static void refixes$safeAppendHistory( + TeleportHistory instance, + World world, + Vector3d pos, + Rotation3f rotation, + String reason, + Operation original) { + if (instance == null) { + return; + } + original.call(instance, world, pos, rotation, reason); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinTickingThread.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinTickingThread.java index 8fda4f6..8e5490e 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinTickingThread.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinTickingThread.java @@ -6,15 +6,21 @@ import com.hypixel.hytale.server.core.util.thread.TickingThread; import java.util.concurrent.locks.LockSupport; import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Overwrite; +import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Redirect; // Replaces deprecated Thread.stop() with Thread.interrupt() on Java 21+ +// Also relaxes isInThread() for parallel entity ticking worker threads @Mixin(TickingThread.class) public class MixinTickingThread { + @Shadow + private Thread thread; + @Unique private static final HytaleLogger refixes$LOGGER = Logs.logger(); @@ -41,4 +47,17 @@ public class MixinTickingThread { // Park for 100ns, loop will re-check and spin-wait naturally as it approaches the deadline LockSupport.parkNanos(100_000L); } + + // Relaxes isInThread() to also return true for parallel entity ticking worker threads. + @Overwrite + public boolean isInThread() { + Thread current = Thread.currentThread(); + if (current.equals(this.thread)) { + return true; + } + if (current instanceof java.util.concurrent.ForkJoinWorkerThread fjwt) { + return fjwt.getPool() == java.util.concurrent.ForkJoinPool.commonPool(); + } + return false; + } } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinTriggerVolumesPlugin.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinTriggerVolumesPlugin.java new file mode 100644 index 0000000..894e3cf --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinTriggerVolumesPlugin.java @@ -0,0 +1,20 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin; +import com.hypixel.hytale.server.core.universe.world.events.RemoveWorldEvent; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +// Skip cleanup when the world's entity store wasn't fully initialized (init failure path). +@Mixin(TriggerVolumesPlugin.class) +public class MixinTriggerVolumesPlugin { + + @Inject(method = "onWorldRemoved", at = @At("HEAD"), cancellable = true) + private void refixes$nullGuardOnWorldRemoved(RemoveWorldEvent event, CallbackInfo ci) { + if (event.getWorld().getEntityStore().getStore() == null) { + ci.cancel(); + } + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinTurnOffTeleportersSystem.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinTurnOffTeleportersSystem.java new file mode 100644 index 0000000..8b6fa99 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinTurnOffTeleportersSystem.java @@ -0,0 +1,67 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.util.Logs; +import com.hypixel.hytale.builtin.adventure.teleporter.system.TurnOffTeleportersSystem; +import com.hypixel.hytale.component.AddReason; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.RemoveReason; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.ChunkStore; +import javax.annotation.Nonnull; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Overwrite; +import org.spongepowered.asm.mixin.Unique; + +/** + * Defers TurnOffTeleportersSystem.updatePortalBlocksInWorld() calls to world.execute() + * instead of running inline during onEntityAdded/onEntityRemove callbacks. + */ +@Mixin(TurnOffTeleportersSystem.class) +public class MixinTurnOffTeleportersSystem { + + @Unique + private static final HytaleLogger refixes$LOGGER = Logs.logger(); + + @Overwrite + public void onEntityAdded( + @Nonnull Ref ref, + @Nonnull AddReason reason, + @Nonnull Store store, + @Nonnull CommandBuffer commandBuffer) { + if (reason == AddReason.LOAD) { + World world = store.getExternalData().getWorld(); + world.execute(() -> { + try { + TurnOffTeleportersSystem.updatePortalBlocksInWorld(world); + } catch (Exception e) { + refixes$LOGGER.atWarning().withCause(e).log( + "TurnOffTeleportersSystem#onEntityAdded(): Failed to update portal blocks in %s", + world.getName()); + } + }); + } + } + + @Overwrite + public void onEntityRemove( + @Nonnull Ref ref, + @Nonnull RemoveReason reason, + @Nonnull Store store, + @Nonnull CommandBuffer commandBuffer) { + if (reason == RemoveReason.REMOVE) { + World world = store.getExternalData().getWorld(); + world.execute(() -> { + try { + TurnOffTeleportersSystem.updatePortalBlocksInWorld(world); + } catch (Exception e) { + refixes$LOGGER.atWarning().withCause(e).log( + "TurnOffTeleportersSystem#onEntityRemove(): Failed to update portal blocks in %s", + world.getName()); + } + }); + } + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinUniverse.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinUniverse.java index 0e52e00..c843af8 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinUniverse.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinUniverse.java @@ -1,6 +1,7 @@ package cc.irori.refixes.early.mixin; import cc.irori.refixes.early.util.Logs; +import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.Universe; @@ -23,10 +24,16 @@ public abstract class MixinUniverse { @Shadow protected abstract void lambda$removePlayer$2(PlayerRef par1, Void par2, Throwable par3); + @Inject(method = "lambda$removePlayer$0", at = @At("HEAD"), cancellable = true) + private static void refixes$guardAsyncRemoval(Ref ref, CallbackInfo ci) { + if (!ref.isValid()) { + ci.cancel(); + } + } + @Inject(method = "lambda$removePlayer$2", at = @At("HEAD"), cancellable = true) private void refixes$wrapRemovePlayerComplete(PlayerRef playerRef, Void result, Throwable error, CallbackInfo ci) { if (refixes$WRAPPING.get()) { - // Run the original method return; } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinUpdateCheckCommand.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinUpdateCheckCommand.java new file mode 100644 index 0000000..5cb4ca3 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinUpdateCheckCommand.java @@ -0,0 +1,22 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.server.core.auth.ServerAuthManager; +import com.hypixel.hytale.server.core.update.command.UpdateCheckCommand; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +// accept identity tokens in /update check +@Mixin(UpdateCheckCommand.class) +public class MixinUpdateCheckCommand { + + @Redirect( + method = "executeAsync", + at = + @At( + value = "INVOKE", + target = "Lcom/hypixel/hytale/server/core/auth/ServerAuthManager;hasSessionToken()Z")) + private boolean refixes$broaderAuthCheck(ServerAuthManager authManager) { + return authManager.hasSessionToken() || authManager.hasIdentityToken(); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinUpdateDownloadCommand.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinUpdateDownloadCommand.java new file mode 100644 index 0000000..52ec456 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinUpdateDownloadCommand.java @@ -0,0 +1,22 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.server.core.auth.ServerAuthManager; +import com.hypixel.hytale.server.core.update.command.UpdateDownloadCommand; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +// accept identity tokens in /update download +@Mixin(UpdateDownloadCommand.class) +public class MixinUpdateDownloadCommand { + + @Redirect( + method = "executeAsync", + at = + @At( + value = "INVOKE", + target = "Lcom/hypixel/hytale/server/core/auth/ServerAuthManager;hasSessionToken()Z")) + private boolean refixes$broaderAuthCheck(ServerAuthManager authManager) { + return authManager.hasSessionToken() || authManager.hasIdentityToken(); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinUpdateModule.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinUpdateModule.java new file mode 100644 index 0000000..167ab93 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinUpdateModule.java @@ -0,0 +1,22 @@ +package cc.irori.refixes.early.mixin; + +import com.hypixel.hytale.server.core.auth.ServerAuthManager; +import com.hypixel.hytale.server.core.update.UpdateModule; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +// UpdateModule.performUpdateCheck() now support external auth +@Mixin(UpdateModule.class) +public class MixinUpdateModule { + + @Redirect( + method = "performUpdateCheck", + at = + @At( + value = "INVOKE", + target = "Lcom/hypixel/hytale/server/core/auth/ServerAuthManager;hasSessionToken()Z")) + private boolean refixes$broaderAuthCheck(ServerAuthManager instance) { + return instance.hasSessionToken() || instance.hasIdentityToken(); + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinVoiceModule.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinVoiceModule.java new file mode 100644 index 0000000..91c4db9 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinVoiceModule.java @@ -0,0 +1,35 @@ +package cc.irori.refixes.early.mixin; + +import cc.irori.refixes.early.util.Logs; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.modules.voice.VoiceModule; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(VoiceModule.class) +public class MixinVoiceModule { + + @Unique + private static final HytaleLogger refixes$LOGGER = Logs.logger(); + + @Redirect( + method = "lambda$updateAllPlayerPositions$1", + at = + @At( + value = "INVOKE", + target = + "Lcom/hypixel/hytale/server/core/universe/world/storage/EntityStore;getWorld()Lcom/hypixel/hytale/server/core/universe/world/World;")) + private World refixes$skipIfPlayerMovedWorlds(EntityStore externalData) { + World freshWorld = externalData.getWorld(); + if (freshWorld != null && !freshWorld.isInThread()) { + refixes$LOGGER.atFine().log( + "VoiceModule: skipped position update for player no longer on this world's thread"); + return null; + } + return freshWorld; + } +} diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinWorld.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinWorld.java index 35e8a0f..dbfd660 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinWorld.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinWorld.java @@ -4,11 +4,18 @@ import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.math.vector.Transform; +import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import java.util.Collections; +import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; @@ -21,6 +28,21 @@ public class MixinWorld { @Unique private static final HytaleLogger refixes$LOGGER = Logs.logger(); + @Shadow + @Final + private EntityStore entityStore; + + // Pre-release init race: WorldMapManager.setGenerator() iterates world.getPlayers() + // during World.init(), but entityStore.getStore() is still null until init's + // entityStore.start(resourceStorage) call later in the same flow. Empty list keeps + // the loop a no-op without NPE. + @Inject(method = "getPlayers()Ljava/util/List;", at = @At("HEAD"), cancellable = true) + private void refixes$nullGuardGetPlayers(CallbackInfoReturnable> cir) { + if (this.entityStore == null || this.entityStore.getStore() == null) { + cir.setReturnValue(Collections.emptyList()); + } + } + @Redirect( method = "addPlayer(Lcom/hypixel/hytale/server/core/universe/PlayerRef;Lcom/hypixel/hytale/math/vector/Transform;Ljava/lang/Boolean;Ljava/lang/Boolean;)Ljava/util/concurrent/CompletableFuture;", @@ -75,4 +97,25 @@ public class MixinWorld { } return false; } + + // Redirects the config save .join() in onShutdown() to use a timeout + @Redirect( + method = "onShutdown", + at = @At(value = "INVOKE", target = "Ljava/util/concurrent/CompletableFuture;join()Ljava/lang/Object;")) + private Object refixes$configSaveJoinWithTimeout(CompletableFuture future) { + boolean wasInterrupted = Thread.interrupted(); + try { + return future.get(10, TimeUnit.SECONDS); + } catch (TimeoutException e) { + refixes$LOGGER.atWarning().log("World#onShutdown(): Config save timed out after 10s, continuing shutdown"); + return null; + } catch (Exception e) { + refixes$LOGGER.atWarning().withCause(e).log("World#onShutdown(): Config save failed, continuing shutdown"); + return null; + } finally { + if (wasInterrupted) { + Thread.currentThread().interrupt(); + } + } + } } diff --git a/early/src/main/java/cc/irori/refixes/early/mixin/MixinWorldMapTracker.java b/early/src/main/java/cc/irori/refixes/early/mixin/MixinWorldMapTracker.java index c1d5f6d..083db1c 100644 --- a/early/src/main/java/cc/irori/refixes/early/mixin/MixinWorldMapTracker.java +++ b/early/src/main/java/cc/irori/refixes/early/mixin/MixinWorldMapTracker.java @@ -39,4 +39,16 @@ public abstract class MixinWorldMapTracker { refixes$WRAPPING.set(false); } } + + // Hytale's CommandManager dispatches commands on a ForkJoinPool. /op add fires + // PermissionsModule events synchronously, which call WorldMapTracker.resendWorldMapSettings + // for every player. That method calls PlayerRef.getComponent(Player), which asserts + // it's on the world tick thread and logs SkipSentryException per player otherwise. + // Skip the off-thread call; the next regular tick refresh covers the update. + @Inject(method = "resendWorldMapSettings", at = @At("HEAD"), cancellable = true) + private void refixes$skipResendOffThread(CallbackInfo ci) { + if (Thread.currentThread() instanceof java.util.concurrent.ForkJoinWorkerThread) { + ci.cancel(); + } + } } diff --git a/early/src/main/java/cc/irori/refixes/early/util/ParallelSpatialCollector.java b/early/src/main/java/cc/irori/refixes/early/util/ParallelSpatialCollector.java new file mode 100644 index 0000000..5ccb419 --- /dev/null +++ b/early/src/main/java/cc/irori/refixes/early/util/ParallelSpatialCollector.java @@ -0,0 +1,112 @@ +package cc.irori.refixes.early.util; + +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.spatial.SpatialData; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; +import org.joml.Vector3d; + +/** + * Parallelizes the entity position collection phase of SpatialSystem.tick(). + * Each ForkJoin worker collects (position, ref) pairs into a local buffer, + * then all buffers are merged sequentially into SpatialData. + */ +public final class ParallelSpatialCollector { + + private ParallelSpatialCollector() {} + + // A collected spatial entry: position + entity reference. + public static final class Entry { + public final double x, y, z; + public final Ref ref; + + public Entry(double x, double y, double z, Ref ref) { + this.x = x; + this.y = y; + this.z = z; + this.ref = ref; + } + } + + // A work unit: one archetype chunk to collect positions from. + public static final class ChunkWork { + public final ArchetypeChunk chunk; + public final PositionExtractor extractor; + + public ChunkWork(ArchetypeChunk chunk, PositionExtractor extractor) { + this.chunk = chunk; + this.extractor = extractor; + } + } + + @FunctionalInterface + public interface PositionExtractor { + Vector3d getPosition(ArchetypeChunk chunk, int index); + } + + // Collects entity positions from the given chunks in parallel, then merges into spatialData. + public static void collectParallel( + List> chunks, SpatialData> spatialData) { + if (chunks.isEmpty()) { + return; + } + + // For small workloads, collect sequentially + if (chunks.size() <= 2) { + collectSequential(chunks, spatialData); + return; + } + + ForkJoinPool pool = ForkJoinPool.commonPool(); + List>>> tasks = new ArrayList<>(chunks.size()); + + for (ChunkWork work : chunks) { + tasks.add(pool.submit(() -> collectChunk(work))); + } + + // Merge results sequentially into SpatialData + for (ForkJoinTask>> task : tasks) { + List> entries = task.join(); + if (entries.isEmpty()) { + continue; + } + spatialData.addCapacity(entries.size()); + for (Entry entry : entries) { + spatialData.append(new Vector3d(entry.x, entry.y, entry.z), entry.ref); + } + } + } + + private static List> collectChunk(ChunkWork work) { + int size = work.chunk.size(); + List> entries = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + Vector3d position = work.extractor.getPosition(work.chunk, i); + if (position == null) { + continue; + } + Ref ref = work.chunk.getReferenceTo(i); + entries.add(new Entry<>(position.x, position.y, position.z, ref)); + } + return entries; + } + + private static void collectSequential( + List> chunks, SpatialData> spatialData) { + for (ChunkWork work : chunks) { + int size = work.chunk.size(); + spatialData.addCapacity(size); + for (int i = 0; i < size; i++) { + Vector3d position = work.extractor.getPosition(work.chunk, i); + if (position == null) { + continue; + } + Ref ref = work.chunk.getReferenceTo(i); + spatialData.append(position, ref); + } + } + } +} diff --git a/early/src/main/resources/manifest.json b/early/src/main/resources/manifest.json index f4de02f..b517198 100644 --- a/early/src/main/resources/manifest.json +++ b/early/src/main/resources/manifest.json @@ -10,7 +10,7 @@ "Url": "https://github.com/KabanFriends" } ], - "ServerVersion": "${hytaleVersion}", + "ServerVersion": ">=${hytaleVersion}", "Dependencies": {}, "OptionalDependencies": {}, "Mixins": [ diff --git a/early/src/main/resources/refixes.mixins.json b/early/src/main/resources/refixes.mixins.json index 223567c..6c6740e 100644 --- a/early/src/main/resources/refixes.mixins.json +++ b/early/src/main/resources/refixes.mixins.json @@ -2,50 +2,82 @@ "required": true, "minVersion": "0.8", "package": "cc.irori.refixes.early.mixin", + "plugin": "cc.irori.refixes.early.RefixesMixinPlugin", "mixins": [ "MixinArchetypeChunk", + "MixinAssetPrefabFileProvider", "MixinBeaconAddRemoveSystem", + "MixinBlockChunk", "MixinBlockComponentChunk", "MixinBlockHealthSystem", "MixinBlockModule", + "MixinBlockSection", "MixinChunkReplicateChanges", "MixinChunkSavingSystems", + "MixinChunkUnloadingSystem", + "MixinChunkUnloadingSystem$ChunkTrackerAccessor", + "MixinChunkUnloadingSystem$DataAccessor", "MixinCollectVisible", "MixinCommandBuffer", "MixinCraftingManagerAccessor", "MixinEntityTickingSystem", + "MixinEntityViewer", + "MixinFillerBlockUtil", + "MixinFloodLightCalculation", "MixinFluidPlugin", "MixinFluidReplicateChanges", "MixinGamePacketHandler", + "MixinHideEntitySystems", "MixinHytaleServer", - "MixinHytaleServerConfig", "MixinInstancesPlugin", "MixinInteractionChain", "MixinInteractionManager", "MixinKDTree", + "MixinLiveConfigModule", "MixinMarkerAddRemoveSystem", + "MixinNPCKillsEntitySystem", + "MixinOptions", + "MixinPageManager", "MixinPlayerChunkTrackerSystems", + "MixinPlayerViewRadius", "MixinPortalDeviceSummonPage", "MixinPortalWorldAccessor", "MixinPrefabLoader", + "MixinPrefabPage", "MixinRemovalSystem", + "MixinServerAuthManager", "MixinSetMemoriesCapacityInteraction", + "MixinSpawnMarkerBlockStateHeartbeat", "MixinStateSupport", + "MixinStatModifiersManager", "MixinStore", + "MixinTelemetryModule", + "MixinTeleportToPlayerCommand", "MixinTickingSpawnMarkerSystem", "MixinTickingThread", "MixinTickingThreadAssert", "MixinTrackedPlacementAccessor", "MixinTrackedPlacementOnAddRemove", + "MixinTriggerVolumesPlugin", "MixinUniverse", + "MixinUpdateCheckCommand", + "MixinUpdateDownloadCommand", + "MixinUpdateModule", "MixinUUIDSystem", + "MixinVoiceModule", "MixinWorld", "MixinWorldConfig", "MixinWorldMapTracker", "MixinWorldSpawningSystem", - "MixinBlockChunk", - "MixinBlockSection", - "MixinFloodLightCalculation", - "MixinStatModifiersManager" + "MixinAStarBase", + "MixinBlockSectionSafety", + "MixinChunkLightDataSerializeSafety", + "MixinEntityChunkLoadingSystem", + "MixinMotionControllerBase", + "MixinPlayer", + "MixinRepulsionTicker", + "MixinSpatialSystem", + "MixinSteeringSystem", + "MixinTurnOffTeleportersSystem" ] } diff --git a/gradle.properties b/gradle.properties index 27251af..32bf6fd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,3 @@ mod_group = cc.irori.refixes -mod_version = 0.2.0 +mod_version = 0.4.0 +hytale_server_version = 0.5.0-pre.8 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca57e05..21c9dcb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] shadow = "9.3.1" spotless = "8.2.1" -hytale = "2026.02.18-f3b8fff95" +hytale = "2026.04.30-b4f6a911e" mixin = "0.17.0+mixin.0.8.7" mixinextras = "0.5.3" guava = "33.5.0-jre" diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index ef39076..6b3a44e 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -22,6 +22,7 @@ tasks { shadowJar { relocate("com.google", "cc.irori.refixes.lib.com.google") { exclude("com.google.common.flogger.*") + exclude("com.google.gson.*") } relocate("org.jspecify", "cc.irori.refixes.lib.org.jspecify") } diff --git a/plugin/src/main/java/cc/irori/refixes/Refixes.java b/plugin/src/main/java/cc/irori/refixes/Refixes.java index 167249f..e3083d8 100644 --- a/plugin/src/main/java/cc/irori/refixes/Refixes.java +++ b/plugin/src/main/java/cc/irori/refixes/Refixes.java @@ -1,5 +1,6 @@ package cc.irori.refixes; +import cc.irori.refixes.command.ChunkLoaderCommand; import cc.irori.refixes.component.TickThrottled; import cc.irori.refixes.config.impl.AiTickThrottlerConfig; import cc.irori.refixes.config.impl.ChunkUnloaderConfig; @@ -15,13 +16,17 @@ import cc.irori.refixes.config.impl.SystemConfig; import cc.irori.refixes.config.impl.TickSleepOptimizationConfig; import cc.irori.refixes.config.impl.WatchdogConfig; +import cc.irori.refixes.copychunks.CopyChunksCommand; +import cc.irori.refixes.copychunks.PasteChunksCommand; import cc.irori.refixes.early.EarlyOptions; import cc.irori.refixes.early.util.TickSleepOptimization; +import cc.irori.refixes.listener.ChunkLoaderWorldListener; import cc.irori.refixes.listener.InstancePositionTracker; import cc.irori.refixes.listener.SharedInstanceBootUnloader; import cc.irori.refixes.listener.UnknownBlockCleaner; import cc.irori.refixes.service.ActiveChunkUnloader; import cc.irori.refixes.service.AiTickThrottlerService; +import cc.irori.refixes.service.ChunkLoaderService; import cc.irori.refixes.service.IdlePlayerService; import cc.irori.refixes.service.PerPlayerHotRadiusService; import cc.irori.refixes.service.WatchdogService; @@ -29,7 +34,6 @@ import cc.irori.refixes.system.CraftingManagerFixSystem; import cc.irori.refixes.system.EntityDespawnTimerSystem; import cc.irori.refixes.system.InteractionManagerFixSystem; -import cc.irori.refixes.system.ProcessingBenchFixSystem; import cc.irori.refixes.system.RespawnBlockFixSystem; import cc.irori.refixes.system.SharedInstancePersistenceSystem; import cc.irori.refixes.util.Early; @@ -64,11 +68,13 @@ public class Refixes extends JavaPlugin { private IdlePlayerService idlePlayerService; private AiTickThrottlerService aiTickThrottler; + private ChunkLoaderService chunkLoaderService; public Refixes(@NonNullDecl JavaPluginInit init) { super(init); instance = this; config = withConfig(RefixesConfig.get().getCodec()); + chunkLoaderService = new ChunkLoaderService(init.getDataDirectory()); } @Override @@ -143,40 +149,35 @@ private void registerEarlyOptions() { EarlyOptions.FORCE_SKIP_MOD_VALIDATION.setSupplier( () -> config.getValue(EarlyConfig.FORCE_SKIP_MOD_VALIDATION)); - EarlyOptions.DISABLE_FLUID_PRE_PROCESS.setSupplier( - () -> config.getValue(EarlyConfig.DISABLE_FLUID_PRE_PROCESS)); - EarlyOptions.ASYNC_BLOCK_PRE_PROCESS.setSupplier(() -> config.getValue(EarlyConfig.ASYNC_BLOCK_PRE_PROCESS)); EarlyOptions.MAX_CHUNKS_PER_SECOND.setSupplier(() -> config.getValue(EarlyConfig.MAX_CHUNKS_PER_SECOND)); EarlyOptions.MAX_CHUNKS_PER_TICK.setSupplier(() -> config.getValue(EarlyConfig.MAX_CHUNKS_PER_TICK)); + EarlyOptions.CHUNK_UNLOAD_OFFSET.setSupplier( + () -> ChunkUnloaderConfig.get().getValue(ChunkUnloaderConfig.UNLOAD_DISTANCE_OFFSET)); + EarlyOptions.VANILLA_KEEP_SPAWN_LOADED.setSupplier( + () -> config.getValue(EarlyConfig.VANILLA_KEEP_SPAWN_LOADED)); - EarlyOptions.CYLINDER_VISIBILITY_ENABLED.setSupplier( - () -> cylinderVisibilityConfig.getValue(CylinderVisibilityConfig.ENABLED)); EarlyOptions.CYLINDER_VISIBILITY_HEIGHT_MULTIPLIER.setSupplier( () -> cylinderVisibilityConfig.getValue(CylinderVisibilityConfig.HEIGHT_MULTIPLIER)); - EarlyOptions.KDTREE_OPTIMIZATION_OPTIMIZE_SORT.setSupplier( - () -> kdTreeOptimizationConfig.getValue(KDTreeOptimizationConfig.OPTIMIZE_KDTREE_SORT)); EarlyOptions.KDTREE_OPTIMIZATION_THRESHOLD.setSupplier( () -> kdTreeOptimizationConfig.getValue(KDTreeOptimizationConfig.SPATIAL_FAST_SORT_THRESHOLD)); - EarlyOptions.SHARED_INSTANCES_ENABLED.setSupplier( - () -> sharedInstanceConfig.getValue(SharedInstanceConfig.ENABLED)); EarlyOptions.SHARED_INSTANCES_EXCLUDED_PREFIXES.setSupplier( () -> sharedInstanceConfig.getValue(SharedInstanceConfig.EXCLUDED_PREFIXES)); - EarlyOptions.PARALLEL_ENTITY_TICKING.setSupplier( - () -> experimentalConfig.getValue(ExperimentalConfig.PARALLEL_ENTITY_TICKING)); + EarlyOptions.PARALLEL_STEERING_THRESHOLD.setSupplier( + () -> experimentalConfig.getValue(ExperimentalConfig.PARALLEL_STEERING_THRESHOLD)); - EarlyOptions.BLOCK_ENTITY_SLEEP_ENABLED.setSupplier( - () -> config.getValue(EarlyConfig.BLOCK_ENTITY_SLEEP_ENABLED)); EarlyOptions.BLOCK_ENTITY_SLEEP_INTERVAL.setSupplier( () -> config.getValue(EarlyConfig.BLOCK_ENTITY_SLEEP_INTERVAL)); - EarlyOptions.STAT_RECALC_THROTTLE_ENABLED.setSupplier( - () -> config.getValue(EarlyConfig.STAT_RECALC_THROTTLE_ENABLED)); EarlyOptions.STAT_RECALC_INTERVAL.setSupplier(() -> config.getValue(EarlyConfig.STAT_RECALC_INTERVAL)); - EarlyOptions.SECTION_CACHE_ENABLED.setSupplier(() -> config.getValue(EarlyConfig.SECTION_CACHE_ENABLED)); - EarlyOptions.SKIP_EMPTY_LIGHT_SECTIONS.setSupplier( - () -> config.getValue(EarlyConfig.SKIP_EMPTY_LIGHT_SECTIONS)); + + EarlyOptions.PATHFINDING_MAX_PATH_LENGTH.setSupplier( + () -> config.getValue(EarlyConfig.PATHFINDING_MAX_PATH_LENGTH)); + EarlyOptions.PATHFINDING_OPEN_NODES_LIMIT.setSupplier( + () -> config.getValue(EarlyConfig.PATHFINDING_OPEN_NODES_LIMIT)); + EarlyOptions.PATHFINDING_TOTAL_NODES_LIMIT.setSupplier( + () -> config.getValue(EarlyConfig.PATHFINDING_TOTAL_NODES_LIMIT)); EarlyOptions.setAvailable(true); @@ -213,10 +214,6 @@ private void registerFixes() { "Respawn block fix", SystemConfig.get().getValue(SystemConfig.RESPAWN_BLOCK), () -> getChunkStoreRegistry().registerSystem(new RespawnBlockFixSystem())); - applyFix( - "Processing bench fix", - SystemConfig.get().getValue(SystemConfig.PROCESSING_BENCH), - () -> getChunkStoreRegistry().registerSystem(new ProcessingBenchFixSystem())); applyFix( "Crafting manager fix", SystemConfig.get().getValue(SystemConfig.CRAFTING_MANAGER) @@ -241,10 +238,10 @@ private void registerFixes() { "Per-player hot radius", PerPlayerHotRadiusConfig.get().getValue(PerPlayerHotRadiusConfig.ENABLED), () -> perPlayerHotRadiusService = new PerPlayerHotRadiusService()); - applyFix( - "Server watchdog", - WatchdogConfig.get().getValue(WatchdogConfig.ENABLED), - () -> watchdogService = new WatchdogService()); + applyFix("Server watchdog", WatchdogConfig.get().getValue(WatchdogConfig.ENABLED), () -> { + watchdogService = new WatchdogService(); + watchdogService.registerEvents(this); + }); applyFix( "Idle player handler", @@ -255,6 +252,11 @@ private void registerFixes() { AiTickThrottlerConfig.get().getValue(AiTickThrottlerConfig.ENABLED), () -> aiTickThrottler = new AiTickThrottlerService()); + getCommandRegistry().registerCommand(new ChunkLoaderCommand(chunkLoaderService)); + getCommandRegistry().registerCommand(new CopyChunksCommand()); + getCommandRegistry().registerCommand(new PasteChunksCommand()); + new ChunkLoaderWorldListener(chunkLoaderService).registerEvents(this); + applyFix( "Shared instance worlds", SharedInstanceConfig.get().getValue(SharedInstanceConfig.ENABLED) diff --git a/plugin/src/main/java/cc/irori/refixes/command/ChunkLoaderCommand.java b/plugin/src/main/java/cc/irori/refixes/command/ChunkLoaderCommand.java new file mode 100644 index 0000000..bbeb867 --- /dev/null +++ b/plugin/src/main/java/cc/irori/refixes/command/ChunkLoaderCommand.java @@ -0,0 +1,209 @@ +package cc.irori.refixes.command; + +import cc.irori.refixes.service.ChunkLoaderService; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.math.util.ChunkUtil; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.permissions.HytalePermissions; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import java.util.Map; + +public class ChunkLoaderCommand extends CommandBase { + + private final ChunkLoaderService service; + + public ChunkLoaderCommand(ChunkLoaderService service) { + super("chunkloader", "refixes.commands.chunkloader.desc"); + this.service = service; + + this.addSubCommand(new AddCommand(service)); + this.addSubCommand(new RemoveCommand(service)); + this.addSubCommand(new ListCommand(service)); + } + + @Override + protected void executeSync(CommandContext context) { + context.sender().sendMessage(Message.raw("§cUsage: /refixes chunkloader ")); + } + + private static class AddCommand extends CommandBase { + private final ChunkLoaderService service; + private final OptionalArg worldArg; + private final OptionalArg xArg; + private final OptionalArg zArg; + private final OptionalArg labelArg; + + AddCommand(ChunkLoaderService service) { + super("add", "refixes.commands.chunkloader.add.desc"); + this.service = service; + this.worldArg = this.withOptionalArg("world", "target world", ArgTypes.WORLD); + this.xArg = this.withOptionalArg("x", "chunk x coordinate", ArgTypes.INTEGER); + this.zArg = this.withOptionalArg("z", "chunk z coordinate", ArgTypes.INTEGER); + this.labelArg = this.withOptionalArg("label", "chunk loader label", ArgTypes.STRING); + this.requirePermission(HytalePermissions.fromCommand("refixes.chunkloader.add")); + } + + @Override + protected void executeSync(CommandContext context) { + World world = worldArg.getProcessed(context); + if (world == null && !context.isPlayer()) { + context.sender().sendMessage(Message.raw("§cProvide world argument or run as a player")); + return; + } + if (world == null) { + Ref playerRef = context.senderAsPlayerRef(); + world = playerRef.getStore().getExternalData().getWorld(); + } + + int chunkX, chunkZ; + if (xArg.provided(context) && zArg.provided(context)) { + chunkX = xArg.getProcessed(context); + chunkZ = zArg.getProcessed(context); + } else if (!context.isPlayer()) { + context.sender().sendMessage(Message.raw("§cProvide coordinates or run as a player")); + return; + } else { + Ref playerRef = context.senderAsPlayerRef(); + TransformComponent transformComponent = + playerRef.getStore().getComponent(playerRef, TransformComponent.getComponentType()); + if (transformComponent == null) { + context.sender().sendMessage(Message.raw("§cCould not get player position")); + return; + } + chunkX = ChunkUtil.chunkCoordinate( + (int) transformComponent.getTransform().getPosition().x()); + chunkZ = ChunkUtil.chunkCoordinate( + (int) transformComponent.getTransform().getPosition().z()); + } + + String label = labelArg.provided(context) ? labelArg.getProcessed(context) : null; + service.addChunk(world, chunkX, chunkZ, label); + + String message = "§aAdded chunk loader at " + chunkX + ", " + chunkZ; + if (label != null && !label.isEmpty()) { + message += " (" + label + ")"; + } + context.sender().sendMessage(Message.raw(message)); + } + } + + private static class RemoveCommand extends CommandBase { + private final ChunkLoaderService service; + private final OptionalArg worldArg; + private final OptionalArg xArg; + private final OptionalArg zArg; + private final OptionalArg labelArg; + + RemoveCommand(ChunkLoaderService service) { + super("remove", "refixes.commands.chunkloader.remove.desc"); + this.service = service; + this.worldArg = this.withOptionalArg("world", "target world", ArgTypes.WORLD); + this.xArg = this.withOptionalArg("x", "chunk x coordinate", ArgTypes.INTEGER); + this.zArg = this.withOptionalArg("z", "chunk z coordinate", ArgTypes.INTEGER); + this.labelArg = this.withOptionalArg("label", "chunk loader label", ArgTypes.STRING); + this.requirePermission(HytalePermissions.fromCommand("refixes.chunkloader.remove")); + } + + @Override + protected void executeSync(CommandContext context) { + World world = worldArg.getProcessed(context); + if (world == null && !context.isPlayer()) { + context.sender().sendMessage(Message.raw("§cProvide world argument or run as a player")); + return; + } + if (world == null) { + Ref playerRef = context.senderAsPlayerRef(); + world = playerRef.getStore().getExternalData().getWorld(); + } + + if (labelArg.provided(context)) { + String label = labelArg.getProcessed(context); + Long chunkIndex = service.findChunkByLabel(world.getName(), label); + if (chunkIndex == null) { + context.sender().sendMessage(Message.raw("§cNo chunk loader found with label: " + label)); + return; + } + int x = ChunkUtil.xOfChunkIndex(chunkIndex); + int z = ChunkUtil.zOfChunkIndex(chunkIndex); + service.removeChunk(world, x, z); + context.sender() + .sendMessage(Message.raw("§aRemoved chunk loader at " + x + ", " + z + " (" + label + ")")); + return; + } + + int chunkX, chunkZ; + if (xArg.provided(context) && zArg.provided(context)) { + chunkX = xArg.getProcessed(context); + chunkZ = zArg.getProcessed(context); + } else if (!context.isPlayer()) { + context.sender().sendMessage(Message.raw("§cProvide coordinates, label, or run as a player")); + return; + } else { + Ref playerRef = context.senderAsPlayerRef(); + TransformComponent transformComponent = + playerRef.getStore().getComponent(playerRef, TransformComponent.getComponentType()); + if (transformComponent == null) { + context.sender().sendMessage(Message.raw("§cCould not get player position")); + return; + } + chunkX = ChunkUtil.chunkCoordinate( + (int) transformComponent.getTransform().getPosition().x()); + chunkZ = ChunkUtil.chunkCoordinate( + (int) transformComponent.getTransform().getPosition().z()); + } + + service.removeChunk(world, chunkX, chunkZ); + context.sender().sendMessage(Message.raw("§aRemoved chunk loader at " + chunkX + ", " + chunkZ)); + } + } + + private static class ListCommand extends CommandBase { + private final ChunkLoaderService service; + private final OptionalArg worldArg; + + ListCommand(ChunkLoaderService service) { + super("list", "refixes.commands.chunkloader.list.desc"); + this.service = service; + this.worldArg = this.withOptionalArg("world", "target world", ArgTypes.WORLD); + this.requirePermission(HytalePermissions.fromCommand("refixes.chunkloader.list")); + } + + @Override + protected void executeSync(CommandContext context) { + World world = worldArg.getProcessed(context); + if (world == null && !context.isPlayer()) { + context.sender().sendMessage(Message.raw("§cProvide world argument or run as a player")); + return; + } + if (world == null) { + Ref playerRef = context.senderAsPlayerRef(); + world = playerRef.getStore().getExternalData().getWorld(); + } + + Map chunks = service.getKeptChunks(world.getName()); + if (chunks.isEmpty()) { + context.sender().sendMessage(Message.raw("§eNo chunk loaders in this world")); + return; + } + + context.sender().sendMessage(Message.raw("§aChunk loaders in " + world.getName() + ":")); + for (Map.Entry entry : chunks.entrySet()) { + int x = ChunkUtil.xOfChunkIndex(entry.getKey()); + int z = ChunkUtil.zOfChunkIndex(entry.getKey()); + String label = entry.getValue(); + + String message = " - " + x + ", " + z; + if (label != null && !label.isEmpty()) { + message += " (" + label + ")"; + } + context.sender().sendMessage(Message.raw(message)); + } + } + } +} diff --git a/plugin/src/main/java/cc/irori/refixes/config/impl/AiTickThrottlerConfig.java b/plugin/src/main/java/cc/irori/refixes/config/impl/AiTickThrottlerConfig.java index a24e152..fc0fdd1 100644 --- a/plugin/src/main/java/cc/irori/refixes/config/impl/AiTickThrottlerConfig.java +++ b/plugin/src/main/java/cc/irori/refixes/config/impl/AiTickThrottlerConfig.java @@ -8,10 +8,11 @@ public class AiTickThrottlerConfig extends Configuration public static final ConfigurationKey ENABLED = new ConfigurationKey<>("Enabled", ConfigField.BOOLEAN, false); - public static final ConfigurationKey CLEANUP_FROZEN_ENTITIES = - new ConfigurationKey<>("CleanupFrozenEntities", ConfigField.BOOLEAN, true); public static final ConfigurationKey UPDATE_INTERVAL_MS = new ConfigurationKey<>("UpdateIntervalMs", ConfigField.INTEGER, 150); + // 0 disables the budget. + public static final ConfigurationKey MAX_CYCLE_MS = + new ConfigurationKey<>("MaxCycleMs", ConfigField.INTEGER, 30); // NPCs within this chunk distance get full tick rate (~64 blocks) public static final ConfigurationKey NEAR_CHUNKS = @@ -32,9 +33,6 @@ public class AiTickThrottlerConfig extends Configuration public static final ConfigurationKey MIN_TICK_SECONDS = new ConfigurationKey<>("MinTickSeconds", ConfigField.FLOAT, 0.05f); - public static final ConfigurationKey LEGACY_CLEANUP = - new ConfigurationKey<>("LegacyCleanup", ConfigField.BOOLEAN, false); - public static final ConfigurationKey ACTIVATION_HYSTERESIS_CHUNKS = new ConfigurationKey<>("ActivationHysteresisChunks", ConfigField.INTEGER, 0); public static final ConfigurationKey MAX_UNFREEZES_PER_TICK = @@ -42,13 +40,30 @@ public class AiTickThrottlerConfig extends Configuration public static final ConfigurationKey MAX_FREEZES_PER_TICK = new ConfigurationKey<>("MaxFreezesPerTick", ConfigField.INTEGER, 20); + public static final ConfigurationKey THROTTLE_EXCLUDED_NPC_TYPES = + new ConfigurationKey<>("ThrottleExcludedNpcTypes", ConfigField.STRING_ARRAY, new String[0]); + public static final ConfigurationKey THROTTLE_EXCLUDE_MOUNTS = + new ConfigurationKey<>("ThrottleExcludeMounts", ConfigField.BOOLEAN, true); + public static final ConfigurationKey THROTTLE_EXCLUDE_FLYING = + new ConfigurationKey<>("ThrottleExcludeFlying", ConfigField.BOOLEAN, false); + + public static final ConfigurationKey CLEANUP_FROZEN_ENTITIES = + new ConfigurationKey<>("CleanupFrozenEntities", ConfigField.BOOLEAN, false); + public static final ConfigurationKey CLEANUP_EXCLUDED_NPC_TYPES = + new ConfigurationKey<>("CleanupExcludedNpcTypes", ConfigField.STRING_ARRAY, new String[0]); + + public static final ConfigurationKey LEGACY_CLEANUP = + new ConfigurationKey<>("LegacyCleanup", ConfigField.BOOLEAN, false); + public static final ConfigurationKey LEGACY_CLEANUP_EXCLUDED_NPC_TYPES = + new ConfigurationKey<>("LegacyCleanupExcludedNpcTypes", ConfigField.STRING_ARRAY, new String[0]); + private static final AiTickThrottlerConfig INSTANCE = new AiTickThrottlerConfig(); public AiTickThrottlerConfig() { register( ENABLED, - CLEANUP_FROZEN_ENTITIES, UPDATE_INTERVAL_MS, + MAX_CYCLE_MS, NEAR_CHUNKS, MID_CHUNKS, FAR_CHUNKS, @@ -56,10 +71,16 @@ public AiTickThrottlerConfig() { FAR_TICK_SECONDS, VERY_FAR_TICK_SECONDS, MIN_TICK_SECONDS, - LEGACY_CLEANUP, ACTIVATION_HYSTERESIS_CHUNKS, MAX_UNFREEZES_PER_TICK, - MAX_FREEZES_PER_TICK); + MAX_FREEZES_PER_TICK, + THROTTLE_EXCLUDED_NPC_TYPES, + THROTTLE_EXCLUDE_MOUNTS, + THROTTLE_EXCLUDE_FLYING, + CLEANUP_FROZEN_ENTITIES, + CLEANUP_EXCLUDED_NPC_TYPES, + LEGACY_CLEANUP, + LEGACY_CLEANUP_EXCLUDED_NPC_TYPES); } public static AiTickThrottlerConfig get() { diff --git a/plugin/src/main/java/cc/irori/refixes/config/impl/ChunkUnloaderConfig.java b/plugin/src/main/java/cc/irori/refixes/config/impl/ChunkUnloaderConfig.java index e24ffc8..a40bae9 100644 --- a/plugin/src/main/java/cc/irori/refixes/config/impl/ChunkUnloaderConfig.java +++ b/plugin/src/main/java/cc/irori/refixes/config/impl/ChunkUnloaderConfig.java @@ -18,6 +18,8 @@ public class ChunkUnloaderConfig extends Configuration { new ConfigurationKey<>("MinLoadedChunks", ConfigField.INTEGER, 0); public static final ConfigurationKey CHECK_INTERVAL_MS = new ConfigurationKey<>("CheckIntervalMs", ConfigField.INTEGER, 10000); + public static final ConfigurationKey KEEP_SPAWN_LOADED = + new ConfigurationKey<>("KeepSpawnLoaded", ConfigField.BOOLEAN, true); private static final ChunkUnloaderConfig INSTANCE = new ChunkUnloaderConfig(); @@ -28,7 +30,8 @@ public ChunkUnloaderConfig() { UNLOAD_DELAY_SECONDS, MAX_UNLOADS_PER_RUN, MIN_LOADED_CHUNKS, - CHECK_INTERVAL_MS); + CHECK_INTERVAL_MS, + KEEP_SPAWN_LOADED); } public static ChunkUnloaderConfig get() { diff --git a/plugin/src/main/java/cc/irori/refixes/config/impl/CylinderVisibilityConfig.java b/plugin/src/main/java/cc/irori/refixes/config/impl/CylinderVisibilityConfig.java index d58e2cb..4445a3a 100644 --- a/plugin/src/main/java/cc/irori/refixes/config/impl/CylinderVisibilityConfig.java +++ b/plugin/src/main/java/cc/irori/refixes/config/impl/CylinderVisibilityConfig.java @@ -6,15 +6,13 @@ public class CylinderVisibilityConfig extends Configuration { - public static final ConfigurationKey ENABLED = - new ConfigurationKey<>("Enabled", ConfigField.BOOLEAN, true); public static final ConfigurationKey HEIGHT_MULTIPLIER = new ConfigurationKey<>("HeightMultiplier", ConfigField.DOUBLE, 2.0); private static final CylinderVisibilityConfig INSTANCE = new CylinderVisibilityConfig(); public CylinderVisibilityConfig() { - register(ENABLED, HEIGHT_MULTIPLIER); + register(HEIGHT_MULTIPLIER); } public static CylinderVisibilityConfig get() { diff --git a/plugin/src/main/java/cc/irori/refixes/config/impl/EarlyConfig.java b/plugin/src/main/java/cc/irori/refixes/config/impl/EarlyConfig.java index f814fd0..f38c339 100644 --- a/plugin/src/main/java/cc/irori/refixes/config/impl/EarlyConfig.java +++ b/plugin/src/main/java/cc/irori/refixes/config/impl/EarlyConfig.java @@ -15,27 +15,24 @@ public class EarlyConfig extends Configuration { public static final ConfigurationKey FORCE_SKIP_MOD_VALIDATION = new ConfigurationKey<>("ForceSkipModValidation", ConfigField.BOOLEAN, false); - public static final ConfigurationKey DISABLE_FLUID_PRE_PROCESS = - new ConfigurationKey<>("DisableFluidPreProcess", ConfigField.BOOLEAN, true); - public static final ConfigurationKey ASYNC_BLOCK_PRE_PROCESS = - new ConfigurationKey<>("AsyncBlockPreProcess", ConfigField.BOOLEAN, true); public static final ConfigurationKey MAX_CHUNKS_PER_SECOND = new ConfigurationKey<>("MaxChunksPerSecond", ConfigField.INTEGER, 36); public static final ConfigurationKey MAX_CHUNKS_PER_TICK = new ConfigurationKey<>("MaxChunksPerTick", ConfigField.INTEGER, 4); + public static final ConfigurationKey VANILLA_KEEP_SPAWN_LOADED = + new ConfigurationKey<>("VanillaKeepSpawnLoaded", ConfigField.BOOLEAN, true); - public static final ConfigurationKey BLOCK_ENTITY_SLEEP_ENABLED = - new ConfigurationKey<>("BlockEntitySleepEnabled", ConfigField.BOOLEAN, false); public static final ConfigurationKey BLOCK_ENTITY_SLEEP_INTERVAL = new ConfigurationKey<>("BlockEntitySleepInterval", ConfigField.INTEGER, 4); - public static final ConfigurationKey STAT_RECALC_THROTTLE_ENABLED = - new ConfigurationKey<>("StatRecalcThrottleEnabled", ConfigField.BOOLEAN, false); public static final ConfigurationKey STAT_RECALC_INTERVAL = new ConfigurationKey<>("StatRecalcInterval", ConfigField.INTEGER, 4); - public static final ConfigurationKey SECTION_CACHE_ENABLED = - new ConfigurationKey<>("SectionCacheEnabled", ConfigField.BOOLEAN, false); - public static final ConfigurationKey SKIP_EMPTY_LIGHT_SECTIONS = - new ConfigurationKey<>("SkipEmptyLightSections", ConfigField.BOOLEAN, false); + + public static final ConfigurationKey PATHFINDING_MAX_PATH_LENGTH = + new ConfigurationKey<>("PathfindingMaxPathLength", ConfigField.INTEGER, 200); + public static final ConfigurationKey PATHFINDING_OPEN_NODES_LIMIT = + new ConfigurationKey<>("PathfindingOpenNodesLimit", ConfigField.INTEGER, 80); + public static final ConfigurationKey PATHFINDING_TOTAL_NODES_LIMIT = + new ConfigurationKey<>("PathfindingTotalNodesLimit", ConfigField.INTEGER, 400); private static final EarlyConfig INSTANCE = new EarlyConfig(); @@ -45,16 +42,14 @@ public EarlyConfig() { CYLINDER_VISIBILITY_CONFIG, KDTREE_OPTIMIZATION_CONFIG, FORCE_SKIP_MOD_VALIDATION, - DISABLE_FLUID_PRE_PROCESS, - ASYNC_BLOCK_PRE_PROCESS, MAX_CHUNKS_PER_SECOND, MAX_CHUNKS_PER_TICK, - BLOCK_ENTITY_SLEEP_ENABLED, + VANILLA_KEEP_SPAWN_LOADED, BLOCK_ENTITY_SLEEP_INTERVAL, - STAT_RECALC_THROTTLE_ENABLED, STAT_RECALC_INTERVAL, - SECTION_CACHE_ENABLED, - SKIP_EMPTY_LIGHT_SECTIONS); + PATHFINDING_MAX_PATH_LENGTH, + PATHFINDING_OPEN_NODES_LIMIT, + PATHFINDING_TOTAL_NODES_LIMIT); } public static EarlyConfig get() { diff --git a/plugin/src/main/java/cc/irori/refixes/config/impl/ExperimentalConfig.java b/plugin/src/main/java/cc/irori/refixes/config/impl/ExperimentalConfig.java index c494bb7..a615416 100644 --- a/plugin/src/main/java/cc/irori/refixes/config/impl/ExperimentalConfig.java +++ b/plugin/src/main/java/cc/irori/refixes/config/impl/ExperimentalConfig.java @@ -6,13 +6,13 @@ public class ExperimentalConfig extends Configuration { - public static final ConfigurationKey PARALLEL_ENTITY_TICKING = - new ConfigurationKey<>("ParallelEntityTicking", ConfigField.BOOLEAN, false); + public static final ConfigurationKey PARALLEL_STEERING_THRESHOLD = + new ConfigurationKey<>("ParallelSteeringThreshold", ConfigField.INTEGER, 64); private static final ExperimentalConfig INSTANCE = new ExperimentalConfig(); public ExperimentalConfig() { - register(PARALLEL_ENTITY_TICKING); + register(PARALLEL_STEERING_THRESHOLD); } public static ExperimentalConfig get() { diff --git a/plugin/src/main/java/cc/irori/refixes/config/impl/KDTreeOptimizationConfig.java b/plugin/src/main/java/cc/irori/refixes/config/impl/KDTreeOptimizationConfig.java index 4265d16..1f7fdea 100644 --- a/plugin/src/main/java/cc/irori/refixes/config/impl/KDTreeOptimizationConfig.java +++ b/plugin/src/main/java/cc/irori/refixes/config/impl/KDTreeOptimizationConfig.java @@ -6,15 +6,13 @@ public class KDTreeOptimizationConfig extends Configuration { - public static final ConfigurationKey OPTIMIZE_KDTREE_SORT = - new ConfigurationKey<>("OptimizeKDTreeSort", ConfigField.BOOLEAN, true); public static final ConfigurationKey SPATIAL_FAST_SORT_THRESHOLD = new ConfigurationKey<>("SpatialFastSortThreshold", ConfigField.INTEGER, 64); private static final KDTreeOptimizationConfig INSTANCE = new KDTreeOptimizationConfig(); public KDTreeOptimizationConfig() { - register(OPTIMIZE_KDTREE_SORT, SPATIAL_FAST_SORT_THRESHOLD); + register(SPATIAL_FAST_SORT_THRESHOLD); } public static KDTreeOptimizationConfig get() { diff --git a/plugin/src/main/java/cc/irori/refixes/config/impl/ListenerConfig.java b/plugin/src/main/java/cc/irori/refixes/config/impl/ListenerConfig.java index 3c95c6e..8fef935 100644 --- a/plugin/src/main/java/cc/irori/refixes/config/impl/ListenerConfig.java +++ b/plugin/src/main/java/cc/irori/refixes/config/impl/ListenerConfig.java @@ -10,11 +10,19 @@ public class ListenerConfig extends Configuration { new ConfigurationKey<>("InstancePositionTracker", ConfigField.BOOLEAN, true); public static final ConfigurationKey UNKNOWN_BLOCK_CLEANER = new ConfigurationKey<>("UnknownBlockCleaner", ConfigField.BOOLEAN, false); + public static final ConfigurationKey UNKNOWN_BLOCK_CLEANER_BUDGET_MS = + new ConfigurationKey<>("UnknownBlockCleanerBudgetMs", ConfigField.INTEGER, 10); + public static final ConfigurationKey UNKNOWN_BLOCK_CLEANER_INTERVAL_MS = + new ConfigurationKey<>("UnknownBlockCleanerIntervalMs", ConfigField.INTEGER, 50); private static final ListenerConfig INSTANCE = new ListenerConfig(); public ListenerConfig() { - register(INSTANCE_POSITION_TRACKER, UNKNOWN_BLOCK_CLEANER); + register( + INSTANCE_POSITION_TRACKER, + UNKNOWN_BLOCK_CLEANER, + UNKNOWN_BLOCK_CLEANER_BUDGET_MS, + UNKNOWN_BLOCK_CLEANER_INTERVAL_MS); } public static ListenerConfig get() { diff --git a/plugin/src/main/java/cc/irori/refixes/config/impl/SharedInstanceConfig.java b/plugin/src/main/java/cc/irori/refixes/config/impl/SharedInstanceConfig.java index 5d63b21..b1878b0 100644 --- a/plugin/src/main/java/cc/irori/refixes/config/impl/SharedInstanceConfig.java +++ b/plugin/src/main/java/cc/irori/refixes/config/impl/SharedInstanceConfig.java @@ -7,7 +7,7 @@ public class SharedInstanceConfig extends Configuration { public static final ConfigurationKey ENABLED = - new ConfigurationKey<>("Enabled", ConfigField.BOOLEAN, true); + new ConfigurationKey<>("Enabled", ConfigField.BOOLEAN, false); public static final ConfigurationKey EXCLUDED_PREFIXES = new ConfigurationKey<>("ExcludedPrefixes", ConfigField.STRING_ARRAY, new String[0]); diff --git a/plugin/src/main/java/cc/irori/refixes/config/impl/SystemConfig.java b/plugin/src/main/java/cc/irori/refixes/config/impl/SystemConfig.java index 2d1240a..92d76b7 100644 --- a/plugin/src/main/java/cc/irori/refixes/config/impl/SystemConfig.java +++ b/plugin/src/main/java/cc/irori/refixes/config/impl/SystemConfig.java @@ -8,8 +8,6 @@ public class SystemConfig extends Configuration { public static final ConfigurationKey RESPAWN_BLOCK = new ConfigurationKey<>("RespawnBlock", ConfigField.BOOLEAN, true); - public static final ConfigurationKey PROCESSING_BENCH = - new ConfigurationKey<>("ProcessingBench", ConfigField.BOOLEAN, true); public static final ConfigurationKey CRAFTING_MANAGER = new ConfigurationKey<>("CraftingManager", ConfigField.BOOLEAN, true); public static final ConfigurationKey INTERACTION_MANAGER = @@ -24,7 +22,6 @@ public class SystemConfig extends Configuration { public SystemConfig() { register( RESPAWN_BLOCK, - PROCESSING_BENCH, CRAFTING_MANAGER, INTERACTION_MANAGER, ENTITY_DESPAWN_TIMER, diff --git a/plugin/src/main/java/cc/irori/refixes/copychunks/ChunkBundle.java b/plugin/src/main/java/cc/irori/refixes/copychunks/ChunkBundle.java new file mode 100644 index 0000000..6cb3ca6 --- /dev/null +++ b/plugin/src/main/java/cc/irori/refixes/copychunks/ChunkBundle.java @@ -0,0 +1,169 @@ +package cc.irori.refixes.copychunks; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +public final class ChunkBundle { + + private static final int MAGIC = 0x52465843; + private static final int VERSION = 1; + private static final Pattern NAME_PATTERN = Pattern.compile("[A-Za-z0-9._-]{1,64}"); + private static final Path CLIPBOARD_ROOT = Paths.get("refixes-clipboards"); + + public final String sourceWorldName; + public final int minChunkX; + public final int minChunkZ; + public final int maxChunkX; + public final int maxChunkZ; + public final int blockIdVersion; + public final List chunks; + + public static final class Entry { + public final int chunkX; + public final int chunkZ; + public final ByteBuffer blob; + + public Entry(int chunkX, int chunkZ, ByteBuffer blob) { + this.chunkX = chunkX; + this.chunkZ = chunkZ; + this.blob = blob; + } + } + + public ChunkBundle( + String sourceWorldName, + int minChunkX, + int minChunkZ, + int maxChunkX, + int maxChunkZ, + int blockIdVersion, + List chunks) { + this.sourceWorldName = sourceWorldName; + this.minChunkX = minChunkX; + this.minChunkZ = minChunkZ; + this.maxChunkX = maxChunkX; + this.maxChunkZ = maxChunkZ; + this.blockIdVersion = blockIdVersion; + this.chunks = chunks; + } + + public static boolean isValidName(String name) { + return name != null && NAME_PATTERN.matcher(name).matches(); + } + + public static Path pathFor(String name) { + return CLIPBOARD_ROOT.resolve(name + ".bundle"); + } + + public void write(Path path) throws IOException { + Path parent = path.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + try (OutputStream out = Files.newOutputStream(path)) { + byte[] worldBytes = sourceWorldName.getBytes(StandardCharsets.UTF_8); + ByteBuffer header = ByteBuffer.allocate(10 + worldBytes.length + 22).order(ByteOrder.BIG_ENDIAN); + header.putInt(MAGIC); + header.putInt(VERSION); + header.putShort((short) worldBytes.length); + header.put(worldBytes); + header.putInt(minChunkX); + header.putInt(minChunkZ); + header.putInt(maxChunkX); + header.putInt(maxChunkZ); + header.putShort((short) blockIdVersion); + header.putInt(chunks.size()); + out.write(header.array()); + + ByteBuffer entryHeader = ByteBuffer.allocate(12).order(ByteOrder.BIG_ENDIAN); + for (Entry entry : chunks) { + entryHeader.clear(); + ByteBuffer dup = entry.blob.duplicate(); + int blobLen = dup.remaining(); + entryHeader.putInt(entry.chunkX); + entryHeader.putInt(entry.chunkZ); + entryHeader.putInt(blobLen); + out.write(entryHeader.array()); + if (blobLen > 0) { + if (dup.hasArray()) { + out.write(dup.array(), dup.arrayOffset() + dup.position(), blobLen); + } else { + byte[] tmp = new byte[blobLen]; + dup.get(tmp); + out.write(tmp); + } + } + } + } + } + + public static ChunkBundle read(Path path) throws IOException { + try (InputStream in = Files.newInputStream(path)) { + byte[] head = in.readNBytes(10); + if (head.length < 10) { + throw new IOException("Bundle truncated in header"); + } + ByteBuffer headBuf = ByteBuffer.wrap(head).order(ByteOrder.BIG_ENDIAN); + int magic = headBuf.getInt(); + if (magic != MAGIC) { + throw new IOException("Bad magic: expected RFXC, got 0x" + Integer.toHexString(magic)); + } + int version = headBuf.getInt(); + if (version != VERSION) { + throw new IOException("Unsupported version: " + version); + } + int nameLen = Short.toUnsignedInt(headBuf.getShort()); + byte[] nameBytes = in.readNBytes(nameLen); + if (nameBytes.length < nameLen) { + throw new IOException("Bundle truncated in source world name"); + } + String worldName = new String(nameBytes, StandardCharsets.UTF_8); + + byte[] body = in.readNBytes(22); + if (body.length < 22) { + throw new IOException("Bundle truncated in range header"); + } + ByteBuffer bodyBuf = ByteBuffer.wrap(body).order(ByteOrder.BIG_ENDIAN); + int minCx = bodyBuf.getInt(); + int minCz = bodyBuf.getInt(); + int maxCx = bodyBuf.getInt(); + int maxCz = bodyBuf.getInt(); + int blockIdVersion = Short.toUnsignedInt(bodyBuf.getShort()); + int count = bodyBuf.getInt(); + if (count < 0 || count > 1_048_576) { + throw new IOException("Implausible chunk count: " + count); + } + + List entries = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + byte[] entryHead = in.readNBytes(12); + if (entryHead.length < 12) { + throw new IOException("Bundle truncated in chunk #" + i + " header"); + } + ByteBuffer eb = ByteBuffer.wrap(entryHead).order(ByteOrder.BIG_ENDIAN); + int cx = eb.getInt(); + int cz = eb.getInt(); + int blobLen = eb.getInt(); + if (blobLen < 0 || blobLen > 16_777_216) { + throw new IOException("Implausible blob length: " + blobLen + " at chunk #" + i); + } + byte[] blob = in.readNBytes(blobLen); + if (blob.length < blobLen) { + throw new IOException("Bundle truncated in chunk #" + i + " blob"); + } + entries.add(new Entry(cx, cz, ByteBuffer.wrap(blob))); + } + return new ChunkBundle(worldName, minCx, minCz, maxCx, maxCz, blockIdVersion, entries); + } + } +} diff --git a/plugin/src/main/java/cc/irori/refixes/copychunks/ChunkClipboardOps.java b/plugin/src/main/java/cc/irori/refixes/copychunks/ChunkClipboardOps.java new file mode 100644 index 0000000..c6f4a27 --- /dev/null +++ b/plugin/src/main/java/cc/irori/refixes/copychunks/ChunkClipboardOps.java @@ -0,0 +1,38 @@ +package cc.irori.refixes.copychunks; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.RemoveReason; +import com.hypixel.hytale.math.util.ChunkUtil; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.BufferChunkSaver; +import com.hypixel.hytale.server.core.universe.world.storage.ChunkStore; +import com.hypixel.hytale.server.core.universe.world.storage.component.ChunkSavingSystems; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public final class ChunkClipboardOps { + + private ChunkClipboardOps() {} + + public static CompletableFuture forceUnloadAndDrain( + World world, ChunkStore chunkStore, BufferChunkSaver saver, List coords) { + for (int[] coord : coords) { + long idx = ChunkUtil.indexChunk(coord[0], coord[1]); + Ref ref = chunkStore.getChunkReference(idx); + if (ref != null) { + chunkStore.remove(ref, RemoveReason.UNLOAD); + world.getNotificationHandler().updateChunk(idx); + } + } + ChunkSavingSystems.Data savingData = + (ChunkSavingSystems.Data) chunkStore.getStore().getResource(ChunkStore.SAVE_RESOURCE); + return savingData.waitForSavingChunks().thenRunAsync(() -> { + try { + saver.flush(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/plugin/src/main/java/cc/irori/refixes/copychunks/CopyChunksCommand.java b/plugin/src/main/java/cc/irori/refixes/copychunks/CopyChunksCommand.java new file mode 100644 index 0000000..04a4352 --- /dev/null +++ b/plugin/src/main/java/cc/irori/refixes/copychunks/CopyChunksCommand.java @@ -0,0 +1,161 @@ +package cc.irori.refixes.copychunks; + +import cc.irori.refixes.util.Logs; +import com.hypixel.hytale.builtin.buildertools.BuilderToolsPlugin; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.permissions.HytalePermissions; +import com.hypixel.hytale.server.core.prefab.selection.standard.BlockSelection; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.BufferChunkLoader; +import com.hypixel.hytale.server.core.universe.world.storage.BufferChunkSaver; +import com.hypixel.hytale.server.core.universe.world.storage.ChunkStore; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.core.universe.world.storage.IChunkLoader; +import com.hypixel.hytale.server.core.universe.world.storage.IChunkSaver; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; +import org.joml.Vector3i; + +public class CopyChunksCommand extends AbstractPlayerCommand { + + private static final HytaleLogger LOGGER = Logs.logger(); + + @Nonnull + private final RequiredArg nameArg; + + public CopyChunksCommand() { + super("copychunks", "refixes.commands.copychunks.desc"); + this.nameArg = this.withRequiredArg("name", "refixes.commands.copychunks.name.desc", ArgTypes.STRING); + this.setPermissionGroups(new String[] {"hytale:WorldEditor"}); + this.requirePermission(HytalePermissions.EDITOR_SELECTION_USE); + } + + @Override + protected void execute( + @Nonnull CommandContext context, + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull PlayerRef playerRef, + @Nonnull World world) { + String name = (String) this.nameArg.get(context); + if (!ChunkBundle.isValidName(name)) { + context.sendMessage(Message.raw("copychunks: invalid name. Allowed: [A-Za-z0-9._-], 1-64 chars.")); + return; + } + + Player playerComponent = (Player) store.getComponent(ref, Player.getComponentType()); + if (playerComponent == null) { + context.sendMessage(Message.raw("copychunks: player component missing.")); + return; + } + BuilderToolsPlugin.BuilderState state = BuilderToolsPlugin.getState(playerComponent, playerRef); + BlockSelection selection = state.getSelection(); + if (selection == null) { + context.sendMessage(Message.raw("copychunks: no selection. Use /pos1 and /pos2 first.")); + return; + } + Vector3i min = selection.getSelectionMin(); + Vector3i max = selection.getSelectionMax(); + if (min == null || max == null) { + context.sendMessage(Message.raw("copychunks: selection has no bounds.")); + return; + } + + boolean aligned = ((min.x & 31) == 0) + && ((min.z & 31) == 0) + && (((max.x + 1) & 31) == 0) + && (((max.z + 1) & 31) == 0); + if (!aligned) { + context.sendMessage(Message.raw(String.format( + "copychunks: selection not chunk-aligned; snapping to enclosing chunks (min=(%d,%d,%d), max=(%d,%d,%d)).", + min.x, min.y, min.z, max.x, max.y, max.z))); + } + + final int minCx = min.x >> 5; + final int minCz = min.z >> 5; + final int maxCx = max.x >> 5; + final int maxCz = max.z >> 5; + + ChunkStore chunkStore = world.getChunkStore(); + IChunkLoader rawLoader = chunkStore.getLoader(); + IChunkSaver rawSaver = chunkStore.getSaver(); + if (rawLoader == null || rawSaver == null) { + context.sendMessage(Message.raw("copychunks: no chunk loader/saver for this world.")); + return; + } + if (!(rawLoader instanceof BufferChunkLoader) || !(rawSaver instanceof BufferChunkSaver)) { + context.sendMessage(Message.raw("copychunks: world chunk loader/saver is not buffer-based.")); + return; + } + BufferChunkLoader loader = (BufferChunkLoader) rawLoader; + BufferChunkSaver saver = (BufferChunkSaver) rawSaver; + + List coords = new ArrayList<>(); + for (int cx = minCx; cx <= maxCx; cx++) { + for (int cz = minCz; cz <= maxCz; cz++) { + coords.add(new int[] {cx, cz}); + } + } + + final String worldName = world.getName(); + final Path bundlePath = ChunkBundle.pathFor(name); + + ChunkClipboardOps.forceUnloadAndDrain(world, chunkStore, saver, coords) + .thenComposeAsync(unused -> { + List> futures = new ArrayList<>(coords.size()); + for (int[] coord : coords) { + final int fcx = coord[0]; + final int fcz = coord[1]; + futures.add(loader.loadBuffer(fcx, fcz).thenApply(buf -> { + ByteBuffer blob = (buf == null) ? ByteBuffer.allocate(0) : buf; + return new ChunkBundle.Entry(fcx, fcz, blob); + })); + } + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply(v -> { + List entries = new ArrayList<>(futures.size()); + for (CompletableFuture f : futures) { + entries.add(f.join()); + } + return entries; + }); + }) + .thenAccept(entries -> { + int nonEmpty = 0; + for (ChunkBundle.Entry e : entries) { + if (e.blob.remaining() > 0) { + nonEmpty++; + } + } + try { + ChunkBundle bundle = new ChunkBundle(worldName, minCx, minCz, maxCx, maxCz, 0, entries); + bundle.write(bundlePath); + context.sendMessage(Message.raw(String.format( + "copychunks: wrote %d chunks (%d non-empty) to %s", + entries.size(), nonEmpty, bundlePath.toAbsolutePath()))); + } catch (IOException e) { + LOGGER.atSevere().withCause(e).log("copychunks: write failed"); + context.sendMessage(Message.raw("copychunks: write failed: " + e.getMessage())); + } + }) + .exceptionally(e -> { + LOGGER.atSevere().withCause(e).log("copychunks: failed"); + context.sendMessage(Message.raw("copychunks: failed: " + e.getMessage())); + return null; + }); + } +} diff --git a/plugin/src/main/java/cc/irori/refixes/copychunks/PasteChunksCommand.java b/plugin/src/main/java/cc/irori/refixes/copychunks/PasteChunksCommand.java new file mode 100644 index 0000000..2c31941 --- /dev/null +++ b/plugin/src/main/java/cc/irori/refixes/copychunks/PasteChunksCommand.java @@ -0,0 +1,208 @@ +package cc.irori.refixes.copychunks; + +import cc.irori.refixes.util.Logs; +import com.hypixel.hytale.builtin.buildertools.BuilderToolsPlugin; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.FlagArg; +import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.permissions.HytalePermissions; +import com.hypixel.hytale.server.core.prefab.selection.standard.BlockSelection; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.BufferChunkSaver; +import com.hypixel.hytale.server.core.universe.world.storage.ChunkStore; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.core.universe.world.storage.IChunkSaver; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; +import org.joml.Vector3i; + +public class PasteChunksCommand extends AbstractPlayerCommand { + + private static final HytaleLogger LOGGER = Logs.logger(); + + private static final Field RAW_POS1_FIELD; + + static { + Field f = null; + try { + f = BuilderToolsPlugin.BuilderState.class.getDeclaredField("rawPos1"); + f.setAccessible(true); + } catch (NoSuchFieldException e) { + LOGGER.atWarning().withCause(e).log( + "pastechunks: BuilderState.rawPos1 not found; --here will fall back to selection.min"); + } + RAW_POS1_FIELD = f; + } + + private static Vector3i readRawPos1(BuilderToolsPlugin.BuilderState state) { + if (RAW_POS1_FIELD == null) { + return null; + } + try { + return (Vector3i) RAW_POS1_FIELD.get(state); + } catch (IllegalAccessException e) { + return null; + } + } + + @Nonnull + private final RequiredArg nameArg; + + @Nonnull + private final OptionalArg worldArg; + + @Nonnull + private final FlagArg hereArg; + + public PasteChunksCommand() { + super("pastechunks", "refixes.commands.pastechunks.desc"); + this.nameArg = this.withRequiredArg("name", "refixes.commands.pastechunks.name.desc", ArgTypes.STRING); + this.worldArg = this.withOptionalArg("world", "refixes.commands.pastechunks.world.desc", ArgTypes.STRING); + this.hereArg = this.withFlagArg("here", "refixes.commands.pastechunks.here.desc"); + this.setPermissionGroups(new String[] {"hytale:WorldEditor"}); + this.requirePermission(HytalePermissions.EDITOR_SELECTION_MODIFY); + } + + @Override + protected void execute( + @Nonnull CommandContext context, + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull PlayerRef playerRef, + @Nonnull World world) { + String name = (String) this.nameArg.get(context); + if (!ChunkBundle.isValidName(name)) { + context.sendMessage(Message.raw("pastechunks: invalid name. Allowed: [A-Za-z0-9._-], 1-64 chars.")); + return; + } + Path bundlePath = ChunkBundle.pathFor(name); + if (!Files.isRegularFile(bundlePath)) { + context.sendMessage(Message.raw("pastechunks: bundle not found at " + bundlePath.toAbsolutePath())); + return; + } + + ChunkBundle bundle; + try { + bundle = ChunkBundle.read(bundlePath); + } catch (IOException e) { + LOGGER.atSevere().withCause(e).log("pastechunks: read failed"); + context.sendMessage(Message.raw("pastechunks: read failed: " + e.getMessage())); + return; + } + + World targetWorld = world; + if (this.worldArg.provided(context)) { + String requested = (String) this.worldArg.get(context); + World resolved = Universe.get().getWorld(requested); + if (resolved == null || !resolved.isAlive()) { + context.sendMessage(Message.raw("pastechunks: target world '" + + requested + + "' is not loaded. Run /world load " + + requested + + " first.")); + return; + } + targetWorld = resolved; + } + + int dx = 0; + int dz = 0; + if (this.hereArg.get(context)) { + Player playerComponent = (Player) store.getComponent(ref, Player.getComponentType()); + if (playerComponent == null) { + context.sendMessage(Message.raw("pastechunks: player component missing for --here.")); + return; + } + BuilderToolsPlugin.BuilderState state = BuilderToolsPlugin.getState(playerComponent, playerRef); + BlockSelection selection = state.getSelection(); + if (selection == null) { + context.sendMessage( + Message.raw("pastechunks: --here requires a selection (use /pos1 at the new min corner).")); + return; + } + Vector3i rawPos1 = readRawPos1(state); + Vector3i newMin = rawPos1 != null ? rawPos1 : selection.getSelectionMin(); + if (newMin == null) { + context.sendMessage(Message.raw("pastechunks: --here selection has no bounds.")); + return; + } + if (((newMin.x & 31) != 0) || ((newMin.z & 31) != 0)) { + context.sendMessage(Message.raw(String.format( + "pastechunks: --here selection min (%d,%d,%d) is not chunk-aligned. " + + "Snap pos1 to a multiple of 32 on X and Z. " + + "(Blocks-only fallback for misaligned origins is not yet implemented.)", + newMin.x, newMin.y, newMin.z))); + return; + } + int newMinCx = newMin.x >> 5; + int newMinCz = newMin.z >> 5; + dx = newMinCx - bundle.minChunkX; + dz = newMinCz - bundle.minChunkZ; + } + + ChunkStore chunkStore = targetWorld.getChunkStore(); + IChunkSaver rawSaver = chunkStore.getSaver(); + if (rawSaver == null) { + context.sendMessage(Message.raw("pastechunks: no chunk saver for target world.")); + return; + } + if (!(rawSaver instanceof BufferChunkSaver)) { + context.sendMessage(Message.raw("pastechunks: target chunk saver is not buffer-based.")); + return; + } + final BufferChunkSaver saver = (BufferChunkSaver) rawSaver; + + List destCoords = new ArrayList<>(bundle.chunks.size()); + for (ChunkBundle.Entry entry : bundle.chunks) { + destCoords.add(new int[] {entry.chunkX + dx, entry.chunkZ + dz}); + } + + final int finalDx = dx; + final int finalDz = dz; + final String targetWorldName = targetWorld.getName(); + final Path finalBundlePath = bundlePath; + + ChunkClipboardOps.forceUnloadAndDrain(targetWorld, chunkStore, saver, destCoords) + .thenComposeAsync(unused -> { + List> writes = new ArrayList<>(bundle.chunks.size()); + int skipped = 0; + for (ChunkBundle.Entry entry : bundle.chunks) { + if (entry.blob.remaining() == 0) { + skipped++; + continue; + } + writes.add(saver.saveBuffer( + entry.chunkX + finalDx, entry.chunkZ + finalDz, entry.blob.duplicate())); + } + final int writtenCount = writes.size(); + final int skippedCount = skipped; + return CompletableFuture.allOf(writes.toArray(new CompletableFuture[0])) + .thenApply(v -> new int[] {writtenCount, skippedCount}); + }) + .thenAccept(counts -> context.sendMessage(Message.raw(String.format( + "pastechunks: wrote %d chunks (skipped %d empty) to world '%s' " + + "from %s. Translation: dx=%d, dz=%d. Re-enter the area to reload.", + counts[0], counts[1], targetWorldName, finalBundlePath.toAbsolutePath(), finalDx, finalDz)))) + .exceptionally(e -> { + LOGGER.atSevere().withCause(e).log("pastechunks: failed"); + context.sendMessage(Message.raw("pastechunks: failed: " + e.getMessage())); + return null; + }); + } +} diff --git a/plugin/src/main/java/cc/irori/refixes/listener/ChunkLoaderWorldListener.java b/plugin/src/main/java/cc/irori/refixes/listener/ChunkLoaderWorldListener.java new file mode 100644 index 0000000..489cd51 --- /dev/null +++ b/plugin/src/main/java/cc/irori/refixes/listener/ChunkLoaderWorldListener.java @@ -0,0 +1,22 @@ +package cc.irori.refixes.listener; + +import cc.irori.refixes.service.ChunkLoaderService; +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import com.hypixel.hytale.server.core.universe.world.events.StartWorldEvent; + +public class ChunkLoaderWorldListener { + + private final ChunkLoaderService chunkLoaderService; + + public ChunkLoaderWorldListener(ChunkLoaderService chunkLoaderService) { + this.chunkLoaderService = chunkLoaderService; + } + + public void registerEvents(JavaPlugin plugin) { + plugin.getEventRegistry().registerGlobal(StartWorldEvent.class, this::onWorldStart); + } + + private void onWorldStart(StartWorldEvent event) { + chunkLoaderService.loadWorld(event.getWorld()); + } +} diff --git a/plugin/src/main/java/cc/irori/refixes/listener/UnknownBlockCleaner.java b/plugin/src/main/java/cc/irori/refixes/listener/UnknownBlockCleaner.java index 594fe64..d685635 100644 --- a/plugin/src/main/java/cc/irori/refixes/listener/UnknownBlockCleaner.java +++ b/plugin/src/main/java/cc/irori/refixes/listener/UnknownBlockCleaner.java @@ -1,57 +1,175 @@ package cc.irori.refixes.listener; +import cc.irori.refixes.config.impl.ListenerConfig; import cc.irori.refixes.util.Logs; import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.math.util.ChunkUtil; +import com.hypixel.hytale.server.core.HytaleServer; import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.chunk.BlockChunk; import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; +import com.hypixel.hytale.server.core.universe.world.chunk.section.BlockSection; import com.hypixel.hytale.server.core.universe.world.events.ChunkPreLoadProcessEvent; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; public final class UnknownBlockCleaner { private static final HytaleLogger LOGGER = Logs.logger(); + private static final Queue pendingChunks = new ConcurrentLinkedQueue<>(); + + private static volatile Field blockChunkField; + private static volatile boolean fieldSearchDone; - // Private constructor to prevent instantiation private UnknownBlockCleaner() {} public static void registerEvents(JavaPlugin plugin) { plugin.getEventRegistry() - .registerGlobal(ChunkPreLoadProcessEvent.class, UnknownBlockCleaner::cleanUnknownBlocks); + .registerGlobal(ChunkPreLoadProcessEvent.class, event -> pendingChunks.add(event.getChunk())); + + int intervalMs = Math.max(20, ListenerConfig.get().getValue(ListenerConfig.UNKNOWN_BLOCK_CLEANER_INTERVAL_MS)); + HytaleServer.SCHEDULED_EXECUTOR.scheduleAtFixedRate( + () -> { + try { + drainQueue(); + } catch (Exception e) { + LOGGER.atSevere().withCause(e).log("Error in unknown block cleaner"); + } + }, + 1000, + intervalMs, + TimeUnit.MILLISECONDS); + } + + private static void drainQueue() { + if (pendingChunks.isEmpty()) { + return; + } + + Map> byWorld = new HashMap<>(); + WorldChunk chunk; + while ((chunk = pendingChunks.poll()) != null) { + World world = chunk.getWorld(); + if (world != null) { + byWorld.computeIfAbsent(world, k -> new ArrayList<>()).add(chunk); + } + } + + for (Map.Entry> entry : byWorld.entrySet()) { + List chunks = entry.getValue(); + entry.getKey().execute(() -> processChunks(chunks)); + } + } + + private static void processChunks(List chunks) { + int budgetMs = Math.max(1, ListenerConfig.get().getValue(ListenerConfig.UNKNOWN_BLOCK_CLEANER_BUDGET_MS)); + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(budgetMs); + for (int i = 0; i < chunks.size(); i++) { + cleanChunk(chunks.get(i)); + if (System.nanoTime() > deadline && i + 1 < chunks.size()) { + for (int j = i + 1; j < chunks.size(); j++) { + pendingChunks.add(chunks.get(j)); + } + return; + } + } + } + + private static void cleanChunk(WorldChunk chunk) { + Map removedCounts = new HashMap<>(); + + BlockChunk blockChunk = resolveBlockChunk(chunk); + if (blockChunk != null) { + for (int sectionY = 0; sectionY < ChunkUtil.HEIGHT; sectionY += ChunkUtil.SIZE) { + BlockSection section = blockChunk.getSectionAtBlockY(sectionY); + if (section == null || section.isSolidAir()) { + continue; + } + cleanSection(chunk, sectionY, removedCounts); + } + } else { + cleanAll(chunk, removedCounts); + } + + if (!removedCounts.isEmpty()) { + int total = + removedCounts.values().stream().mapToInt(Integer::intValue).sum(); + LOGGER.atInfo().log( + "Cleaned %d unknown blocks (%d types) from chunk (%d, %d) in world '%s': %s", + total, + removedCounts.size(), + chunk.getX(), + chunk.getZ(), + chunk.getWorld().getName(), + removedCounts); + } } - private static void cleanUnknownBlocks(ChunkPreLoadProcessEvent event) { - WorldChunk chunk = event.getChunk(); + private static void cleanSection(WorldChunk chunk, int sectionStartY, Map removedCounts) { + int sectionEndY = sectionStartY + ChunkUtil.SIZE; for (int x = 0; x < ChunkUtil.SIZE; x++) { for (int z = 0; z < ChunkUtil.SIZE; z++) { - for (int y = 0; y < ChunkUtil.HEIGHT; y++) { - int blockX = chunk.getX() * ChunkUtil.SIZE + x; - int blockZ = chunk.getZ() * ChunkUtil.SIZE + z; - + for (int y = sectionStartY; y < sectionEndY; y++) { try { BlockType blockType = chunk.getBlockType(x, y, z); if (blockType != null && blockType.isUnknown()) { - LOGGER.atWarning().log( - "Removed unknown block '%s' at position (%d, %d, %d), world '%s'", - blockType.getId(), - blockX, - y, - blockZ, - chunk.getWorld().getName()); + removedCounts.merge(blockType.getId(), 1, Integer::sum); chunk.setBlock(x, y, z, BlockType.EMPTY_KEY); } } catch (Throwable t) { + int blockX = chunk.getX() * ChunkUtil.SIZE + x; + int blockZ = chunk.getZ() * ChunkUtil.SIZE + z; LOGGER.atWarning().withCause(t).log( - "Failed to scan for unknown blocks at position (%d, %d, %d), world '%s'", - chunk.getX(), - chunk.getZ(), - blockX, - y, - blockZ, - chunk.getWorld().getName()); + "Error cleaning block at (%d, %d, %d) in world '%s'", + blockX, y, blockZ, chunk.getWorld().getName()); } } } } } + + private static void cleanAll(WorldChunk chunk, Map removedCounts) { + for (int sectionY = 0; sectionY < ChunkUtil.HEIGHT; sectionY += ChunkUtil.SIZE) { + cleanSection(chunk, sectionY, removedCounts); + } + } + + private static BlockChunk resolveBlockChunk(Object worldChunk) { + if (!fieldSearchDone) { + synchronized (UnknownBlockCleaner.class) { + if (!fieldSearchDone) { + try { + for (Field field : WorldChunk.class.getDeclaredFields()) { + if (BlockChunk.class.isAssignableFrom(field.getType())) { + field.setAccessible(true); + blockChunkField = field; + break; + } + } + } catch (Throwable t) { + LOGGER.atWarning().withCause(t).log( + "Failed to locate BlockChunk field on WorldChunk via reflection"); + } + fieldSearchDone = true; + } + } + } + + if (blockChunkField == null) { + return null; + } + try { + return (BlockChunk) blockChunkField.get(worldChunk); + } catch (Exception e) { + return null; + } + } } diff --git a/plugin/src/main/java/cc/irori/refixes/service/ActiveChunkUnloader.java b/plugin/src/main/java/cc/irori/refixes/service/ActiveChunkUnloader.java index 851d3e8..ec4d12c 100644 --- a/plugin/src/main/java/cc/irori/refixes/service/ActiveChunkUnloader.java +++ b/plugin/src/main/java/cc/irori/refixes/service/ActiveChunkUnloader.java @@ -7,9 +7,7 @@ import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.math.shape.Box2D; import com.hypixel.hytale.math.util.ChunkUtil; -import com.hypixel.hytale.math.vector.Transform; import com.hypixel.hytale.server.core.HytaleServer; -import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.World; @@ -17,12 +15,9 @@ import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; import com.hypixel.hytale.server.core.universe.world.events.ecs.ChunkUnloadEvent; import com.hypixel.hytale.server.core.universe.world.storage.ChunkStore; -import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap; import it.unimi.dsi.fastutil.longs.LongIterator; import it.unimi.dsi.fastutil.longs.LongSet; -import java.util.ArrayList; -import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; @@ -67,14 +62,12 @@ private void execute() { // Clean cached state for worlds that no longer exist outOfRangeSinceByWorld.keySet().removeIf(name -> !worldsByName.containsKey(name)); - int offset = Math.max(config.getValue(ChunkUnloaderConfig.UNLOAD_DISTANCE_OFFSET), 0); - for (World world : worldsByName.values()) { - world.execute(() -> unloadWorld(world, offset, config)); + world.execute(() -> unloadWorld(world, config)); } } - private void unloadWorld(World world, int offset, ChunkUnloaderConfig config) { + private void unloadWorld(World world, ChunkUnloaderConfig config) { if (!world.getWorldConfig().canUnloadChunks()) { return; } @@ -86,13 +79,13 @@ private void unloadWorld(World world, int offset, ChunkUnloaderConfig config) { return; } - List playerSnapshots = collectPlayerSnapshots(world.getPlayerRefs(), offset); + int playerCount = world.getPlayerRefs().size(); - // Compatibility with view radius reducer plugins, prevents unloading when no chunks are loaded. - if (!playerSnapshots.isEmpty()) { + // Compatibility with view radius reducer plugins, prevents unloading when no chunks are loaded. + if (playerCount > 0) { boolean anyPlayerChunkLoaded = false; - for (PlayerSnapshot snapshot : playerSnapshots) { - if (chunkStore.getChunkReference(snapshot.chunkIndex()) != null) { + for (PlayerRef playerRef : world.getPlayerRefs()) { + if (playerRef != null && playerRef.getChunkTracker().shouldBeVisible(ChunkUtil.indexChunk(0, 0))) { anyPlayerChunkLoaded = true; break; } @@ -146,8 +139,15 @@ private void unloadWorld(World world, int offset, ChunkUnloaderConfig config) { continue; } - // Skip chunks still within a player's safe radius (per-player view radius + offset) - if (isChunkNeeded(playerSnapshots, chunkIndex)) { + // Skip spawn chunk if configured + if (config.getValue(ChunkUnloaderConfig.KEEP_SPAWN_LOADED) && isSpawnChunk(world, worldChunk)) { + outOfRangeSince.remove(chunkIndex); + skippedKeepLoaded++; + continue; + } + + // Skip chunks still within a player's view range + if (isChunkNeeded(world, chunkIndex)) { outOfRangeSince.remove(chunkIndex); skippedInRange++; continue; @@ -195,7 +195,7 @@ private void unloadWorld(World world, int offset, ChunkUnloaderConfig config) { if (unloaded > 0) { LOGGER.atInfo().log( - "[%s] ChunkUnloader: total=%d, unloaded=%d, inRange=%d, keepLoaded=%d, keepLoadedRegion=%d, waitingDelay=%d, tickingStripped=%d, eventCancelled=%d, players=%d, offset=%d", + "[%s] ChunkUnloader: total=%d, unloaded=%d, inRange=%d, keepLoaded=%d, keepLoadedRegion=%d, waitingDelay=%d, tickingStripped=%d, eventCancelled=%d, players=%d", world.getName(), totalChunks, unloaded, @@ -205,62 +205,19 @@ private void unloadWorld(World world, int offset, ChunkUnloaderConfig config) { skippedWaitingDelay, skippedTickingStripped, skippedEventCancelled, - playerSnapshots.size(), - offset); - } - } - - private static List collectPlayerSnapshots(java.util.Collection players, int offset) { - List snapshots = new ArrayList<>(); - if (players == null || players.isEmpty()) { - return snapshots; - } - for (PlayerRef playerRef : players) { - if (playerRef == null) { - continue; - } - Transform transform = playerRef.getTransform(); - int chunkX = ChunkUtil.chunkCoordinate(transform.getPosition().getX()); - int chunkZ = ChunkUtil.chunkCoordinate(transform.getPosition().getZ()); - long chunkIndex = ChunkUtil.indexChunk(chunkX, chunkZ); - - int viewRadius = Player.DEFAULT_VIEW_RADIUS_CHUNKS; - try { - Ref entityRef = playerRef.getReference(); - if (entityRef != null && entityRef.isValid()) { - Player player = entityRef.getStore().getComponent(entityRef, Player.getComponentType()); - if (player != null) { - viewRadius = player.getViewRadius(); - } - } - } catch (Throwable ignored) { - // Fall back to default - } - - snapshots.add(new PlayerSnapshot(chunkIndex, Math.max(viewRadius + offset, 2))); + playerCount); } - return snapshots; } - private static boolean isChunkNeeded(List playerSnapshots, long chunkIndex) { - for (PlayerSnapshot snapshot : playerSnapshots) { - if (chebyshevDistance(chunkIndex, snapshot.chunkIndex) <= snapshot.safeRadius) { + private boolean isChunkNeeded(World world, long chunkIndex) { + for (PlayerRef playerRef : world.getPlayerRefs()) { + if (playerRef != null && playerRef.getChunkTracker().shouldBeVisible(chunkIndex)) { return true; } } return false; } - private record PlayerSnapshot(long chunkIndex, int safeRadius) {} - - private static int chebyshevDistance(long index1, long index2) { - int x1 = ChunkUtil.xOfChunkIndex(index1); - int z1 = ChunkUtil.zOfChunkIndex(index1); - int x2 = ChunkUtil.xOfChunkIndex(index2); - int z2 = ChunkUtil.zOfChunkIndex(index2); - return Math.max(Math.abs(x1 - x2), Math.abs(z1 - z2)); - } - private static boolean isInKeepLoadedRegion(World world, WorldChunk worldChunk) { Box2D keepLoaded = world.getWorldConfig().getChunkConfig().getKeepLoadedRegion(); if (keepLoaded == null) { @@ -275,4 +232,9 @@ private static boolean isInKeepLoadedRegion(World world, WorldChunk worldChunk) && maxZ >= keepLoaded.min.y && minZ <= keepLoaded.max.y; } + + private static boolean isSpawnChunk(World world, WorldChunk worldChunk) { + // Protect spawn chunk at origin (0,0) + return worldChunk.getX() == 0 && worldChunk.getZ() == 0; + } } diff --git a/plugin/src/main/java/cc/irori/refixes/service/AiTickThrottlerService.java b/plugin/src/main/java/cc/irori/refixes/service/AiTickThrottlerService.java index 87fb67c..e66a3af 100644 --- a/plugin/src/main/java/cc/irori/refixes/service/AiTickThrottlerService.java +++ b/plugin/src/main/java/cc/irori/refixes/service/AiTickThrottlerService.java @@ -3,6 +3,8 @@ import cc.irori.refixes.component.TickThrottled; import cc.irori.refixes.config.impl.AiTickThrottlerConfig; import cc.irori.refixes.util.Logs; +import com.hypixel.hytale.builtin.mounts.NPCMountComponent; +import com.hypixel.hytale.component.ArchetypeChunk; import com.hypixel.hytale.component.ComponentType; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; @@ -14,6 +16,7 @@ import com.hypixel.hytale.server.core.entity.Frozen; import com.hypixel.hytale.server.core.entity.UUIDComponent; import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.entity.movement.MovementStatesComponent; import com.hypixel.hytale.server.core.modules.entity.EntityModule; import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; import com.hypixel.hytale.server.core.universe.PlayerRef; @@ -21,8 +24,11 @@ import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import com.hypixel.hytale.server.npc.components.StepComponent; +import com.hypixel.hytale.server.npc.entities.NPCEntity; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -53,6 +59,9 @@ public class AiTickThrottlerService { private ComponentType stepType; private ComponentType tickThrottledType; private ComponentType playerType; + private ComponentType npcEntityType; + private ComponentType mountType; + private ComponentType movementStatesType; private Query npcQuery; private final Map worldStates = new ConcurrentHashMap<>(); @@ -121,7 +130,18 @@ private void throttle() { worldStates.keySet().removeIf(name -> !worlds.containsKey(name)); for (World world : worlds.values()) { - world.execute(() -> processWorld(world, cfg)); + WorldState state = worldStates.computeIfAbsent(world.getName(), _k -> new WorldState()); + // Skip if prior cycle's world.execute is still queued (pile-up guard). + if (!state.inProgress.compareAndSet(false, true)) { + continue; + } + world.execute(() -> { + try { + processWorld(world, cfg); + } finally { + state.inProgress.set(false); + } + }); } } @@ -139,7 +159,11 @@ private void processWorld(World world, AiTickThrottlerConfig cfg) { // No players online: freeze all NPCs once, then skip subsequent cycles if (playerChunks.isEmpty()) { if (!state.frozenWithoutPlayers) { - freezeAllNpcs(store); + Set excludedNpcTypes = + new HashSet<>(Arrays.asList(cfg.getValue(AiTickThrottlerConfig.THROTTLE_EXCLUDED_NPC_TYPES))); + boolean excludeMountsOnEmpty = cfg.getValue(AiTickThrottlerConfig.THROTTLE_EXCLUDE_MOUNTS); + boolean excludeFlyingOnEmpty = cfg.getValue(AiTickThrottlerConfig.THROTTLE_EXCLUDE_FLYING); + freezeAllNpcs(store, excludedNpcTypes, excludeMountsOnEmpty, excludeFlyingOnEmpty); state.frozenWithoutPlayers = true; } return; @@ -168,12 +192,24 @@ private void processWorld(World world, AiTickThrottlerConfig cfg) { StepComponent farStep = new StepComponent(farSec); StepComponent veryFarStep = new StepComponent(veryFarSec); + Set excludedNpcTypes = + new HashSet<>(Arrays.asList(cfg.getValue(AiTickThrottlerConfig.THROTTLE_EXCLUDED_NPC_TYPES))); + boolean excludeMounts = cfg.getValue(AiTickThrottlerConfig.THROTTLE_EXCLUDE_MOUNTS); + boolean excludeFlying = cfg.getValue(AiTickThrottlerConfig.THROTTLE_EXCLUDE_FLYING); + // Reuse seen set to avoid allocating a new ConcurrentHashMap each cycle state.seen.clear(); + // Bail out per-entity if the cycle exceeds the wall-clock budget; remaining are picked up next cycle. + int maxCycleMs = Math.max(0, cfg.getValue(AiTickThrottlerConfig.MAX_CYCLE_MS)); + long budgetNanos = maxCycleMs == 0 ? Long.MAX_VALUE : TimeUnit.MILLISECONDS.toNanos(maxCycleMs); + long cycleStartNanos = System.nanoTime(); + store.forEachEntityParallel(npcQuery, (index, archetypeChunk, commandBuffer) -> { - // Skip player entities - if (playerType != null && archetypeChunk.getArchetype().contains(playerType)) { + if (System.nanoTime() - cycleStartNanos > budgetNanos) { + return; + } + if (isExcluded(index, archetypeChunk, excludedNpcTypes, excludeMounts, excludeFlying)) { return; } @@ -184,8 +220,8 @@ private void processWorld(World world, AiTickThrottlerConfig cfg) { } // Compute chunk distance to nearest player - int entityChunkX = ChunkUtil.chunkCoordinate(transform.getPosition().getX()); - int entityChunkZ = ChunkUtil.chunkCoordinate(transform.getPosition().getZ()); + int entityChunkX = ChunkUtil.chunkCoordinate(transform.getPosition().x()); + int entityChunkZ = ChunkUtil.chunkCoordinate(transform.getPosition().z()); int chunkDist = closestPlayerChunkDistance(entityChunkX, entityChunkZ, playerChunks); UUID entityId = uuid.getUuid(); state.seen.add(entityId); @@ -255,9 +291,10 @@ private void processWorld(World world, AiTickThrottlerConfig cfg) { state.entries.keySet().retainAll(state.seen); } - private void freezeAllNpcs(Store store) { + private void freezeAllNpcs( + Store store, Set excludedNpcTypes, boolean excludeMounts, boolean excludeFlying) { store.forEachEntityParallel(npcQuery, (index, archetypeChunk, commandBuffer) -> { - if (playerType != null && archetypeChunk.getArchetype().contains(playerType)) { + if (isExcluded(index, archetypeChunk, excludedNpcTypes, excludeMounts, excludeFlying)) { return; } boolean frozen = archetypeChunk.getComponent(index, frozenType) != null; @@ -273,6 +310,36 @@ private void freezeAllNpcs(Store store) { }); } + private boolean isExcluded( + int index, + ArchetypeChunk archetypeChunk, + Set excludedNpcTypes, + boolean excludeMounts, + boolean excludeFlying) { + if (playerType != null && archetypeChunk.getArchetype().contains(playerType)) { + return true; + } + if (excludeMounts && mountType != null && archetypeChunk.getComponent(index, mountType) != null) { + return true; + } + if (excludeFlying && movementStatesType != null) { + MovementStatesComponent ms = archetypeChunk.getComponent(index, movementStatesType); + if (ms != null) { + var states = ms.getMovementStates(); + if (states != null && states.flying) { + return true; + } + } + } + if (!excludedNpcTypes.isEmpty()) { + NPCEntity npcEntity = archetypeChunk.getComponent(index, npcEntityType); + if (npcEntity != null && excludedNpcTypes.contains(npcEntity.getNPCTypeId())) { + return true; + } + } + return false; + } + private static double computeInterval( int chunkDist, int nearChunks, int midChunks, int farChunks, AiTickThrottlerConfig cfg) { if (chunkDist <= nearChunks) { @@ -296,8 +363,8 @@ private static List collectPlayerChunkPositions(Collection pla if (player == null) continue; Transform transform = player.getTransform(); if (transform == null) continue; - int chunkX = ChunkUtil.chunkCoordinate(transform.getPosition().getX()); - int chunkZ = ChunkUtil.chunkCoordinate(transform.getPosition().getZ()); + int chunkX = ChunkUtil.chunkCoordinate(transform.getPosition().x()); + int chunkZ = ChunkUtil.chunkCoordinate(transform.getPosition().z()); positions.add(new int[] {chunkX, chunkZ}); } return positions; @@ -324,7 +391,8 @@ private boolean resolveComponentTypes() { && uuidType != null && frozenType != null && stepType != null - && tickThrottledType != null) { + && tickThrottledType != null + && npcEntityType != null) { return true; } try { @@ -335,6 +403,9 @@ private boolean resolveComponentTypes() { if (stepType == null) stepType = StepComponent.getComponentType(); if (tickThrottledType == null) tickThrottledType = TickThrottled.getComponentType(); if (playerType == null) playerType = Player.getComponentType(); + if (npcEntityType == null) npcEntityType = NPCEntity.getComponentType(); + if (mountType == null) mountType = NPCMountComponent.getComponentType(); + if (movementStatesType == null) movementStatesType = MovementStatesComponent.getComponentType(); if (npcQuery == null) { npcQuery = Query.and(npcType, transformType); @@ -347,10 +418,14 @@ private boolean resolveComponentTypes() { && uuidType != null && frozenType != null && stepType != null - && tickThrottledType != null; + && tickThrottledType != null + && npcEntityType != null; } private static final class WorldState { + final java.util.concurrent.atomic.AtomicBoolean inProgress = + new java.util.concurrent.atomic.AtomicBoolean(false); + final Map entries = new ConcurrentHashMap<>(); final Set seen = ConcurrentHashMap.newKeySet(); boolean frozenWithoutPlayers; diff --git a/plugin/src/main/java/cc/irori/refixes/service/ChunkLoaderService.java b/plugin/src/main/java/cc/irori/refixes/service/ChunkLoaderService.java new file mode 100644 index 0000000..4ee3432 --- /dev/null +++ b/plugin/src/main/java/cc/irori/refixes/service/ChunkLoaderService.java @@ -0,0 +1,206 @@ +package cc.irori.refixes.service; + +import cc.irori.refixes.util.Logs; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.util.ChunkUtil; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; +import com.hypixel.hytale.server.core.universe.world.storage.ChunkStore; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class ChunkLoaderService { + + private static final HytaleLogger LOGGER = Logs.logger(); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private final Path dataDir; + + private final Map> keptChunksByWorld = new ConcurrentHashMap<>(); + + public ChunkLoaderService(Path pluginDataDir) { + this.dataDir = pluginDataDir.resolve("chunkloaders"); + loadAll(); + } + + public void addChunk(World world, int chunkX, int chunkZ, String label) { + long chunkIndex = ChunkUtil.indexChunk(chunkX, chunkZ); + String worldName = world.getName(); + + keptChunksByWorld + .computeIfAbsent(worldName, k -> new ConcurrentHashMap<>()) + .put(chunkIndex, label != null ? label : ""); + save(worldName); + + world.execute(() -> { + Ref chunkRef = world.getChunkStore().getChunkReference(chunkIndex); + if (chunkRef != null && chunkRef.isValid()) { + WorldChunk chunk = + world.getChunkStore().getStore().getComponent(chunkRef, WorldChunk.getComponentType()); + if (chunk != null) { + chunk.addKeepLoaded(); + LOGGER.atInfo().log("Added chunk loader at %d, %d in world %s", chunkX, chunkZ, worldName); + } + } + }); + } + + public void removeChunk(World world, int chunkX, int chunkZ) { + long chunkIndex = ChunkUtil.indexChunk(chunkX, chunkZ); + String worldName = world.getName(); + + Map chunks = keptChunksByWorld.get(worldName); + if (chunks != null) { + chunks.remove(chunkIndex); + save(worldName); + } + + world.execute(() -> { + Ref chunkRef = world.getChunkStore().getChunkReference(chunkIndex); + if (chunkRef != null && chunkRef.isValid()) { + WorldChunk chunk = + world.getChunkStore().getStore().getComponent(chunkRef, WorldChunk.getComponentType()); + if (chunk != null) { + chunk.removeKeepLoaded(); + LOGGER.atInfo().log("Removed chunk loader at %d, %d in world %s", chunkX, chunkZ, worldName); + } + } + }); + } + + public Map getKeptChunks(String worldName) { + return keptChunksByWorld.getOrDefault(worldName, new ConcurrentHashMap<>()); + } + + public Long findChunkByLabel(String worldName, String label) { + Map chunks = keptChunksByWorld.get(worldName); + if (chunks == null) { + return null; + } + for (Map.Entry entry : chunks.entrySet()) { + if (label.equalsIgnoreCase(entry.getValue())) { + return entry.getKey(); + } + } + return null; + } + + public void loadWorld(World world) { + String worldName = world.getName(); + Map chunks = keptChunksByWorld.get(worldName); + if (chunks == null || chunks.isEmpty()) { + return; + } + + world.execute(() -> { + int loaded = 0; + for (long chunkIndex : chunks.keySet()) { + Ref chunkRef = world.getChunkStore().getChunkReference(chunkIndex); + if (chunkRef != null && chunkRef.isValid()) { + WorldChunk chunk = + world.getChunkStore().getStore().getComponent(chunkRef, WorldChunk.getComponentType()); + if (chunk != null) { + chunk.addKeepLoaded(); + loaded++; + } + } + } + if (loaded > 0) { + LOGGER.atInfo().log("Loaded %d chunk loaders in world %s", loaded, worldName); + } + }); + } + + public void unloadWorld(World world) { + String worldName = world.getName(); + Map chunks = keptChunksByWorld.get(worldName); + if (chunks == null || chunks.isEmpty()) { + return; + } + + world.execute(() -> { + for (long chunkIndex : chunks.keySet()) { + Ref chunkRef = world.getChunkStore().getChunkReference(chunkIndex); + if (chunkRef != null && chunkRef.isValid()) { + WorldChunk chunk = + world.getChunkStore().getStore().getComponent(chunkRef, WorldChunk.getComponentType()); + if (chunk != null) { + chunk.removeKeepLoaded(); + } + } + } + }); + } + + private void save(String worldName) { + try { + Files.createDirectories(dataDir); + Map chunks = keptChunksByWorld.get(worldName); + if (chunks == null || chunks.isEmpty()) { + Files.deleteIfExists(dataDir.resolve(worldName + ".json")); + return; + } + + List chunkDataList = new ArrayList<>(); + for (Map.Entry entry : chunks.entrySet()) { + long chunkIndex = entry.getKey(); + chunkDataList.add(new ChunkData( + ChunkUtil.xOfChunkIndex(chunkIndex), ChunkUtil.zOfChunkIndex(chunkIndex), entry.getValue())); + } + + String json = GSON.toJson(chunkDataList); + Files.writeString(dataDir.resolve(worldName + ".json"), json); + } catch (IOException e) { + LOGGER.atSevere().withCause(e).log("Failed to save chunk loaders for world %s", worldName); + } + } + + private void loadAll() { + try { + if (!Files.exists(dataDir)) { + return; + } + + Files.list(dataDir) + .filter(path -> path.toString().endsWith(".json")) + .forEach(path -> { + try { + String worldName = path.getFileName().toString().replace(".json", ""); + String json = Files.readString(path); + List chunkDataList = + GSON.fromJson(json, new TypeToken>() {}.getType()); + + Map chunks = new ConcurrentHashMap<>(); + for (ChunkData data : chunkDataList) { + chunks.put(ChunkUtil.indexChunk(data.x, data.z), data.label != null ? data.label : ""); + } + keptChunksByWorld.put(worldName, chunks); + LOGGER.atInfo().log("Loaded %d chunk loaders for world %s", chunks.size(), worldName); + } catch (Exception e) { + LOGGER.atSevere().withCause(e).log("Failed to load chunk loaders from %s", path); + } + }); + } catch (IOException e) { + LOGGER.atSevere().withCause(e).log("Failed to load chunk loaders"); + } + } + + private static class ChunkData { + int x, z; + String label; + + ChunkData(int x, int z, String label) { + this.x = x; + this.z = z; + this.label = label; + } + } +} diff --git a/plugin/src/main/java/cc/irori/refixes/service/IdlePlayerService.java b/plugin/src/main/java/cc/irori/refixes/service/IdlePlayerService.java index d6011b6..863f758 100644 --- a/plugin/src/main/java/cc/irori/refixes/service/IdlePlayerService.java +++ b/plugin/src/main/java/cc/irori/refixes/service/IdlePlayerService.java @@ -5,7 +5,6 @@ import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; import com.hypixel.hytale.logger.HytaleLogger; -import com.hypixel.hytale.math.vector.Vector3d; import com.hypixel.hytale.server.core.HytaleServer; import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.modules.entity.player.ChunkTracker; @@ -18,6 +17,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import org.joml.Vector3d; /** * Detects AFK players and reduces their view/hot/minLoaded @@ -235,8 +235,8 @@ private static Player getPlayerComponent(PlayerRef playerRef) { } private static boolean hasPlayerMoved(Vector3d prev, Vector3d curr, double threshold) { - double dx = prev.getX() - curr.getX(); - double dz = prev.getZ() - curr.getZ(); + double dx = prev.x() - curr.x(); + double dz = prev.z() - curr.z(); // Only check XZ movement; ignore Y to avoid false positives from falling return dx * dx + dz * dz > threshold * threshold; } diff --git a/plugin/src/main/java/cc/irori/refixes/service/PerPlayerHotRadiusService.java b/plugin/src/main/java/cc/irori/refixes/service/PerPlayerHotRadiusService.java index fa8ec8a..1a003b4 100644 --- a/plugin/src/main/java/cc/irori/refixes/service/PerPlayerHotRadiusService.java +++ b/plugin/src/main/java/cc/irori/refixes/service/PerPlayerHotRadiusService.java @@ -8,7 +8,7 @@ import com.hypixel.hytale.server.core.modules.entity.player.ChunkTracker; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.Universe; -import java.util.List; +import java.util.Collection; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -84,7 +84,7 @@ private static int applyToAllPlayers(int targetRadius) { int minRadius = config.getValue(PerPlayerHotRadiusConfig.MIN_RADIUS); int maxRadius = config.getValue(PerPlayerHotRadiusConfig.MAX_RADIUS); - List players = Universe.get().getPlayers(); + Collection players = Universe.get().getPlayers(); if (players.isEmpty()) { return 0; } diff --git a/plugin/src/main/java/cc/irori/refixes/service/WatchdogService.java b/plugin/src/main/java/cc/irori/refixes/service/WatchdogService.java index 0a66629..b6ee167 100644 --- a/plugin/src/main/java/cc/irori/refixes/service/WatchdogService.java +++ b/plugin/src/main/java/cc/irori/refixes/service/WatchdogService.java @@ -5,15 +5,20 @@ import com.hypixel.hytale.builtin.instances.InstancesPlugin; import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.server.core.HytaleServer; +import com.hypixel.hytale.server.core.Message; import com.hypixel.hytale.server.core.ShutdownReason; +import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.events.AddWorldEvent; +import com.hypixel.hytale.server.core.universe.world.events.RemoveWorldEvent; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Stream; @@ -22,19 +27,22 @@ public class WatchdogService { private static final long WORLD_RESPONSE_ERROR = -1L; + private static final int MAX_RESTART_FAILURES = 5; private static final HytaleLogger LOGGER = Logs.logger(); private final AtomicLong defaultWorldResponse = new AtomicLong(System.currentTimeMillis()); private final Map worldResponseMap = new ConcurrentHashMap<>(); + private final Map worldRestartFailures = new ConcurrentHashMap<>(); + private final Set worldsGivenUp = ConcurrentHashMap.newKeySet(); + private final Set intentionallyRemoved = ConcurrentHashMap.newKeySet(); + private final Set selfInitiatedRemovals = ConcurrentHashMap.newKeySet(); private Thread watchdogThread; private World lastDefaultWorld; private volatile State state = State.ACTIVATING; - public WatchdogService() { - lastDefaultWorld = Universe.get().getDefaultWorld(); - } + public WatchdogService() {} public State getState() { return state; @@ -45,13 +53,40 @@ public void registerService() { start(); } + public void registerEvents(JavaPlugin plugin) { + plugin.getEventRegistry().registerGlobal(RemoveWorldEvent.class, this::onRemoveWorld); + plugin.getEventRegistry().registerGlobal(AddWorldEvent.class, this::onAddWorld); + } + + private void onRemoveWorld(RemoveWorldEvent event) { + // EXCEPTIONAL = crash path; let the watchdog auto-restart those. + if (event.getRemovalReason() == RemoveWorldEvent.RemovalReason.EXCEPTIONAL) { + return; + } + String name = event.getWorld().getName(); + if (selfInitiatedRemovals.remove(name)) { + // The watchdog itself initiated this removal as part of an auto-restart. + return; + } + worldResponseMap.remove(name); + intentionallyRemoved.add(name); + LOGGER.atInfo().log("World '%s' removed externally; watchdog will not auto-restart it", name); + } + + private void onAddWorld(AddWorldEvent event) { + intentionallyRemoved.remove(event.getWorld().getName()); + } + public void unregisterService() { LOGGER.atInfo().log("Stopping server watchdog"); - watchdogThread.interrupt(); + if (watchdogThread != null) { + watchdogThread.interrupt(); + } } private void start() { - LOGGER.atInfo().log("Starting server watchdog (default world: %s)", lastDefaultWorld.getName()); + String worldName = lastDefaultWorld != null ? lastDefaultWorld.getName() : ""; + LOGGER.atInfo().log("Starting server watchdog (default world: %s)", worldName); watchdogThread = new Thread(this::runWatchdog, "Refixes-Watchdog"); watchdogThread.setDaemon(true); watchdogThread.start(); @@ -193,7 +228,7 @@ private void watchForAutoRestartingWorlds() { } else if (response != null) { long elapsed = System.currentTimeMillis() - response; if (elapsed > config.getValue(WatchdogConfig.THREAD_TIMEOUT_MS)) { - LOGGER.atSevere().log("World %s did not respond for %.2f seconds.", worldName, elapsed / 1000); + LOGGER.atSevere().log("World %s did not respond for %.2f seconds.", worldName, elapsed / 1000.0); restart = true; } } @@ -203,24 +238,46 @@ private void watchForAutoRestartingWorlds() { if (!Universe.get().isWorldLoadable(worldName)) { continue; } + if (worldsGivenUp.contains(worldName)) { + continue; + } + if (intentionallyRemoved.contains(worldName)) { + continue; + } LOGGER.atSevere().log("========== AUTO WORLD RESTART =========="); LOGGER.atSevere().log("World: %s", worldName); dumpThreads(worldName); LOGGER.atInfo().log("Attempting to unload world: " + worldName); - try { - Universe.get().removeWorld(worldName); - } catch (NullPointerException e) { - LOGGER.atWarning().withCause(e).log("Exception on unloading world %s", worldName); + if (Universe.get().getWorld(worldName) != null) { + selfInitiatedRemovals.add(worldName); + try { + Universe.get().removeWorld(worldName); + } catch (Exception e) { + LOGGER.atWarning().withCause(e).log("Exception on unloading world %s", worldName); + } finally { + // Defensive: cleared by the event listener on success; this covers the case + // where the listener never ran (e.g. removeWorld threw before dispatching). + selfInitiatedRemovals.remove(worldName); + } } LOGGER.atInfo().log("Restarting world: %s", worldName); try { Universe.get().loadWorld(worldName).join(); LOGGER.atInfo().log("World %s loaded", worldName); + worldRestartFailures.remove(worldName); } catch (Exception e) { - LOGGER.atSevere().withCause(e).log("Failed to load world: %s", worldName); + int failures = worldRestartFailures.merge(worldName, 1, Integer::sum); + LOGGER.atSevere().withCause(e).log( + "Failed to load world: %s (attempt %d/%d)", worldName, failures, MAX_RESTART_FAILURES); + if (failures >= MAX_RESTART_FAILURES) { + worldsGivenUp.add(worldName); + LOGGER.atSevere().log( + "Giving up on auto-restarting world '%s' after %d failures. Resolve the underlying issue and restart the server.", + worldName, failures); + } } } } @@ -294,7 +351,8 @@ private static void triggerWatchdog(String reason) throws InterruptedException { dumpThreads(); Thread.sleep(5000); - HytaleServer.get().shutdownServer(ShutdownReason.CRASH.withMessage("Watchdog triggered a shutdown")); + HytaleServer.get() + .shutdownServer(ShutdownReason.CRASH.withMessage(Message.raw("Watchdog triggered a shutdown"))); handleShutdownTimeout(); } diff --git a/plugin/src/main/java/cc/irori/refixes/system/AiTickThrottlerCleanupSystem.java b/plugin/src/main/java/cc/irori/refixes/system/AiTickThrottlerCleanupSystem.java index 3258856..27fdb57 100644 --- a/plugin/src/main/java/cc/irori/refixes/system/AiTickThrottlerCleanupSystem.java +++ b/plugin/src/main/java/cc/irori/refixes/system/AiTickThrottlerCleanupSystem.java @@ -1,9 +1,11 @@ package cc.irori.refixes.system; import cc.irori.refixes.component.TickThrottled; +import cc.irori.refixes.config.ConfigurationKey; import cc.irori.refixes.config.impl.AiTickThrottlerConfig; import com.hypixel.hytale.component.AddReason; import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.ComponentType; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.RemoveReason; import com.hypixel.hytale.component.Store; @@ -13,6 +15,10 @@ import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import com.hypixel.hytale.server.npc.components.StepComponent; +import com.hypixel.hytale.server.npc.entities.NPCEntity; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -23,30 +29,53 @@ public void onEntityAdded( @NonNull AddReason addReason, @NonNull Store store, @NonNull CommandBuffer commandBuffer) { - if (!AiTickThrottlerConfig.get().getValue(AiTickThrottlerConfig.CLEANUP_FROZEN_ENTITIES)) { - return; - } if (addReason != AddReason.LOAD) { return; } - boolean sweep; - if (AiTickThrottlerConfig.get().getValue(AiTickThrottlerConfig.LEGACY_CLEANUP)) { - if (commandBuffer.getComponent(ref, TickThrottled.getComponentType()) != null) { - sweep = false; - } else { - sweep = commandBuffer.getComponent(ref, Frozen.getComponentType()) != null - || commandBuffer.getComponent(ref, StepComponent.getComponentType()) != null; + AiTickThrottlerConfig cfg = AiTickThrottlerConfig.get(); + boolean throttlerEnabled = cfg.getValue(AiTickThrottlerConfig.ENABLED); + + if (cfg.getValue(AiTickThrottlerConfig.CLEANUP_FROZEN_ENTITIES)) { + ComponentType tickThrottledType = TickThrottled.getComponentType(); + if (tickThrottledType != null && commandBuffer.getComponent(ref, tickThrottledType) != null) { + if (!isNpcTypeExcluded(ref, commandBuffer, cfg, AiTickThrottlerConfig.CLEANUP_EXCLUDED_NPC_TYPES)) { + commandBuffer.tryRemoveComponent(ref, Frozen.getComponentType()); + commandBuffer.tryRemoveComponent(ref, StepComponent.getComponentType()); + if (!throttlerEnabled) { + commandBuffer.tryRemoveComponent(ref, tickThrottledType); + } + } + } + } + + if (cfg.getValue(AiTickThrottlerConfig.LEGACY_CLEANUP)) { + boolean hasOrphan = commandBuffer.getComponent(ref, Frozen.getComponentType()) != null + || commandBuffer.getComponent(ref, StepComponent.getComponentType()) != null; + if (hasOrphan + && !isNpcTypeExcluded( + ref, commandBuffer, cfg, AiTickThrottlerConfig.LEGACY_CLEANUP_EXCLUDED_NPC_TYPES)) { + commandBuffer.tryRemoveComponent(ref, Frozen.getComponentType()); + commandBuffer.tryRemoveComponent(ref, StepComponent.getComponentType()); } - } else { - sweep = commandBuffer.getComponent(ref, TickThrottled.getComponentType()) != null; } + } - if (sweep) { - commandBuffer.tryRemoveComponent(ref, Frozen.getComponentType()); - commandBuffer.tryRemoveComponent(ref, StepComponent.getComponentType()); - commandBuffer.tryRemoveComponent(ref, TickThrottled.getComponentType()); + private static boolean isNpcTypeExcluded( + Ref ref, + CommandBuffer commandBuffer, + AiTickThrottlerConfig cfg, + ConfigurationKey excludedTypesKey) { + Set excluded = new HashSet<>(Arrays.asList(cfg.getValue(excludedTypesKey))); + if (excluded.isEmpty()) { + return false; + } + ComponentType npcEntityType = NPCEntity.getComponentType(); + if (npcEntityType == null) { + return false; } + NPCEntity npcEntity = commandBuffer.getComponent(ref, npcEntityType); + return npcEntity != null && excluded.contains(npcEntity.getNPCTypeId()); } @Override diff --git a/plugin/src/main/java/cc/irori/refixes/system/CraftingManagerFixSystem.java b/plugin/src/main/java/cc/irori/refixes/system/CraftingManagerFixSystem.java index 56380de..fff0918 100644 --- a/plugin/src/main/java/cc/irori/refixes/system/CraftingManagerFixSystem.java +++ b/plugin/src/main/java/cc/irori/refixes/system/CraftingManagerFixSystem.java @@ -21,6 +21,8 @@ public class CraftingManagerFixSystem extends EntityTickingSystem { private static final HytaleLogger LOGGER = Logs.logger(); + private boolean disabled = false; + public CraftingManagerFixSystem() { Early.requireEnabled(); } @@ -32,10 +34,16 @@ public void tick( @NonNull ArchetypeChunk archetypeChunk, @NonNull Store store, @NonNull CommandBuffer commandBuffer) { + if (disabled) return; try { CraftingManager craftingManager = archetypeChunk.getComponent(index, CraftingManager.getComponentType()); if (craftingManager == null) return; - MixinCraftingManagerAccessor accessor = (MixinCraftingManagerAccessor) craftingManager; + if (!(craftingManager instanceof MixinCraftingManagerAccessor accessor)) { + disabled = true; + LOGGER.atSevere().log( + "CraftingManager is not a MixinCraftingManagerAccessor — Refixes Mixins were not applied (is Hyinit running?). Disabling crafting manager fix."); + return; + } if (accessor.getBlockType() == null) return; Player player = archetypeChunk.getComponent(index, Player.getComponentType()); if (player == null || !player.getWindowManager().getWindows().isEmpty()) return; diff --git a/plugin/src/main/java/cc/irori/refixes/system/EntityDespawnTimerSystem.java b/plugin/src/main/java/cc/irori/refixes/system/EntityDespawnTimerSystem.java index e6263fc..445a8e6 100644 --- a/plugin/src/main/java/cc/irori/refixes/system/EntityDespawnTimerSystem.java +++ b/plugin/src/main/java/cc/irori/refixes/system/EntityDespawnTimerSystem.java @@ -14,6 +14,7 @@ import com.hypixel.hytale.server.core.entity.entities.ProjectileComponent; import com.hypixel.hytale.server.core.modules.entity.DespawnComponent; import com.hypixel.hytale.server.core.modules.entity.item.ItemComponent; +import com.hypixel.hytale.server.core.modules.entity.item.PreventPickup; import com.hypixel.hytale.server.core.modules.projectile.component.Projectile; import com.hypixel.hytale.server.core.modules.time.TimeResource; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; @@ -42,6 +43,10 @@ public void onEntityAdded( return; } + if (store.getComponent(ref, PreventPickup.getComponentType()) != null) { + return; + } + // For loaded entities, only fix null despawn timers (prevents engine NPE) if (addReason == AddReason.LOAD) { DespawnComponent existing = store.getComponent(ref, DespawnComponent.getComponentType()); diff --git a/plugin/src/main/java/cc/irori/refixes/system/ProcessingBenchFixSystem.java b/plugin/src/main/java/cc/irori/refixes/system/ProcessingBenchFixSystem.java deleted file mode 100644 index 6b46d5c..0000000 --- a/plugin/src/main/java/cc/irori/refixes/system/ProcessingBenchFixSystem.java +++ /dev/null @@ -1,59 +0,0 @@ -package cc.irori.refixes.system; - -import cc.irori.refixes.util.Logs; -import com.hypixel.hytale.builtin.crafting.state.ProcessingBenchState; -import com.hypixel.hytale.builtin.crafting.window.BenchWindow; -import com.hypixel.hytale.component.*; -import com.hypixel.hytale.component.query.Query; -import com.hypixel.hytale.component.system.RefSystem; -import com.hypixel.hytale.logger.HytaleLogger; -import com.hypixel.hytale.server.core.universe.world.meta.BlockStateModule; -import com.hypixel.hytale.server.core.universe.world.storage.ChunkStore; -import java.util.Map; -import java.util.UUID; -import org.checkerframework.checker.nullness.compatqual.NonNullDecl; -import org.checkerframework.checker.nullness.compatqual.NullableDecl; - -public class ProcessingBenchFixSystem extends RefSystem { - - private static final HytaleLogger LOGGER = Logs.logger(); - - @Override - public void onEntityAdded( - @NonNullDecl Ref ref, - @NonNullDecl AddReason addReason, - @NonNullDecl Store store, - @NonNullDecl CommandBuffer commandBuffer) { - // No-op - } - - @Override - public void onEntityRemove( - @NonNullDecl Ref ref, - @NonNullDecl RemoveReason removeReason, - @NonNullDecl Store store, - @NonNullDecl CommandBuffer commandBuffer) { - if (removeReason == RemoveReason.UNLOAD) { - return; - } - - try { - ProcessingBenchState benchState = - store.getComponent(ref, BlockStateModule.get().getComponentType(ProcessingBenchState.class)); - if (benchState == null) return; - Map windows = benchState.getWindows(); - if (windows.isEmpty()) return; - - LOGGER.atWarning().log("Clearing %d open windows on bench remove", windows.size()); - windows.clear(); - } catch (Exception e) { - LOGGER.atSevere().withCause(e).log("Failed to apply processing bench fix"); - } - } - - @NullableDecl - @Override - public Query getQuery() { - return BlockStateModule.get().getComponentType(ProcessingBenchState.class); - } -} diff --git a/plugin/src/main/java/cc/irori/refixes/util/WeakLocation.java b/plugin/src/main/java/cc/irori/refixes/util/WeakLocation.java index e80b3fe..91da630 100644 --- a/plugin/src/main/java/cc/irori/refixes/util/WeakLocation.java +++ b/plugin/src/main/java/cc/irori/refixes/util/WeakLocation.java @@ -1,16 +1,19 @@ package cc.irori.refixes.util; +import com.hypixel.hytale.math.vector.Rotation3f; import com.hypixel.hytale.math.vector.Transform; -import com.hypixel.hytale.math.vector.Vector3d; -import com.hypixel.hytale.math.vector.Vector3f; import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.World; import javax.annotation.Nullable; +import org.joml.Vector3d; -public record WeakLocation(String worldName, Vector3d position, Vector3f rotation) { +public record WeakLocation(String worldName, Vector3d position, Rotation3f rotation) { public WeakLocation(String worldName, Transform transform) { - this(worldName, transform.getPosition(), transform.getRotation()); + this( + worldName, + transform != null ? transform.getPosition() : null, + transform != null ? transform.getRotation() : null); } public @Nullable World getWorld() { diff --git a/plugin/src/main/resources/manifest.json b/plugin/src/main/resources/manifest.json index 45f6f9b..7702836 100644 --- a/plugin/src/main/resources/manifest.json +++ b/plugin/src/main/resources/manifest.json @@ -11,7 +11,7 @@ "Url": "https://github.com/KabanFriends" } ], - "ServerVersion": "${hytaleVersion}", + "ServerVersion": ">=${hytaleVersion}", "Dependencies": {}, "OptionalDependencies": {} } \ No newline at end of file