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:
+ *
+ * - Acquire a per-declaring-class lock before executing the body.
+ * - Skip the body entirely if the {@code ItemEntity} parameter has already been
+ * removed (i.e. {@code itemEntity.isRemoved()} returns {@code true}).
+ *
+ *
+ * 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
+ *
+ * - The annotated method must declare an {@code ItemEntity} (or subtype) parameter.
+ * - Annotate only the entry point that consumes the item β not internal helpers.
+ * - Do not add a manual {@code synchronized} keyword or your own
+ * {@code isRemoved()} check; both are injected for you.
+ *
+ *
+ * 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 extends AgeableWaterCreature> 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:
+ *
+ * - Renames the original method body to {@code <name>$async$body} (kept private).
+ * - 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...);
+ * }
+ *
+ *
+ *
+ *
+ * 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: .