From 1780af065d03eb4fa6baf33769526af432df1a8a Mon Sep 17 00:00:00 2001 From: furrymileon Date: Sun, 12 Apr 2026 18:36:33 +0300 Subject: [PATCH] update api --- README.md | 4 + .../async/api/annotation/SyncItemPickup.java | 51 +++ .../async/common/mixin/entity/AllayMixin.java | 12 +- .../common/mixin/entity/DolphinMixin.java | 14 +- .../async/common/mixin/entity/FoxMixin.java | 14 +- .../async/common/mixin/entity/MobMixin.java | 8 +- .../async/common/mixin/entity/PandaMixin.java | 14 +- .../common/mixin/entity/PiglinMixin.java | 14 +- .../common/mixin/entity/RaiderMixin.java | 14 +- .../common/mixin/entity/VillagerMixin.java | 10 +- .../mixin/utils/GeneratedMixinClasspath.java | 164 ++++++++++ .../mixin/utils/SyncAnnotationScanner.java | 144 +++++++++ .../utils/SyncItemPickupTransformer.java | 249 +++++++++++++++ .../mixin/utils/SyncStubMixinGenerator.java | 67 ++++ .../common/mixin/utils/SynchronisePlugin.java | 41 ++- docs/API.md | 297 ++++++++++++++++++ 16 files changed, 1047 insertions(+), 70 deletions(-) create mode 100644 api/src/main/java/com/axalotl/async/api/annotation/SyncItemPickup.java create mode 100644 common/src/main/java/com/axalotl/async/common/mixin/utils/GeneratedMixinClasspath.java create mode 100644 common/src/main/java/com/axalotl/async/common/mixin/utils/SyncAnnotationScanner.java create mode 100644 common/src/main/java/com/axalotl/async/common/mixin/utils/SyncItemPickupTransformer.java create mode 100644 common/src/main/java/com/axalotl/async/common/mixin/utils/SyncStubMixinGenerator.java create mode 100644 docs/API.md diff --git a/README.md b/README.md index 15e6aa56..0be16e19 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ Concurrent Chunk Management Engine, Fabric API, FerriteCore, Lithium, ScalableLu - `/async stats entity` β€” Shows the number of entities processed by Async in various worlds. - `/async stats entity [number] [ticks]` β€” Shows the top [number] entity types by count in descending order. For example, `/async stats entity 10` displays the top 10 most numerous entity types. /async stats entity 5 20 displays the top 5 most numerous entity types with their average mspt usage. +## πŸ“š API for Mod Developers +Making your custom entities or AI compatible with Async? See the full API reference: +**[docs/API.md](docs/API.md)** β€” annotations, concurrent collections, sensor helpers, and fastutil concurrent wrappers, with usage examples. + ## πŸ“₯ Download The mod is available on [Modrinth](https://modrinth.com/mod/async) diff --git a/api/src/main/java/com/axalotl/async/api/annotation/SyncItemPickup.java b/api/src/main/java/com/axalotl/async/api/annotation/SyncItemPickup.java new file mode 100644 index 00000000..a4ebd79c --- /dev/null +++ b/api/src/main/java/com/axalotl/async/api/annotation/SyncItemPickup.java @@ -0,0 +1,51 @@ +package com.axalotl.async.api.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks an entity method that consumes an {@code ItemEntity} (typically the override + * of {@code pickUpItem(ServerLevel, ItemEntity)}) as needing automatic synchronization + * when Async is processing entities on multiple threads. + * + *

At class-load time, AsyncAPI rewrites the annotated method to:

+ *
    + *
  1. Acquire a per-declaring-class lock before executing the body.
  2. + *
  3. Skip the body entirely if the {@code ItemEntity} parameter has already been + * removed (i.e. {@code itemEntity.isRemoved()} returns {@code true}).
  4. + *
+ * + *

Without these two guards, two parallel ticks may race on the same item entity, + * both pass their internal liveness check, and both consume it β€” producing the classic + * item-duplication bug observed on Pandas, Foxes, Allays, Villagers, etc.

+ * + *

Contract

+ * + * + *

Example

+ *
{@code
+ * @AsyncCompatible
+ * public class TruffleHog extends Animal {
+ *
+ *     @Override
+ *     @SyncItemPickup
+ *     protected void pickUpItem(ServerLevel level, ItemEntity item) {
+ *         super.pickUpItem(level, item);
+ *         getBrain().setMemory(MemoryModuleType.LIKED_PLAYER, item.getOwner());
+ *     }
+ * }
+ * }
+ * + * @see com.axalotl.async.api.utils.AsyncCompatible + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface SyncItemPickup { +} diff --git a/common/src/main/java/com/axalotl/async/common/mixin/entity/AllayMixin.java b/common/src/main/java/com/axalotl/async/common/mixin/entity/AllayMixin.java index c7cc6797..489f2c58 100644 --- a/common/src/main/java/com/axalotl/async/common/mixin/entity/AllayMixin.java +++ b/common/src/main/java/com/axalotl/async/common/mixin/entity/AllayMixin.java @@ -1,25 +1,19 @@ package com.axalotl.async.common.mixin.entity; +import com.axalotl.async.api.annotation.SyncItemPickup; import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.animal.allay.Allay; import net.minecraft.world.entity.item.ItemEntity; import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; @Mixin(Allay.class) public abstract class AllayMixin { - @Unique - private static final Object lock = new Object(); - @WrapMethod(method = "pickUpItem") + @SyncItemPickup private void pickUpItem(ServerLevel level, ItemEntity entity, Operation original) { - synchronized (lock) { - if (!entity.isRemoved()) { - original.call(level, entity); - } - } + original.call(level, entity); } } diff --git a/common/src/main/java/com/axalotl/async/common/mixin/entity/DolphinMixin.java b/common/src/main/java/com/axalotl/async/common/mixin/entity/DolphinMixin.java index a8b9b18d..561dffd8 100644 --- a/common/src/main/java/com/axalotl/async/common/mixin/entity/DolphinMixin.java +++ b/common/src/main/java/com/axalotl/async/common/mixin/entity/DolphinMixin.java @@ -1,5 +1,6 @@ package com.axalotl.async.common.mixin.entity; +import com.axalotl.async.api.annotation.SyncItemPickup; import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import net.minecraft.server.level.ServerLevel; @@ -9,24 +10,17 @@ import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.level.Level; import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; @Mixin(Dolphin.class) public abstract class DolphinMixin extends AgeableWaterCreature { - @Unique - private static final Object lock = new Object(); - protected DolphinMixin(EntityType entityType, Level level) { super(entityType, level); } @WrapMethod(method = "pickUpItem") + @SyncItemPickup private void pickUpItem(ServerLevel level, ItemEntity entity, Operation original) { - synchronized (lock) { - if (!entity.isRemoved()) { - original.call(level, entity); - } - } + original.call(level, entity); } -} \ No newline at end of file +} diff --git a/common/src/main/java/com/axalotl/async/common/mixin/entity/FoxMixin.java b/common/src/main/java/com/axalotl/async/common/mixin/entity/FoxMixin.java index b10d5785..10917164 100644 --- a/common/src/main/java/com/axalotl/async/common/mixin/entity/FoxMixin.java +++ b/common/src/main/java/com/axalotl/async/common/mixin/entity/FoxMixin.java @@ -1,25 +1,19 @@ package com.axalotl.async.common.mixin.entity; +import com.axalotl.async.api.annotation.SyncItemPickup; import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.animal.fox.Fox; import net.minecraft.world.entity.item.ItemEntity; import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; @Mixin(Fox.class) public class FoxMixin { - @Unique - private static final Object lock = new Object(); - @WrapMethod(method = "pickUpItem") + @SyncItemPickup private void pickUpItem(ServerLevel level, ItemEntity entity, Operation original) { - synchronized (lock) { - if (!entity.isRemoved()) { - original.call(level, entity); - } - } + original.call(level, entity); } -} \ No newline at end of file +} diff --git a/common/src/main/java/com/axalotl/async/common/mixin/entity/MobMixin.java b/common/src/main/java/com/axalotl/async/common/mixin/entity/MobMixin.java index bd45c408..79dafce7 100644 --- a/common/src/main/java/com/axalotl/async/common/mixin/entity/MobMixin.java +++ b/common/src/main/java/com/axalotl/async/common/mixin/entity/MobMixin.java @@ -1,5 +1,6 @@ package com.axalotl.async.common.mixin.entity; +import com.axalotl.async.api.annotation.SyncItemPickup; import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import net.minecraft.server.level.ServerLevel; @@ -24,10 +25,9 @@ private ItemStack tryEquip(ServerLevel level, ItemStack itemStack, Operation original) { - synchronized (lock) { - original.call(level, entity); - } + original.call(level, entity); } @WrapMethod(method = "setItemSlotAndDropWhenKilled") @@ -43,4 +43,4 @@ private void equipLootStack(ItemStack item, Operation original) { original.call(item); } } -} \ No newline at end of file +} diff --git a/common/src/main/java/com/axalotl/async/common/mixin/entity/PandaMixin.java b/common/src/main/java/com/axalotl/async/common/mixin/entity/PandaMixin.java index b24158dd..8d8e97a0 100644 --- a/common/src/main/java/com/axalotl/async/common/mixin/entity/PandaMixin.java +++ b/common/src/main/java/com/axalotl/async/common/mixin/entity/PandaMixin.java @@ -1,25 +1,19 @@ package com.axalotl.async.common.mixin.entity; +import com.axalotl.async.api.annotation.SyncItemPickup; import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.animal.panda.Panda; import net.minecraft.world.entity.item.ItemEntity; import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; @Mixin(Panda.class) public class PandaMixin { - @Unique - private static final Object lock = new Object(); - @WrapMethod(method = "pickUpItem") + @SyncItemPickup private void pickUpItem(ServerLevel level, ItemEntity entity, Operation original) { - synchronized (lock) { - if (!entity.isRemoved()) { - original.call(level, entity); - } - } + original.call(level, entity); } -} \ No newline at end of file +} diff --git a/common/src/main/java/com/axalotl/async/common/mixin/entity/PiglinMixin.java b/common/src/main/java/com/axalotl/async/common/mixin/entity/PiglinMixin.java index 37d860e6..6001f034 100644 --- a/common/src/main/java/com/axalotl/async/common/mixin/entity/PiglinMixin.java +++ b/common/src/main/java/com/axalotl/async/common/mixin/entity/PiglinMixin.java @@ -1,25 +1,19 @@ package com.axalotl.async.common.mixin.entity; +import com.axalotl.async.api.annotation.SyncItemPickup; import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.entity.monster.piglin.Piglin; import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; @Mixin(Piglin.class) public class PiglinMixin { - @Unique - private static final Object lock = new Object(); - @WrapMethod(method = "pickUpItem") + @SyncItemPickup private void pickUpItem(ServerLevel level, ItemEntity entity, Operation original) { - synchronized (lock) { - if (!entity.isRemoved()) { - original.call(level, entity); - } - } + original.call(level, entity); } -} \ No newline at end of file +} diff --git a/common/src/main/java/com/axalotl/async/common/mixin/entity/RaiderMixin.java b/common/src/main/java/com/axalotl/async/common/mixin/entity/RaiderMixin.java index e624fc73..116daa14 100644 --- a/common/src/main/java/com/axalotl/async/common/mixin/entity/RaiderMixin.java +++ b/common/src/main/java/com/axalotl/async/common/mixin/entity/RaiderMixin.java @@ -1,25 +1,19 @@ package com.axalotl.async.common.mixin.entity; +import com.axalotl.async.api.annotation.SyncItemPickup; import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.entity.raid.Raider; import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; @Mixin(Raider.class) public class RaiderMixin { - @Unique - private static final Object lock = new Object(); - @WrapMethod(method = "pickUpItem") + @SyncItemPickup private void pickUpItem(ServerLevel level, ItemEntity entity, Operation original) { - synchronized (lock) { - if (!entity.isRemoved()) { - original.call(level, entity); - } - } + original.call(level, entity); } -} \ No newline at end of file +} diff --git a/common/src/main/java/com/axalotl/async/common/mixin/entity/VillagerMixin.java b/common/src/main/java/com/axalotl/async/common/mixin/entity/VillagerMixin.java index 86b19194..bfaf542e 100644 --- a/common/src/main/java/com/axalotl/async/common/mixin/entity/VillagerMixin.java +++ b/common/src/main/java/com/axalotl/async/common/mixin/entity/VillagerMixin.java @@ -1,5 +1,6 @@ package com.axalotl.async.common.mixin.entity; +import com.axalotl.async.api.annotation.SyncItemPickup; import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import net.minecraft.server.level.ServerLevel; @@ -15,12 +16,9 @@ public class VillagerMixin { private static final Object lock = new Object(); @WrapMethod(method = "pickUpItem") + @SyncItemPickup private void pickUpItem(ServerLevel level, ItemEntity entity, Operation original) { - synchronized (lock) { - if (!entity.isRemoved()) { - original.call(level, entity); - } - } + original.call(level, entity); } @WrapMethod(method = "spawnGolemIfNeeded") @@ -29,4 +27,4 @@ private void spawnGolemIfNeeded(ServerLevel level, long timestamp, int villagers original.call(level, timestamp, villagersNeededToAgree); } } -} \ No newline at end of file +} diff --git a/common/src/main/java/com/axalotl/async/common/mixin/utils/GeneratedMixinClasspath.java b/common/src/main/java/com/axalotl/async/common/mixin/utils/GeneratedMixinClasspath.java new file mode 100644 index 00000000..576eddf6 --- /dev/null +++ b/common/src/main/java/com/axalotl/async/common/mixin/utils/GeneratedMixinClasspath.java @@ -0,0 +1,164 @@ +package com.axalotl.async.common.mixin.utils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** + * Materializes generated stub mixin class bytes onto the active mod-loader's + * classpath so that Mixin's bytecode provider can resolve them. + * + *

Strategy: write all stubs to a temporary directory tree on disk, then ask + * the platform classloader to add that directory as a code source via + * reflection on internal APIs (Knot for Fabric, ModLauncher for NeoForge). + * If injection fails, we log a clear, actionable error rather than crashing β€” + * the rest of the mod still works, the auto-magic {@code @SyncItemPickup} just + * won't apply to plain classes (it still works for in-tree mixins via + * {@link SyncItemPickupTransformer}).

+ */ +final class GeneratedMixinClasspath { + + private static final Logger LOGGER = LoggerFactory.getLogger(GeneratedMixinClasspath.class); + + private GeneratedMixinClasspath() {} + + /** + * Write every generated class to a temp dir and add that dir to the active classloader. + * + * @param classes map of binary class name (dot-separated) to bytes + * @return {@code true} on success. + */ + static boolean publish(Map classes) { + if (classes.isEmpty()) return true; + Path root; + try { + root = Files.createTempDirectory("async-generated-mixins-"); + root.toFile().deleteOnExit(); + } catch (IOException e) { + LOGGER.error("Async: cannot create temp dir for generated mixins", e); + return false; + } + + for (Map.Entry e : classes.entrySet()) { + Path target = root.resolve(e.getKey().replace('.', '/') + ".class"); + try { + Files.createDirectories(target.getParent()); + Files.write(target, e.getValue()); + target.toFile().deleteOnExit(); + } catch (IOException ex) { + LOGGER.error("Async: cannot write {}", target, ex); + return false; + } + } + + if (tryInjectKnot(root)) return true; + if (tryInjectModLauncher(root)) return true; + + LOGGER.error("Async: failed to inject generated mixin classpath into the active classloader. " + + "@SyncItemPickup on plain classes will not take effect. " + + "This usually means the mod loader version is incompatible β€” please open an issue at " + + "https://github.com/AxalotLDev/Async/issues including your loader version."); + return false; + } + + /** Fabric Knot: reflection on KnotClassDelegate#addCodeSource(Path). */ + private static boolean tryInjectKnot(Path root) { + try { + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + // Walk up parent chain looking for the Knot loader. + ClassLoader cur = cl; + while (cur != null) { + String name = cur.getClass().getName(); + if (name.contains("KnotClassLoader") || name.contains("knot.Knot")) { + Object delegate = readField(cur, "delegate"); + if (delegate == null) { + cur = cur.getParent(); + continue; + } + // KnotClassDelegate.addCodeSource(Path) β€” present in Loader 0.14+ + for (Method m : delegate.getClass().getMethods()) { + if (m.getName().equals("addCodeSource") + && m.getParameterCount() == 1 + && m.getParameterTypes()[0] == Path.class) { + m.invoke(delegate, root); + LOGGER.info("Async: injected generated mixin classpath via Knot ({} entries)", root); + return true; + } + } + } + cur = cur.getParent(); + } + } catch (Throwable t) { + LOGGER.debug("Async: Knot injection path failed", t); + } + return false; + } + + /** NeoForge ModLauncher: reflection on TransformingClassLoader's resource finder. */ + private static boolean tryInjectModLauncher(Path root) { + try { + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + ClassLoader cur = cl; + while (cur != null) { + String name = cur.getClass().getName(); + if (name.contains("TransformingClassLoader") || name.contains("modlauncher")) { + // ModLauncher exposes a 'resourceFinder' field of type Function> + // we wrap it to also serve our temp dir. + if (wrapResourceFinder(cur, root)) { + LOGGER.info("Async: injected generated mixin classpath via ModLauncher ({})", root); + return true; + } + } + cur = cur.getParent(); + } + } catch (Throwable t) { + LOGGER.debug("Async: ModLauncher injection path failed", t); + } + return false; + } + + @SuppressWarnings("unchecked") + private static boolean wrapResourceFinder(ClassLoader cl, Path root) throws Exception { + for (Field f : cl.getClass().getDeclaredFields()) { + if (f.getName().toLowerCase().contains("resourcefinder") + || f.getName().toLowerCase().contains("classbytesfinder")) { + f.setAccessible(true); + Object original = f.get(cl); + if (original instanceof java.util.function.Function fn) { + java.util.function.Function wrapped = path -> { + Path local = root.resolve(path); + if (Files.isRegularFile(local)) { + try { + return java.util.Collections.enumeration(java.util.List.of(local.toUri().toURL())); + } catch (Exception ignored) {} + } + return ((java.util.function.Function) fn).apply(path); + }; + f.set(cl, wrapped); + return true; + } + } + } + return false; + } + + private static Object readField(Object owner, String name) throws ReflectiveOperationException { + Class c = owner.getClass(); + while (c != null) { + try { + Field f = c.getDeclaredField(name); + f.setAccessible(true); + return f.get(owner); + } catch (NoSuchFieldException ignore) { + c = c.getSuperclass(); + } + } + return null; + } +} diff --git a/common/src/main/java/com/axalotl/async/common/mixin/utils/SyncAnnotationScanner.java b/common/src/main/java/com/axalotl/async/common/mixin/utils/SyncAnnotationScanner.java new file mode 100644 index 00000000..86c5fc9b --- /dev/null +++ b/common/src/main/java/com/axalotl/async/common/mixin/utils/SyncAnnotationScanner.java @@ -0,0 +1,144 @@ +package com.axalotl.async.common.mixin.utils; + +import org.objectweb.asm.AnnotationVisitor; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Walks every loaded mod's root paths looking for classes whose methods carry + * {@code @com.axalotl.async.api.annotation.SyncItemPickup}. + * + *

Returns the internal class names ({@code com/example/MyMob}) of every + * candidate target. The actual bytecode rewrite is handled later by + * {@link SyncItemPickupTransformer}; this stage only locates targets so we can + * generate stub mixins for them.

+ */ +final class SyncAnnotationScanner { + + private static final Logger LOGGER = LoggerFactory.getLogger(SyncAnnotationScanner.class); + private static final String ANNOTATION_DESC = "Lcom/axalotl/async/api/annotation/SyncItemPickup;"; + + /** Skip our own classes, vanilla, JDK, and common library namespaces β€” they're either handled or irrelevant. */ + private static final String[] SKIP_PREFIXES = { + "com/axalotl/async/", + "net/minecraft/", + "java/", "javax/", "jdk/", "sun/", + "org/spongepowered/", "com/llamalad7/", "com/bawnorton/", + "org/objectweb/asm/", "org/slf4j/", "org/apache/", + "it/unimi/dsi/fastutil/", "com/google/", "com/mojang/", + }; + + private SyncAnnotationScanner() {} + + static Set scan() { + Set targets = new LinkedHashSet<>(); + for (Path root : discoverModRoots()) { + scanRoot(root, targets); + } + return targets; + } + + private static List discoverModRoots() { + try { + Class loaderCls = Class.forName("net.fabricmc.loader.api.FabricLoader"); + Object loader = loaderCls.getMethod("getInstance").invoke(null); + Collection mods = (Collection) loaderCls.getMethod("getAllMods").invoke(loader); + List roots = new java.util.ArrayList<>(); + for (Object mod : mods) { + Method getRootPaths = mod.getClass().getMethod("getRootPaths"); + @SuppressWarnings("unchecked") + List modRoots = (List) getRootPaths.invoke(mod); + roots.addAll(modRoots); + } + return roots; + } catch (ClassNotFoundException ignore) { + // Not Fabric β€” try NeoForge below. + } catch (Throwable t) { + LOGGER.warn("Async: Fabric mod enumeration failed", t); + } + try { + Class fmlCls = Class.forName("net.neoforged.fml.loading.FMLLoader"); + Object current = fmlCls.getMethod("getCurrent").invoke(null); + Object loadingList = current.getClass().getMethod("getLoadingModList").invoke(current); + Collection modFiles = (Collection) loadingList.getClass().getMethod("getModFiles").invoke(loadingList); + List roots = new java.util.ArrayList<>(); + for (Object mfi : modFiles) { + Object modFile = mfi.getClass().getMethod("getFile").invoke(mfi); + Object secureJar = modFile.getClass().getMethod("getSecureJar").invoke(modFile); + Path root = (Path) secureJar.getClass().getMethod("getRootPath").invoke(secureJar); + if (root != null) roots.add(root); + } + return roots; + } catch (ClassNotFoundException ignore) { + // Neither Fabric nor NeoForge detected. + } catch (Throwable t) { + LOGGER.warn("Async: NeoForge mod enumeration failed", t); + } + LOGGER.warn("Async: no supported mod loader detected β€” @SyncItemPickup auto-scan disabled"); + return List.of(); + } + + private static void scanRoot(Path root, Set out) { + if (!Files.isDirectory(root)) return; + try { + Files.walkFileTree(root, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + String name = file.getFileName().toString(); + if (!name.endsWith(".class")) return FileVisitResult.CONTINUE; + if (name.equals("module-info.class") || name.equals("package-info.class")) return FileVisitResult.CONTINUE; + try (InputStream in = Files.newInputStream(file)) { + scanClass(in, out); + } catch (IOException e) { + LOGGER.debug("Async: cannot read {}", file, e); + } + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + LOGGER.warn("Async: walk failed for {}", root, e); + } + } + + private static void scanClass(InputStream in, Set out) throws IOException { + ClassReader reader = new ClassReader(in); + String internalName = reader.getClassName(); + for (String prefix : SKIP_PREFIXES) { + if (internalName.startsWith(prefix)) return; + } + boolean[] hit = {false}; + reader.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] ex) { + return new MethodVisitor(Opcodes.ASM9) { + @Override + public AnnotationVisitor visitAnnotation(String aDesc, boolean visible) { + if (ANNOTATION_DESC.equals(aDesc)) hit[0] = true; + return null; + } + }; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + if (hit[0]) { + out.add(internalName); + LOGGER.debug("Async: found @SyncItemPickup target {}", internalName); + } + } +} diff --git a/common/src/main/java/com/axalotl/async/common/mixin/utils/SyncItemPickupTransformer.java b/common/src/main/java/com/axalotl/async/common/mixin/utils/SyncItemPickupTransformer.java new file mode 100644 index 00000000..39be03b4 --- /dev/null +++ b/common/src/main/java/com/axalotl/async/common/mixin/utils/SyncItemPickupTransformer.java @@ -0,0 +1,249 @@ +package com.axalotl.async.common.mixin.utils; + +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.AnnotationNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.JumpInsnNode; +import org.objectweb.asm.tree.LabelNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.TryCatchBlockNode; +import org.objectweb.asm.tree.VarInsnNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +/** + * Bytecode rewriter for methods marked with {@code @com.axalotl.async.api.annotation.SyncItemPickup}. + * + *

For each annotated method on the target class:

+ *
    + *
  1. Renames the original method body to {@code <name>$async$body} (kept private).
  2. + *
  3. Replaces the original method with a wrapper that: + *
    + *       if (item == null || item.isRemoved()) return;
    + *       synchronized (item) {
    + *           if (item.isRemoved()) return;
    + *           this.<name>$async$body(args...);
    + *       }
    + *     
    + *
  4. + *
+ * + *

The lock target is the {@code ItemEntity} parameter itself, giving fine-grained + * per-item serialization without needing a static lock field on the target class.

+ */ +final class SyncItemPickupTransformer { + + private static final Logger LOGGER = LoggerFactory.getLogger(SyncItemPickupTransformer.class); + + private static final String ANNOTATION_DESC = "Lcom/axalotl/async/api/annotation/SyncItemPickup;"; + private static final String ITEM_ENTITY_INTERNAL = "net/minecraft/world/entity/item/ItemEntity"; + private static final String ENTITY_INTERNAL = "net/minecraft/world/entity/Entity"; + private static final String IS_REMOVED_DESC = "()Z"; + private static final String BODY_SUFFIX = "$async$body"; + + private SyncItemPickupTransformer() { + } + + static void apply(ClassNode targetClass) { + // Snapshot to avoid CME β€” we add new methods during the loop. + List snapshot = new ArrayList<>(targetClass.methods); + for (MethodNode method : snapshot) { + if (!hasAnnotation(method)) continue; + if ((method.access & Opcodes.ACC_ABSTRACT) != 0) continue; + if (method.name.endsWith(BODY_SUFFIX)) continue; + + int itemArgIdx = findItemEntityArg(method.desc); + if (itemArgIdx < 0) { + LOGGER.warn("@SyncItemPickup on {}.{}{} skipped: no ItemEntity parameter found", + targetClass.name, method.name, method.desc); + continue; + } + transform(targetClass, method, itemArgIdx); + LOGGER.debug("Applied @SyncItemPickup to {}.{}{}", targetClass.name, method.name, method.desc); + } + } + + private static boolean hasAnnotation(MethodNode m) { + if (m.visibleAnnotations != null) { + for (AnnotationNode a : m.visibleAnnotations) { + if (ANNOTATION_DESC.equals(a.desc)) return true; + } + } + if (m.invisibleAnnotations != null) { + for (AnnotationNode a : m.invisibleAnnotations) { + if (ANNOTATION_DESC.equals(a.desc)) return true; + } + } + return false; + } + + private static int findItemEntityArg(String desc) { + Type[] args = Type.getArgumentTypes(desc); + for (int i = 0; i < args.length; i++) { + if (args[i].getSort() == Type.OBJECT + && ITEM_ENTITY_INTERNAL.equals(args[i].getInternalName())) { + return i; + } + } + return -1; + } + + private static void transform(ClassNode owner, MethodNode original, int itemArgIdx) { + boolean isStatic = (original.access & Opcodes.ACC_STATIC) != 0; + Type[] argTypes = Type.getArgumentTypes(original.desc); + Type retType = Type.getReturnType(original.desc); + + // Local slot of the ItemEntity argument inside the method frame. + int itemLocal = isStatic ? 0 : 1; + for (int i = 0; i < itemArgIdx; i++) itemLocal += argTypes[i].getSize(); + + // 1. Build renamed body method (clone of original, private). + String bodyName = original.name + BODY_SUFFIX; + MethodNode body = new MethodNode(Opcodes.ASM9, + (original.access & ~(Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED)) | Opcodes.ACC_PRIVATE | Opcodes.ACC_SYNTHETIC, + bodyName, original.desc, original.signature, + original.exceptions == null ? null : original.exceptions.toArray(new String[0])); + body.instructions = original.instructions; + body.tryCatchBlocks = original.tryCatchBlocks; + body.localVariables = original.localVariables; + body.maxStack = original.maxStack; + body.maxLocals = original.maxLocals; + body.visibleAnnotations = null; + body.invisibleAnnotations = null; + owner.methods.add(body); + + // 2. Replace original with the synchronized wrapper. + original.instructions = new InsnList(); + original.tryCatchBlocks = new ArrayList<>(); + original.localVariables = null; + + InsnList code = original.instructions; + LabelNode lAfterFirstCheck = new LabelNode(); + LabelNode lTryStart = new LabelNode(); + LabelNode lTryEnd = new LabelNode(); + LabelNode lHandler = new LabelNode(); + LabelNode lAfterRecheck = new LabelNode(); + LabelNode lExitNormal = new LabelNode(); + + // Local slots for: lock copy, return-value temp. + int firstFreeLocal = computeFirstFreeLocal(isStatic, argTypes); + int lockLocal = firstFreeLocal; + int retLocal = firstFreeLocal + 1; // only used for non-void + + // Pre-lock fast path: if (item == null || item.isRemoved()) return; + code.add(new VarInsnNode(Opcodes.ALOAD, itemLocal)); + code.add(new JumpInsnNode(Opcodes.IFNULL, lExitNormal)); + code.add(new VarInsnNode(Opcodes.ALOAD, itemLocal)); + code.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, ENTITY_INTERNAL, "isRemoved", IS_REMOVED_DESC, false)); + code.add(new JumpInsnNode(Opcodes.IFNE, lExitNormal)); + code.add(lAfterFirstCheck); + + // lock = item; monitorenter; + code.add(new VarInsnNode(Opcodes.ALOAD, itemLocal)); + code.add(new VarInsnNode(Opcodes.ASTORE, lockLocal)); + code.add(new VarInsnNode(Opcodes.ALOAD, lockLocal)); + code.add(new InsnNode(Opcodes.MONITORENTER)); + + // try { + code.add(lTryStart); + + // re-check inside lock + code.add(new VarInsnNode(Opcodes.ALOAD, itemLocal)); + code.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, ENTITY_INTERNAL, "isRemoved", IS_REMOVED_DESC, false)); + code.add(new JumpInsnNode(Opcodes.IFNE, lAfterRecheck)); + + // call body(this, args...) + if (!isStatic) code.add(new VarInsnNode(Opcodes.ALOAD, 0)); + int argLocal = isStatic ? 0 : 1; + for (Type at : argTypes) { + code.add(new VarInsnNode(at.getOpcode(Opcodes.ILOAD), argLocal)); + argLocal += at.getSize(); + } + code.add(new MethodInsnNode( + isStatic ? Opcodes.INVOKESTATIC : Opcodes.INVOKESPECIAL, + owner.name, bodyName, original.desc, false)); + + if (retType.getSort() != Type.VOID) { + code.add(new VarInsnNode(retType.getOpcode(Opcodes.ISTORE), retLocal)); + } + + code.add(lAfterRecheck); + + // monitorexit (normal path) + code.add(new VarInsnNode(Opcodes.ALOAD, lockLocal)); + code.add(new InsnNode(Opcodes.MONITOREXIT)); + code.add(lTryEnd); + + // load return value (if any) and return + if (retType.getSort() != Type.VOID) { + code.add(new VarInsnNode(retType.getOpcode(Opcodes.ILOAD), retLocal)); + } + code.add(returnInsn(retType)); + + // catch-all handler: monitorexit + rethrow + code.add(lHandler); + code.add(new VarInsnNode(Opcodes.ALOAD, lockLocal)); + code.add(new InsnNode(Opcodes.MONITOREXIT)); + code.add(new InsnNode(Opcodes.ATHROW)); + + // exit-normal (no body call): just return default value + code.add(lExitNormal); + code.add(defaultReturnInsns(retType)); + + // try { ... } finally monitorexit + original.tryCatchBlocks.add(new TryCatchBlockNode(lTryStart, lTryEnd, lHandler, null)); + // also cover the handler itself (in case monitorexit throws) + original.tryCatchBlocks.add(new TryCatchBlockNode(lHandler, lExitNormal, lHandler, null)); + + // Recompute frames; conservative. + original.maxLocals = retLocal + retType.getSize(); + original.maxStack = Math.max(2, retType.getSize() + 1); + } + + private static int computeFirstFreeLocal(boolean isStatic, Type[] argTypes) { + int n = isStatic ? 0 : 1; + for (Type t : argTypes) n += t.getSize(); + return n; + } + + private static AbstractInsnNode returnInsn(Type retType) { + return new InsnNode(retType.getOpcode(Opcodes.IRETURN)); + } + + private static InsnList defaultReturnInsns(Type retType) { + InsnList l = new InsnList(); + switch (retType.getSort()) { + case Type.VOID -> l.add(new InsnNode(Opcodes.RETURN)); + case Type.BOOLEAN, Type.CHAR, Type.BYTE, Type.SHORT, Type.INT -> { + l.add(new InsnNode(Opcodes.ICONST_0)); + l.add(new InsnNode(Opcodes.IRETURN)); + } + case Type.LONG -> { + l.add(new InsnNode(Opcodes.LCONST_0)); + l.add(new InsnNode(Opcodes.LRETURN)); + } + case Type.FLOAT -> { + l.add(new InsnNode(Opcodes.FCONST_0)); + l.add(new InsnNode(Opcodes.FRETURN)); + } + case Type.DOUBLE -> { + l.add(new InsnNode(Opcodes.DCONST_0)); + l.add(new InsnNode(Opcodes.DRETURN)); + } + default -> { + l.add(new InsnNode(Opcodes.ACONST_NULL)); + l.add(new InsnNode(Opcodes.ARETURN)); + } + } + return l; + } +} diff --git a/common/src/main/java/com/axalotl/async/common/mixin/utils/SyncStubMixinGenerator.java b/common/src/main/java/com/axalotl/async/common/mixin/utils/SyncStubMixinGenerator.java new file mode 100644 index 00000000..9571bfcf --- /dev/null +++ b/common/src/main/java/com/axalotl/async/common/mixin/utils/SyncStubMixinGenerator.java @@ -0,0 +1,67 @@ +package com.axalotl.async.common.mixin.utils; + +import org.objectweb.asm.AnnotationVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +/** + * Generates a tiny placeholder mixin class that targets a 3rd-party entity class. + * + *

The stub itself contains no logic β€” its only purpose is to make Mixin + * pull the target class through our {@link SynchronisePlugin#postApply}, where + * {@link SyncItemPickupTransformer} then performs the actual bytecode rewrite + * based on the {@code @SyncItemPickup} annotations the mod author already wrote + * on their own methods.

+ * + *

Equivalent Java:

+ *
{@code
+ * @Mixin(targets = "com.example.MyMob")
+ * public class SyncStub_ { }
+ * }
+ */ +final class SyncStubMixinGenerator { + + static final String STUB_PACKAGE_INTERNAL = "com/axalotl/async/common/mixin/generated"; + private static final String MIXIN_ANNOTATION = "Lorg/spongepowered/asm/mixin/Mixin;"; + + private SyncStubMixinGenerator() {} + + /** @return the FQN (dot-separated) of the generated stub class. */ + static String stubClassName(String targetInternalName) { + String safe = targetInternalName.replace('/', '_').replace('$', '_'); + return STUB_PACKAGE_INTERNAL.replace('/', '.') + ".SyncStub_" + safe; + } + + static String stubClassInternalName(String targetInternalName) { + return stubClassName(targetInternalName).replace('.', '/'); + } + + static byte[] generate(String targetInternalName) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + String stubInternal = stubClassInternalName(targetInternalName); + cw.visit(Opcodes.V21, + Opcodes.ACC_PUBLIC | Opcodes.ACC_SYNTHETIC, + stubInternal, + null, + "java/lang/Object", + null); + + AnnotationVisitor mixinAnno = cw.visitAnnotation(MIXIN_ANNOTATION, true); + AnnotationVisitor targets = mixinAnno.visitArray("targets"); + targets.visit(null, targetInternalName.replace('/', '.')); + targets.visitEnd(); + mixinAnno.visitEnd(); + + MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + ctor.visitCode(); + ctor.visitVarInsn(Opcodes.ALOAD, 0); + ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + ctor.visitInsn(Opcodes.RETURN); + ctor.visitMaxs(1, 1); + ctor.visitEnd(); + + cw.visitEnd(); + return cw.toByteArray(); + } +} diff --git a/common/src/main/java/com/axalotl/async/common/mixin/utils/SynchronisePlugin.java b/common/src/main/java/com/axalotl/async/common/mixin/utils/SynchronisePlugin.java index e60d026b..8254d38d 100644 --- a/common/src/main/java/com/axalotl/async/common/mixin/utils/SynchronisePlugin.java +++ b/common/src/main/java/com/axalotl/async/common/mixin/utils/SynchronisePlugin.java @@ -10,8 +10,11 @@ import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin; import org.spongepowered.asm.mixin.extensibility.IMixinInfo; +import java.util.ArrayList; import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.TreeSet; @@ -24,11 +27,44 @@ public class SynchronisePlugin implements IMixinConfigPlugin { private final Multimap mixin2MethodsExcludeMap = ArrayListMultimap.create(); private final TreeSet syncAllSet = new TreeSet<>(); + /** FQNs of dynamically-generated stub mixins for 3rd-party {@code @SyncItemPickup} targets. */ + private final List generatedStubMixins = new ArrayList<>(); + @Override public void onLoad(String mixinPackage) { mixin2MethodsExcludeMap.put("com.axalotl.async.common.mixin.utils.SyncAllMixin", "net.minecraft.world.level.chunk.ChunkStatus.isOrAfter"); syncAllSet.add("com.axalotl.async.common.mixin.utils.FastUtilSynchronizeMixin"); syncAllSet.add("com.axalotl.async.common.mixin.utils.SyncAllMixin"); + + bootstrapGeneratedStubs(); + } + + /** + * Discover 3rd-party classes carrying {@code @SyncItemPickup}, generate one stub mixin per + * target class, and publish them on the active classloader so Mixin can resolve them when + * {@link #getMixins()} returns their names. + */ + private void bootstrapGeneratedStubs() { + try { + Set targets = SyncAnnotationScanner.scan(); + if (targets.isEmpty()) return; + + Map stubs = new LinkedHashMap<>(); + for (String internalName : targets) { + String fqn = SyncStubMixinGenerator.stubClassName(internalName); + stubs.put(fqn, SyncStubMixinGenerator.generate(internalName)); + generatedStubMixins.add(fqn); + } + + if (!GeneratedMixinClasspath.publish(stubs)) { + generatedStubMixins.clear(); + return; + } + LOGGER.info("Async: registered {} @SyncItemPickup target(s) via auto-generated mixins", generatedStubMixins.size()); + } catch (Throwable t) { + LOGGER.error("Async: @SyncItemPickup auto-discovery failed; in-tree mixins still work", t); + generatedStubMixins.clear(); + } } @Override @@ -47,7 +83,7 @@ public void acceptTargets(Set myTargets, Set otherTargets) { @Override public List getMixins() { - return null; + return generatedStubMixins.isEmpty() ? null : generatedStubMixins; } @Override @@ -59,6 +95,9 @@ public void postApply(String targetClassName, ClassNode targetClass, String mixi Collection targetMethods = mixin2MethodsMap.get(mixinClassName); Collection excludedMethods = mixin2MethodsExcludeMap.get(mixinClassName); + // Always scan for @SyncItemPickup — the annotation is preserved by Mixin on woven methods. + SyncItemPickupTransformer.apply(targetClass); + if (!targetMethods.isEmpty()) { applySynchronizeBit(targetClass, targetMethods, targetClassName); } else if (syncAllSet.contains(mixinClassName)) { diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 00000000..fd143573 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,297 @@ +# AsyncAPI — Mod Developer Reference + +This document describes the public API exposed by **Async** for third-party mod developers who want their custom entities, AI sensors, or data structures to remain correct when Async is processing entities on multiple threads. + +> All API classes live under the package `com.axalotl.async.api.*` and are guaranteed-stable across patch releases. Anything outside this package is internal and may change without notice. + +--- + +## Table of Contents + +- [Annotations](#annotations) + - [`@AsyncCompatible`](#asynccompatible) + - [`@SyncItemPickup`](#syncitempickup) +- [Concurrent Collections](#concurrent-collections) + - [`ConcurrentCollections`](#concurrentcollections) + - [`IteratorSafeOrderedReferenceSet`](#iteratorsafeorderedreferencesete) +- [AI / Sensor Helpers](#ai--sensor-helpers) + - [`SensorUtils`](#sensorutils) +- [FastUtil Concurrent Wrappers](#fastutil-concurrent-wrappers) + - [`Int2ObjectConcurrentHashMap`](#int2objectconcurrenthashmapv) + - [`Long2ObjectConcurrentHashMap`](#long2objectconcurrenthashmapv) + - [`Long2LongConcurrentHashMap`](#long2longconcurrenthashmap) + - [`ConcurrentLongLinkedOpenHashSet`](#concurrentlonglinkedopenhashset) + - [`ConcurrentLongSortedSet`](#concurrentlongsortedset) + - [`FastUtilHackUtil`](#fastutilhackutil) + +--- + +## Annotations + +### `@AsyncCompatible` + +**Package:** `com.axalotl.async.api.utils` +**Target:** `TYPE` · **Retention:** `RUNTIME` + +A marker annotation you place on **your custom entity class** to signal to Async that the class has been audited for concurrent ticking and does not require the fallback synchronized-tick path. + +Use it only after you have: +- Replaced any non-thread-safe collections in your entity with their concurrent equivalents (see below). +- Verified that any inter-entity interaction (item pickup, breeding, container access, etc.) is properly synchronized. + +```java +import com.axalotl.async.api.utils.AsyncCompatible; +import net.minecraft.world.entity.animal.Animal; + +@AsyncCompatible +public class MyCustomMob extends Animal { + // ... entity code that is safe to tick in parallel +} +``` + +If your entity is **not** marked, Async will tick it on the main server thread, which is safe but slower. + +--- + +### `@SyncItemPickup` + +**Package:** `com.axalotl.async.api.annotation` +**Target:** `METHOD` · **Retention:** `RUNTIME` + +Place this annotation on your entity's `pickUpItem(ServerLevel, ItemEntity)` method (or any equivalent method that consumes an `ItemEntity`) to make it safe to call from multiple threads. + +**Where you can put it:** +- Directly on a method in your **own entity class** (the normal case — no Mixin needed). +- On a `@WrapMethod` method in your own Mixin (if you mix into a third-party entity you don't own). + +Both are auto-detected at boot — Async scans every loaded mod's classes for the annotation and rewires them. No registration, no extra configuration, no Gradle plugin. + +**What it does at class-load time:** +1. The annotated method's body is moved into a private helper. +2. The original method becomes a wrapper that synchronizes on the `ItemEntity` argument and skips the call if `entity.isRemoved()` (checked twice — once before locking for the fast path, once inside the lock). + +**Why this is necessary:** without these two guards, two parallel ticks can race on the same `ItemEntity`, both pass their internal "is the item still alive?" check, and both call `pickUpItem`. Result — the item is consumed twice and duplicated in the inventories of two different mobs (a classic dupe bug seen on Pandas, Foxes, Allays, Villagers, etc.). + +**Contract:** +- The annotated method **must** take an `ItemEntity` (or subtype) as one of its parameters. +- Mark exactly the entry point that touches the item — not every helper. +- Combine with `@AsyncCompatible` on the enclosing class. + +```java +import com.axalotl.async.api.annotation.SyncItemPickup; +import com.axalotl.async.api.utils.AsyncCompatible; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.animal.Animal; + +@AsyncCompatible +public class TruffleHog extends Animal { + + @Override + @SyncItemPickup + protected void pickUpItem(ServerLevel level, ItemEntity item) { + // your normal pickup logic — Async injects sync + isRemoved() guard for you + super.pickUpItem(level, item); + getBrain().setMemory(MemoryModuleType.LIKED_PLAYER, item.getOwner()); + } +} +``` + +> **Heads-up:** do not also add a manual `synchronized` keyword or your own `isRemoved()` check — the annotation already provides both, and double-locking adds latency for no benefit. + +--- + +## Concurrent Collections + +### `ConcurrentCollections` + +**Package:** `com.axalotl.async.api.utils` + +Tiny factory class for the three most-needed thread-safe JDK collection shapes. Use these wherever you would have written `new HashSet<>()`, `new HashMap<>()`, or `Collectors.toList()` in code that may run on an Async worker thread. + +| Method | Returns | Backed by | +| --- | --- | --- | +| `static Set newHashSet()` | thread-safe `Set` | `Collections.newSetFromMap(new ConcurrentHashMap<>())` | +| `static Map newHashMap()` | thread-safe `Map` | `ConcurrentHashMap` | +| `static Collector> toList()` | stream collector | `CopyOnWriteArrayList` | + +```java +import com.axalotl.async.api.utils.ConcurrentCollections; + +public class HiveTracker { + private final Set knownBees = ConcurrentCollections.newHashSet(); + private final Map lastVisit = ConcurrentCollections.newHashMap(); + + public List snapshot() { + return knownBees.stream().collect(ConcurrentCollections.toList()); + } +} +``` + +--- + +### `IteratorSafeOrderedReferenceSet` + +**Package:** `com.axalotl.async.api.utils` + +An ordered, reference-equality set that supports **safe concurrent iteration with structural modification** during the iteration. Used internally by Async for entity-tick lists; exposed for mods that maintain similar order-sensitive entity collections. + +Key properties: +- Reference equality (`==`), not `equals()`. +- Maintains insertion order. +- Iterators don't throw `ConcurrentModificationException` if elements are added/removed mid-iteration. +- Self-defragments when the live ratio drops below `maxFragFactor` (default `0.2`). + +| Member | Purpose | +| --- | --- | +| `int ITERATOR_FLAG_SEE_ADDITIONS` | Pass to `iterator(int)` so the iterator yields elements added *after* it was created. | +| `boolean add(E e)` | Insert; auto-defrag triggers if needed. | +| `boolean remove(E e)` | Mark removed; lazy compaction. | +| `boolean contains(E e)` | Membership test. | +| `int size()` | Live element count. | +| `Iterator iterator()` | Standard iterator (snapshot of current view). | +| `Iterator iterator(int flags)` | Configurable iterator. | +| `void finishRawIterator()` | **Must** be called when you finish a raw iteration to allow defragmentation. | + +```java +import com.axalotl.async.api.utils.IteratorSafeOrderedReferenceSet; + +IteratorSafeOrderedReferenceSet tracked = new IteratorSafeOrderedReferenceSet<>(); +tracked.add(entityA); +tracked.add(entityB); + +Iterator it = tracked.iterator(IteratorSafeOrderedReferenceSet.ITERATOR_FLAG_SEE_ADDITIONS); +try { + while (it.hasNext()) { + Entity e = it.next(); + e.tick(); + // safe to add new entities here — the iterator will see them + } +} finally { + tracked.finishRawIterator(); +} +``` + +--- + +## AI / Sensor Helpers + +### `SensorUtils` + +**Package:** `com.axalotl.async.api.utils` + +Helpers for safely customizing vanilla AI sensors that Async parallelizes. + +#### `wrapSensor(Sensor original, TickWrapper logic)` + +Returns a new `Sensor` that delegates `requires()` to the original sensor but routes `doTick(level, entity)` through your custom lambda. Use this when you want to inject thread-safe logic before/after vanilla sensor work — e.g. to fold your mod's memory-module updates into an existing sensor without subclassing. + +#### `distanceComparator(Entity source)` + +Returns a `Comparator` that orders entities by squared distance to `source`, **caching** each computed distance for the duration of the comparator's lifetime. Use with `List.sort` or `stream().sorted(...)` when sorting hot lists during a tick — avoids the O(n log n) recomputation of `distanceToSqr` that a naïve comparator does. + +```java +import com.axalotl.async.api.utils.SensorUtils; + +Sensor wrapped = SensorUtils.wrapSensor(originalNearestPlayerSensor, (level, villager) -> { + // your pre-tick logic here, runs on whatever thread Async picks + villager.getBrain().setMemory(MY_MEMORY, computeStuff(villager)); + originalNearestPlayerSensor.tick(level, villager); // delegate to vanilla +}); + +List nearby = pool.stream() + .sorted(SensorUtils.distanceComparator(self)) + .toList(); +``` + +--- + +## FastUtil Concurrent Wrappers + +These classes implement standard fastutil interfaces (`Long2ObjectMap`, `LongSortedSet`, etc.) but are **safe for concurrent use**. Use them as drop-in replacements wherever your mod stored entity ids, chunk positions, or block ids in fastutil maps that may now be touched from multiple threads. + +All maps in this section are backed by `java.util.concurrent.ConcurrentHashMap`; the sets are backed by `ConcurrentSkipListSet`. Iteration is weakly consistent — it never throws `ConcurrentModificationException` and reflects state at *some* point during the traversal. + +### `Int2ObjectConcurrentHashMap` + +**Package:** `com.axalotl.async.api.fastutil` +Drop-in for `Int2ObjectOpenHashMap`. + +| Method | Description | +| --- | --- | +| `V get(int key)`, `V put(int, V)`, `V remove(int)` | Standard primitive-keyed operations. | +| `V putIfAbsent(int, V)`, `V computeIfAbsent(int, IntFunction)` | Atomic insert-or-fetch. | +| `boolean replace(int, V, V)` | Atomic CAS-style replace. | +| `ObjectSet> int2ObjectEntrySet()` | Entry view (weakly consistent). | +| `IntSet keySet()`, `ObjectCollection values()` | Live views. | + +```java +Int2ObjectConcurrentHashMap byNetId = new Int2ObjectConcurrentHashMap<>(); +EntityState state = byNetId.computeIfAbsent(entity.getId(), id -> new EntityState(id)); +``` + +### `Long2ObjectConcurrentHashMap` + +**Package:** `com.axalotl.async.api.fastutil` +Drop-in for `Long2ObjectOpenHashMap` — typical use is keying by chunk position (`ChunkPos.toLong()`) or block position (`BlockPos.asLong()`). + +| Method | Description | +| --- | --- | +| `V get(long)`, `V put(long, V)`, `V remove(long)` | Primitive-keyed ops. | +| `V putIfAbsent(long, V)`, `V computeIfAbsent(long, LongFunction)`, `V compute(long, ...)` | Atomic helpers. | +| `boolean replace(long, V, V)` | CAS replace. | +| `ObjectSet> long2ObjectEntrySet()` | Entry view. | +| `LongSet keySet()`, `ObjectCollection values()` | Live views. | + +```java +Long2ObjectConcurrentHashMap chunks = new Long2ObjectConcurrentHashMap<>(); +chunks.computeIfAbsent(ChunkPos.asLong(x, z), p -> loadChunkData(p)); +``` + +### `Long2LongConcurrentHashMap` + +**Package:** `com.axalotl.async.api.fastutil` +For long→long mappings (cooldown timers keyed by block position, last-seen-tick-by-entity-id, etc.). + +| Method | Description | +| --- | --- | +| `long get(long)`, `long put(long, long)`, `long remove(long)` | Primitive-only ops. | +| `boolean containsKey(long)`, `boolean containsValue(long)` | Membership. | +| Snapshot views of entry/key/value sets. | + +### `ConcurrentLongLinkedOpenHashSet` + +**Package:** `com.axalotl.async.api.fastutil` +Ordered set of longs with `firstLong()`/`lastLong()`/`removeFirstLong()`/`removeLastLong()`. Use for FIFO/LIFO queues of entity or block ids accessed from multiple threads. + +### `ConcurrentLongSortedSet` + +**Package:** `com.axalotl.async.api.fastutil` +Sorted set of longs implementing fastutil's `LongSortedSet`. Supports `subSet`, `headSet`, `tailSet`, and a `LongBidirectionalIterator iterator(long fromElement)` for ranged scans. + +### `FastUtilHackUtil` + +**Package:** `com.axalotl.async.api.fastutil` +Bridge utilities to wrap raw JDK collections so they implement fastutil interfaces. Useful when you need to hand a JDK `Set` to an API that requires `LongSet`. + +| Method (selected) | Purpose | +| --- | --- | +| `LongSet wrapLongSet(Set)` | View a JDK long set as a fastutil `LongSet`. | +| `IntSet wrapIntSet(Set)` | Same for ints. | +| `ObjectSet> entrySetLongWrap(...)` | Adapt entry sets. | +| `Long2ObjectMap.FastEntrySet entrySetLongWrapFast(...)` | Fast iteration variant. | +| Inner classes `WrappingLongIterator`, `WrappingIntIterator`, etc. | Iterator adapters. | + +```java +Set jdkSet = ConcurrentHashMap.newKeySet(); +LongSet asFastUtil = FastUtilHackUtil.wrapLongSet(jdkSet); +someVanillaApiThatWantsLongSet.accept(asFastUtil); +``` + +--- + +## Versioning & Stability + +- Anything in `com.axalotl.async.api.*` follows semver — breaking changes only on a major bump. +- Internal packages (`com.axalotl.async.common.*`, `com.axalotl.async.fabric.*`, `com.axalotl.async.neoforge.*`) are not API and may change at any time. +- If you need a hook that doesn't exist, please open an issue: .