From 3839ee5da4dd2af7fe1bfb875b62e548e46f7141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20N=C3=BCcke?= Date: Sat, 28 Nov 2020 23:05:24 +0100 Subject: [PATCH] First device API draft. --- .../java/li/cil/oc2/api/bus/DeviceBus.java | 67 +++++ .../cil/oc2/api/bus/DeviceBusController.java | 58 ++++ .../li/cil/oc2/api/bus/DeviceBusElement.java | 52 ++++ .../java/li/cil/oc2/api/bus/package-info.java | 7 + .../li/cil/oc2/api/device/AbstractDevice.java | 56 ++++ .../oc2/api/device/AbstractDeviceMethod.java | 35 +++ .../li/cil/oc2/api/device/CompoundDevice.java | 95 +++++++ .../java/li/cil/oc2/api/device/Device.java | 43 +++ .../li/cil/oc2/api/device/DeviceMethod.java | 76 ++++++ .../oc2/api/device/DeviceMethodParameter.java | 40 +++ .../cil/oc2/api/device/object/Callback.java | 31 +++ .../cil/oc2/api/device/object/Callbacks.java | 61 +++++ .../oc2/api/device/object/ObjectDevice.java | 57 ++++ .../api/device/object/ObjectDeviceMethod.java | 90 ++++++ .../cil/oc2/api/device/object/Parameter.java | 30 ++ .../oc2/api/device/object/package-info.java | 7 + .../li/cil/oc2/api/device/package-info.java | 7 + .../java/li/cil/oc2/common/CommonSetup.java | 3 + .../oc2/common/capabilities/Capabilities.java | 12 + .../DeviceBusElementCapability.java | 11 + .../oc2/common/capabilities/NullStorage.java | 19 ++ .../java/li/cil/oc2/bus/DeviceBusTests.java | 79 ++++++ .../li/cil/oc2/vm/AbstractTestMethod.java | 54 ++++ .../cil/oc2/vm/ObjectDeviceProtocolTests.java | 256 ++++++++++++++++++ src/test/java/li/cil/oc2/vm/package-info.java | 7 + 25 files changed, 1253 insertions(+) create mode 100644 src/main/java/li/cil/oc2/api/bus/DeviceBus.java create mode 100644 src/main/java/li/cil/oc2/api/bus/DeviceBusController.java create mode 100644 src/main/java/li/cil/oc2/api/bus/DeviceBusElement.java create mode 100644 src/main/java/li/cil/oc2/api/bus/package-info.java create mode 100644 src/main/java/li/cil/oc2/api/device/AbstractDevice.java create mode 100644 src/main/java/li/cil/oc2/api/device/AbstractDeviceMethod.java create mode 100644 src/main/java/li/cil/oc2/api/device/CompoundDevice.java create mode 100644 src/main/java/li/cil/oc2/api/device/Device.java create mode 100644 src/main/java/li/cil/oc2/api/device/DeviceMethod.java create mode 100644 src/main/java/li/cil/oc2/api/device/DeviceMethodParameter.java create mode 100644 src/main/java/li/cil/oc2/api/device/object/Callback.java create mode 100644 src/main/java/li/cil/oc2/api/device/object/Callbacks.java create mode 100644 src/main/java/li/cil/oc2/api/device/object/ObjectDevice.java create mode 100644 src/main/java/li/cil/oc2/api/device/object/ObjectDeviceMethod.java create mode 100644 src/main/java/li/cil/oc2/api/device/object/Parameter.java create mode 100644 src/main/java/li/cil/oc2/api/device/object/package-info.java create mode 100644 src/main/java/li/cil/oc2/api/device/package-info.java create mode 100644 src/main/java/li/cil/oc2/common/capabilities/Capabilities.java create mode 100644 src/main/java/li/cil/oc2/common/capabilities/DeviceBusElementCapability.java create mode 100644 src/main/java/li/cil/oc2/common/capabilities/NullStorage.java create mode 100644 src/test/java/li/cil/oc2/bus/DeviceBusTests.java create mode 100644 src/test/java/li/cil/oc2/vm/AbstractTestMethod.java create mode 100644 src/test/java/li/cil/oc2/vm/ObjectDeviceProtocolTests.java create mode 100644 src/test/java/li/cil/oc2/vm/package-info.java diff --git a/src/main/java/li/cil/oc2/api/bus/DeviceBus.java b/src/main/java/li/cil/oc2/api/bus/DeviceBus.java new file mode 100644 index 00000000..68e3788f --- /dev/null +++ b/src/main/java/li/cil/oc2/api/bus/DeviceBus.java @@ -0,0 +1,67 @@ +package li.cil.oc2.api.bus; + +import li.cil.oc2.api.device.Device; + +import java.util.Collection; + +/** + * A device bus provides the interface by which {@link Device} can be made available + * to a {@link DeviceBusController}, which is usually used by VMs to access devices. + *

+ */ +public interface DeviceBus { + /** + * Adds a device to this device bus. + *

+ * Adding a device to the bus does not transfer ownership. In particular, + * the bus will not handle persisting devices that have been added to it. Also, + * the bus does not persist the list of devices (that would imply it persisting + * the devices, as well). Instead, all devices that have been added to the bus + * must be added again after a load, at the latest during the first update/tick + * after the load. + * + * @param device the device to add to the bus. + */ + void addDevice(Device device); + + /** + * Removes a device from this device bus. + *

+ * If the device has not been added with {@link #addDevice(Device)} before calling + * this method, this method is a no-op. + * + * @param device the device to remove from the bus. + */ + void removeDevice(Device device); + + /** + * The list of all devices currently registered with this device bus. + * + * @return the list of all devices that are currently on this bus. + */ + Collection getDevices(); + + /** + * Schedules a rescan of the device bus. + *

+ * This will cause the internal device bus controller to discard the current bus + * state and scan for connected bus segments at an unspecified time in the future + * (typically during the next tick). + *

+ * This should be called on all neighboring {@link DeviceBus} instances when a + * {@link DeviceBus} is created, typically when a block is placed/runs its first + * update after a load. + *

+ * Technically this is a convenience method. It is equivalent to querying for a + * {@link DeviceBusElement}, checking if a controller is set and then scheduling + * a scan on the controller, if present. This way regular code will only ever + * have to interact with this interface. + */ + void scheduleScan(); + +// long addDevice(MemoryMappedDevice device); +// +// void addDevice(final long address, MemoryMappedDevice device); +// +// void removeDevice(MemoryMappedDevice device); +} diff --git a/src/main/java/li/cil/oc2/api/bus/DeviceBusController.java b/src/main/java/li/cil/oc2/api/bus/DeviceBusController.java new file mode 100644 index 00000000..247ade15 --- /dev/null +++ b/src/main/java/li/cil/oc2/api/bus/DeviceBusController.java @@ -0,0 +1,58 @@ +package li.cil.oc2.api.bus; + +import li.cil.oc2.api.device.Device; + +import java.util.Collection; + +/** + * For each device bus there can be exactly one controller. The controller performs the + * actual scan for adjacent {@link DeviceBusElement}s and registers itself with them via + * {@link DeviceBusElement#setController(DeviceBusController)}. + *

+ * This interface is usually provided by VM containers and used to collect connected + * {@link Device}s by aggregating the devices that were added to the device bus elements + * via {@link DeviceBusElement#addDevice(Device)}. + *

+ * The only way for {@link DeviceBusElement}s to be added to a bus is for a + * {@link DeviceBusController} to detect them during a scan. + *

+ * This interface is only of relevance when implementing a VM container or a bus element, + * i.e. something that acts as a "cable" or otherwise extends the bus itself. + * + * @see DeviceBusElement + */ +public interface DeviceBusController { + /** + * Schedules a scan. + *

+ * This will immediately invalidate the current bus, i.e. all {@link DeviceBusElement}s + * will be removed from the controller and {@link #getDevices()} will return an empty + * list after this call. + *

+ * Multiple sequential calls to this method do nothing, the actual scan will be performed + * in the next update. + */ + void scheduleBusScan(); + + /** + * Forces a device map rebuild. + *

+ * This causes the controller to query all registered {@link DeviceBusElement}s for their + * current devices and update the aggregated list of devices. Unlike {@link #scheduleBusScan()} + * this operation runs synchronously. The list of devices known to the controller will be + * updated when this method returns. + *

+ * This should be called when the list of devices of a {@link DeviceBusElement} changes. + */ + void scanDevices(); + + /** + * The list of all devices currently known to this controller. + *

+ * This is the aggregation of all {@link Device} added to all {@link DeviceBusElement}s known + * to the controller as found during the last scan scheduled via {@link #scheduleBusScan()}. + * + * @return the list of all devices on the bus managed by this controller. + */ + Collection getDevices(); +} diff --git a/src/main/java/li/cil/oc2/api/bus/DeviceBusElement.java b/src/main/java/li/cil/oc2/api/bus/DeviceBusElement.java new file mode 100644 index 00000000..c496372e --- /dev/null +++ b/src/main/java/li/cil/oc2/api/bus/DeviceBusElement.java @@ -0,0 +1,52 @@ +package li.cil.oc2.api.bus; + +import li.cil.oc2.api.device.Device; + +import javax.annotation.Nullable; +import java.util.Collection; +import java.util.Optional; + +/** + * Represents a single connection point on a device bus. + *

+ * The only way for {@link DeviceBusElement}s to be added to a bus is for a + * {@link DeviceBusController} to detect them during a scan. + *

+ * When discovered during a scan, the controller will then use the devices + * connected to this element. + *

+ * This interface is only relevant when implementing it, e.g. to provide a custom + * "cable" or other means to extend a bus. + */ +public interface DeviceBusElement extends DeviceBus { + /** + * The controller this bus element is currently registered with, if any. + * + * @return the current controller. + */ + Optional getController(); + + /** + * Sets the controller this bus element is now registered with, if any. + *

+ * This will be called by {@link DeviceBusController}s when scanning. + * + * @param controller the new controller. + */ + void setController(@Nullable final DeviceBusController controller); + + /** + * Returns the list of devices connected specifically by this element. + *

+ * This differs from {@link #getDevices()} in such that {@link #getDevices()} will + * return all devices connected to the controller, if this element is registered + * with a controller. + *

+ * This method is called by the {@link DeviceBusController} the element is registered + * with when the global list of devices is rebuilt, e.g. after a call to + * {@link DeviceBusController#scanDevices()}. + * + * @return the devices that have been added to this element. + */ + Collection getLocalDevices(); +} diff --git a/src/main/java/li/cil/oc2/api/bus/package-info.java b/src/main/java/li/cil/oc2/api/bus/package-info.java new file mode 100644 index 00000000..5313524c --- /dev/null +++ b/src/main/java/li/cil/oc2/api/bus/package-info.java @@ -0,0 +1,7 @@ +@ParametersAreNonnullByDefault +@MethodsReturnNonnullByDefault +package li.cil.oc2.api.bus; + +import mcp.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; \ No newline at end of file diff --git a/src/main/java/li/cil/oc2/api/device/AbstractDevice.java b/src/main/java/li/cil/oc2/api/device/AbstractDevice.java new file mode 100644 index 00000000..290c8bd3 --- /dev/null +++ b/src/main/java/li/cil/oc2/api/device/AbstractDevice.java @@ -0,0 +1,56 @@ +package li.cil.oc2.api.device; + +import net.minecraft.nbt.CompoundNBT; +import net.minecraftforge.common.util.INBTSerializable; + +import java.util.*; + +/** + * Convenience base class for {@link Device} implementations. + *

+ * In particular, this implements the logic needed for generating and + * storing the unique ID for this device. + */ +public abstract class AbstractDevice implements Device, INBTSerializable { + protected static final String UUID_NBT_TAG_NAME = "uuid"; + + protected final List typeNames; + protected UUID uuid; + + protected AbstractDevice() { + this(Collections.emptyList()); + } + + protected AbstractDevice(final Collection typeNames) { + this.typeNames = new ArrayList<>(typeNames); + this.uuid = java.util.UUID.randomUUID(); + } + + @Override + public UUID getUniqueId() { + return uuid; + } + + public void setUniqueId(final UUID uuid) { + this.uuid = uuid; + } + + @Override + public List getTypeNames() { + return typeNames; + } + + @Override + public CompoundNBT serializeNBT() { + final CompoundNBT nbt = new CompoundNBT(); + nbt.putUniqueId(UUID_NBT_TAG_NAME, uuid); + return nbt; + } + + @Override + public void deserializeNBT(final CompoundNBT nbt) { + if (nbt.hasUniqueId(UUID_NBT_TAG_NAME)) { + uuid = nbt.getUniqueId(UUID_NBT_TAG_NAME); + } + } +} diff --git a/src/main/java/li/cil/oc2/api/device/AbstractDeviceMethod.java b/src/main/java/li/cil/oc2/api/device/AbstractDeviceMethod.java new file mode 100644 index 00000000..4e42b79c --- /dev/null +++ b/src/main/java/li/cil/oc2/api/device/AbstractDeviceMethod.java @@ -0,0 +1,35 @@ +package li.cil.oc2.api.device; + +/** + * Convenience base class for {@link DeviceMethod} implementations. + */ +public abstract class AbstractDeviceMethod implements DeviceMethod { + protected final String name; + protected final Class returnType; + protected final DeviceMethodParameter[] parameters; + + protected AbstractDeviceMethod(final String name, final Class returnType, final DeviceMethodParameter... parameters) { + this.name = name; + this.returnType = returnType; + this.parameters = parameters; + } + + protected AbstractDeviceMethod(final String name, final DeviceMethodParameter... parameters) { + this(name, void.class, parameters); + } + + @Override + public String getName() { + return name; + } + + @Override + public Class getReturnType() { + return returnType; + } + + @Override + public DeviceMethodParameter[] getParameters() { + return parameters; + } +} diff --git a/src/main/java/li/cil/oc2/api/device/CompoundDevice.java b/src/main/java/li/cil/oc2/api/device/CompoundDevice.java new file mode 100644 index 00000000..f82c1557 --- /dev/null +++ b/src/main/java/li/cil/oc2/api/device/CompoundDevice.java @@ -0,0 +1,95 @@ +package li.cil.oc2.api.device; + +import net.minecraft.nbt.CompoundNBT; +import net.minecraft.nbt.INBT; +import net.minecraftforge.common.util.INBTSerializable; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * A utility device type that allows grouping multiple {@link Device} instances. + *

+ * Serialization of contained devices requires the added devices' unique id to + * have been restored prior to calling {@link #deserializeNBT(CompoundNBT)}. + */ +public class CompoundDevice extends AbstractDevice { + private final ArrayList devices; + + public CompoundDevice(final Collection devices) { + this.devices = new ArrayList<>(devices); + } + + public CompoundDevice(final Device... devices) { + this(Arrays.asList(devices)); + } + + public CompoundDevice() { + this(Collections.emptyList()); + } + + /** + * The list of devices grouped in this device. + *

+ * Use this in case you need to inspect the current list of devices, add new + * devices or remove existing devices. + */ + public List getDevices() { + return devices; + } + + @Override + public List getTypeNames() { + return devices.stream() + .map(Device::getTypeNames) + .flatMap(Collection::stream) + .collect(Collectors.toList()); + } + + @Override + public List getMethods() { + return devices.stream() + .map(Device::getMethods) + .flatMap(Collection::stream) + .collect(Collectors.toList()); + } + + @SuppressWarnings("rawtypes") + @Override + public CompoundNBT serializeNBT() { + final CompoundNBT nbt = super.serializeNBT(); + + final CompoundNBT devicesNbt = new CompoundNBT(); + for (final Device device : devices) { + if (device instanceof INBTSerializable) { + final INBTSerializable serializable = (INBTSerializable) device; + final String uuid = device.getUniqueId().toString(); + final INBT deviceNbt = serializable.serializeNBT(); + devicesNbt.put(uuid, deviceNbt); + } + } + nbt.put("devices", devicesNbt); + + return nbt; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + @Override + public void deserializeNBT(final CompoundNBT nbt) { + super.deserializeNBT(nbt); + + final CompoundNBT devicesNbt = nbt.getCompound("devices"); + for (final Device device : devices) { + final String uuid = device.getUniqueId().toString(); + if (!devicesNbt.contains(uuid)) { + continue; + } + + if (device instanceof INBTSerializable) { + final INBTSerializable serializable = (INBTSerializable) device; + final INBT deviceNbt = devicesNbt.get(uuid); + serializable.deserializeNBT(deviceNbt); + } + } + } +} diff --git a/src/main/java/li/cil/oc2/api/device/Device.java b/src/main/java/li/cil/oc2/api/device/Device.java new file mode 100644 index 00000000..1e3d4d30 --- /dev/null +++ b/src/main/java/li/cil/oc2/api/device/Device.java @@ -0,0 +1,43 @@ +package li.cil.oc2.api.device; + +import li.cil.oc2.api.bus.DeviceBus; +import li.cil.oc2.api.device.object.ObjectDevice; + +import java.util.List; +import java.util.UUID; + +/** + * Defines a device that may be added to a {@link DeviceBus}. + *

+ * The easiest and hence recommended way of implementing this interface is to use + * the {@link ObjectDevice} class. + * + * @see ObjectDevice + */ +public interface Device { + /** + * An id unique to this device. + *

+ * This id must persist over save/load to prevent code in a running VM losing + * track of the device. + */ + UUID getUniqueId(); + + /** + * A list of device type names for this device. + *

+ * Devices may be identified by multiple type names. Although every atomic + * implementation will usually only have one, when compounding such modular + * devices into a {@link CompoundDevice} all the underlying type names can + * thus be retained. + *

+ * In a more general sense, these can be considered tags the device can be + * referenced by inside a VM. + */ + List getTypeNames(); + + /** + * The list of methods implemented by this device. + */ + List getMethods(); +} diff --git a/src/main/java/li/cil/oc2/api/device/DeviceMethod.java b/src/main/java/li/cil/oc2/api/device/DeviceMethod.java new file mode 100644 index 00000000..9f8ecf50 --- /dev/null +++ b/src/main/java/li/cil/oc2/api/device/DeviceMethod.java @@ -0,0 +1,76 @@ +package li.cil.oc2.api.device; + +import li.cil.oc2.api.bus.DeviceBusController; +import li.cil.oc2.api.device.object.ObjectDevice; + +import javax.annotation.Nullable; +import java.util.Optional; + +/** + * Represents a single method that can be exposed by a {@link Device}. + *

+ * The easiest and hence recommended way of implementing this interface is to use + * the {@link ObjectDevice} class. + * + * @see ObjectDevice + */ +public interface DeviceMethod { + /** + * The name of the method. + *

+ * When invoked through a {@link DeviceBusController} this is what the method + * will be referenced by, so the name should be unlikely to be duplicated in + * another device to avoid ambiguity when devices are combined, e.g. in a + * {@link CompoundDevice}. + */ + String getName(); + + /** + * The type of the values returned by this method. + */ + Class getReturnType(); + + /** + * The list of parameters this method accepts. + */ + DeviceMethodParameter[] getParameters(); + + /** + * Called to run this method. + *

+ * Implementations should expect the passed {@code parameters} to match the + * declared parameters returned by {@link #getParameters()}. If the parameters + * do not match, and exception should be raised. + *

+ * Important: methods are expected to not irrevocably corrupt internal + * state, even when they throw an exception. As such, implementations should + * perform internal error handling to prevent state corruption and only throw + * exceptions to communicate that an error happened during the invocation. + * + * @param parameters the parameters for the method. + * @return the return value, or {@code null} if none. + * @throws Throwable if the parameters did not match or something inside the + * method caused an exception. The caller is responsible for + * catching these and passing them on appropriately. + */ + @Nullable + Object invoke(Object... parameters) throws Throwable; + + /** + * An optional description of the method. + *

+ * May be used inside VMs to generate documentation. + */ + default Optional getDescription() { + return Optional.empty(); + } + + /** + * An optional description of the return value of this method. + *

+ * May be used inside VMs to generate documentation. + */ + default Optional getReturnValueDescription() { + return Optional.empty(); + } +} diff --git a/src/main/java/li/cil/oc2/api/device/DeviceMethodParameter.java b/src/main/java/li/cil/oc2/api/device/DeviceMethodParameter.java new file mode 100644 index 00000000..f5765a35 --- /dev/null +++ b/src/main/java/li/cil/oc2/api/device/DeviceMethodParameter.java @@ -0,0 +1,40 @@ +package li.cil.oc2.api.device; + +import li.cil.oc2.api.bus.DeviceBusController; + +import java.util.Optional; + +/** + * Describes a single parameter of a {@link DeviceMethod}. + */ +public interface DeviceMethodParameter { + /** + * The type of this parameter. + *

+ * This is used by {@link DeviceBusController}s to convert parameters from a lower + * level representation before passing it to {@link DeviceMethod#invoke(Object...)}. + * As such, the types used must be kept simple. As a rule of thumb, only primitives + * and POJOs should be used. + * + * @return the type of the parameter. + */ + Class getType(); + + /** + * An optional name of the parameter. + *

+ * May be used inside VMs to generate documentation. + */ + default Optional getName() { + return Optional.empty(); + } + + /** + * An optional description of the parameter. + *

+ * May be used inside VMs to generate documentation. + */ + default Optional getDescription() { + return Optional.empty(); + } +} diff --git a/src/main/java/li/cil/oc2/api/device/object/Callback.java b/src/main/java/li/cil/oc2/api/device/object/Callback.java new file mode 100644 index 00000000..bfa0b4bd --- /dev/null +++ b/src/main/java/li/cil/oc2/api/device/object/Callback.java @@ -0,0 +1,31 @@ +package li.cil.oc2.api.device.object; + +import li.cil.oc2.api.device.DeviceMethod; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Utility annotation to allow generating lists of {@link DeviceMethod}s using + * {@link Callbacks#collectMethods(Object)}. + *

+ * Intended to be used in classes instances of which are used in combination with + * {@link ObjectDevice} and subclasses of {@link ObjectDevice}. + *

+ * For + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Callback { + /** + * Option VM visible documentation of this method. + */ + String description() default ""; + + /** + * Optional VM visible documentation of the values returned by this method. + */ + String returnValueDescription() default ""; +} diff --git a/src/main/java/li/cil/oc2/api/device/object/Callbacks.java b/src/main/java/li/cil/oc2/api/device/object/Callbacks.java new file mode 100644 index 00000000..b6f5741b --- /dev/null +++ b/src/main/java/li/cil/oc2/api/device/object/Callbacks.java @@ -0,0 +1,61 @@ +package li.cil.oc2.api.device.object; + +import li.cil.oc2.api.device.DeviceMethod; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Provides automated extraction of {@link DeviceMethod}s from instances of + * class with methods annotated with the {@link Callback} annotation. + *

+ * Prefer using {@link ObjectDevice} instead of using this class directly. + */ +public final class Callbacks { + private static final Logger LOGGER = LogManager.getLogger(); + private static final HashMap, List> METHOD_BY_TYPE = new HashMap<>(); + + /** + * Collects all methods annotated with {@link Callback} in the specified object + * and generated {@link DeviceMethod}s for each one. + *

+ * The generated {@link DeviceMethod} will be bound to the passed object and + * can be called without needing to pass the object. + *

+ * For example: + *

+     * class Example {
+     *   @Callback
+     *   public void f(String a) { }
+     * }
+     *
+     * List methods = Callbacks.collectMethods(new Example());
+     * methods.get(0).invoke("argument");
+     * 
+ * + * @param methodContainer an instance of a class with annotated methods. + * @return the list of methods extracted from the specified object. + */ + public static List collectMethods(final Object methodContainer) { + final List reflectedMethods = METHOD_BY_TYPE.computeIfAbsent(methodContainer.getClass(), c -> Arrays.stream(c.getMethods()) + .filter(m -> m.isAnnotationPresent(Callback.class)) + .collect(Collectors.toList())); + + final List methods = new ArrayList<>(); + for (final Method method : reflectedMethods) { + try { + methods.add(new ObjectDeviceMethod(methodContainer, method)); + } catch (final IllegalAccessException e) { + LOGGER.error("Failed accessing method [{}].", method); + } + } + + return methods; + } +} diff --git a/src/main/java/li/cil/oc2/api/device/object/ObjectDevice.java b/src/main/java/li/cil/oc2/api/device/object/ObjectDevice.java new file mode 100644 index 00000000..ef810a4e --- /dev/null +++ b/src/main/java/li/cil/oc2/api/device/object/ObjectDevice.java @@ -0,0 +1,57 @@ +package li.cil.oc2.api.device.object; + +import li.cil.oc2.api.device.AbstractDevice; +import li.cil.oc2.api.device.Device; +import li.cil.oc2.api.device.DeviceMethod; + +import java.util.Collections; +import java.util.List; + +/** + * A reflection based implementation of {@link Device} using the {@link Callback} + * annotation to discover {@link DeviceMethod} in a target object via + * {@link Callbacks#collectMethods(Object)}. + *

+ * This class was designed targeting two possible use-cases: + *

    + *
  • Wrapping some separate object containing the annotated method.
  • + *
  • Subclassing this type and implementing annotated methods in the subclass.
  • + *
+ * The two sets of constructors are designed for these use cases, with the constructors + * targeting the workflow using an external object being {@code public}, the ones targeting + * subclassing being {@code protected}. + */ +public class ObjectDevice extends AbstractDevice { + private final List methods; + + public ObjectDevice(final Object object, final List typeNames) { + super(typeNames); + this.methods = Callbacks.collectMethods(object); + } + + public ObjectDevice(final Object object, final String typeName) { + this(object, Collections.singletonList(typeName)); + } + + public ObjectDevice(final Object object) { + this(object, Collections.emptyList()); + } + + protected ObjectDevice(final List typeNames) { + super(typeNames); + this.methods = Callbacks.collectMethods(this); + } + + protected ObjectDevice(final String typeName) { + this(Collections.singletonList(typeName)); + } + + protected ObjectDevice() { + this(Collections.emptyList()); + } + + @Override + public List getMethods() { + return methods; + } +} diff --git a/src/main/java/li/cil/oc2/api/device/object/ObjectDeviceMethod.java b/src/main/java/li/cil/oc2/api/device/object/ObjectDeviceMethod.java new file mode 100644 index 00000000..e6a8a389 --- /dev/null +++ b/src/main/java/li/cil/oc2/api/device/object/ObjectDeviceMethod.java @@ -0,0 +1,90 @@ +package li.cil.oc2.api.device.object; + +import li.cil.oc2.api.device.AbstractDeviceMethod; +import li.cil.oc2.api.device.DeviceMethodParameter; +import org.apache.logging.log4j.util.Strings; + +import javax.annotation.Nullable; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Optional; + +/** + * Not intended to be instantiated directly, see {@link Callbacks}. + * + * @see Callbacks + */ +public final class ObjectDeviceMethod extends AbstractDeviceMethod { + private final MethodHandle handle; + private final String description; + private final String returnValueDescription; + + public ObjectDeviceMethod(final Object target, final Method method) throws IllegalAccessException { + super(method.getName(), method.getReturnType(), getParameters(method)); + + final Callback annotation = method.getAnnotation(Callback.class); + if (annotation == null) { + throw new IllegalArgumentException("Method without Callback annotation."); + } + + this.handle = MethodHandles.lookup().unreflect(method).bindTo(target); + this.description = Strings.isNotBlank(annotation.description()) ? annotation.description() : null; + this.returnValueDescription = Strings.isNotBlank(annotation.returnValueDescription()) ? annotation.returnValueDescription() : null; + } + + @Nullable + @Override + public Object invoke(final Object... parameters) throws Throwable { + return handle.invokeWithArguments(parameters); + } + + @Override + public Optional getDescription() { + return Optional.ofNullable(description); + } + + @Override + public Optional getReturnValueDescription() { + return Optional.ofNullable(returnValueDescription); + } + + private static DeviceMethodParameter[] getParameters(final Method method) { + return Arrays.stream(method.getParameters()) + .map(ReflectionParameter::new) + .toArray(DeviceMethodParameter[]::new); + } + + private static final class ReflectionParameter implements DeviceMethodParameter { + private final Class type; + @Nullable private final String name; + @Nullable private final String description; + + public ReflectionParameter(final java.lang.reflect.Parameter parameter) { + this.type = parameter.getType(); + + final li.cil.oc2.api.device.object.Parameter annotation = parameter.getAnnotation(li.cil.oc2.api.device.object.Parameter.class); + final boolean hasName = annotation != null && Strings.isNotBlank(annotation.value()); + final boolean hasDescription = annotation != null && Strings.isNotBlank(annotation.description()); + + this.name = hasName ? annotation.value() : null; + this.description = hasDescription ? annotation.description() : null; + } + + @Override + public Class getType() { + return type; + } + + @Override + public Optional getName() { + return Optional.ofNullable(name); + } + + @Override + public Optional getDescription() { + return Optional.ofNullable(description); + } + } +} diff --git a/src/main/java/li/cil/oc2/api/device/object/Parameter.java b/src/main/java/li/cil/oc2/api/device/object/Parameter.java new file mode 100644 index 00000000..05299017 --- /dev/null +++ b/src/main/java/li/cil/oc2/api/device/object/Parameter.java @@ -0,0 +1,30 @@ +package li.cil.oc2.api.device.object; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * This annotation may be used to propagate the name of parameters and to + * provide VM visible documentation of this parameter for methods annotated + * with the {@link Callback} annotation. + *

+ * Java strips parameter names in non-debug builds, so the actual method + * parameter names cannot be retrieved directly. + *

+ * If this is not present, parameters will appear unnamed to the VM. + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Parameter { + /** + * The name of the parameter as seen by the VM. + */ + String value(); + + /** + * Optional VM visible documentation of this parameter. + */ + String description() default ""; +} diff --git a/src/main/java/li/cil/oc2/api/device/object/package-info.java b/src/main/java/li/cil/oc2/api/device/object/package-info.java new file mode 100644 index 00000000..f6655977 --- /dev/null +++ b/src/main/java/li/cil/oc2/api/device/object/package-info.java @@ -0,0 +1,7 @@ +@ParametersAreNonnullByDefault +@MethodsReturnNonnullByDefault +package li.cil.oc2.api.device.object; + +import mcp.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; \ No newline at end of file diff --git a/src/main/java/li/cil/oc2/api/device/package-info.java b/src/main/java/li/cil/oc2/api/device/package-info.java new file mode 100644 index 00000000..4f366c3a --- /dev/null +++ b/src/main/java/li/cil/oc2/api/device/package-info.java @@ -0,0 +1,7 @@ +@ParametersAreNonnullByDefault +@MethodsReturnNonnullByDefault +package li.cil.oc2.api.device; + +import mcp.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; \ No newline at end of file diff --git a/src/main/java/li/cil/oc2/common/CommonSetup.java b/src/main/java/li/cil/oc2/common/CommonSetup.java index d20c35d4..29ae9bb3 100644 --- a/src/main/java/li/cil/oc2/common/CommonSetup.java +++ b/src/main/java/li/cil/oc2/common/CommonSetup.java @@ -1,5 +1,6 @@ package li.cil.oc2.common; +import li.cil.oc2.common.capabilities.DeviceBusElementCapability; import li.cil.oc2.common.network.Network; import li.cil.oc2.common.vm.Allocator; import li.cil.oc2.serialization.BlobStorage; @@ -10,6 +11,8 @@ import net.minecraftforge.fml.event.server.FMLServerStoppedEvent; public final class CommonSetup { public static void run(final FMLCommonSetupEvent event) { + DeviceBusElementCapability.register(); + Network.setup(); MinecraftForge.EVENT_BUS.addListener(CommonSetup::handleServerAboutToStart); diff --git a/src/main/java/li/cil/oc2/common/capabilities/Capabilities.java b/src/main/java/li/cil/oc2/common/capabilities/Capabilities.java new file mode 100644 index 00000000..a5cdc394 --- /dev/null +++ b/src/main/java/li/cil/oc2/common/capabilities/Capabilities.java @@ -0,0 +1,12 @@ +package li.cil.oc2.common.capabilities; + +import li.cil.oc2.api.bus.DeviceBus; +import li.cil.oc2.api.bus.DeviceBusElement; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.capabilities.CapabilityInject; + +public final class Capabilities { + @CapabilityInject(DeviceBus.class) @SuppressWarnings("FieldMayBeFinal") + public static Capability DEVICE_BUS_ELEMENT_CAPABILITY = null; + +} diff --git a/src/main/java/li/cil/oc2/common/capabilities/DeviceBusElementCapability.java b/src/main/java/li/cil/oc2/common/capabilities/DeviceBusElementCapability.java new file mode 100644 index 00000000..4a774e52 --- /dev/null +++ b/src/main/java/li/cil/oc2/common/capabilities/DeviceBusElementCapability.java @@ -0,0 +1,11 @@ +package li.cil.oc2.common.capabilities; + +import li.cil.oc2.api.bus.DeviceBusElement; +import li.cil.oc2.common.vm.DeviceBusElementImpl; +import net.minecraftforge.common.capabilities.CapabilityManager; + +public final class DeviceBusElementCapability { + public static void register() { + CapabilityManager.INSTANCE.register(DeviceBusElement.class, new NullStorage<>(), DeviceBusElementImpl::new); + } +} diff --git a/src/main/java/li/cil/oc2/common/capabilities/NullStorage.java b/src/main/java/li/cil/oc2/common/capabilities/NullStorage.java new file mode 100644 index 00000000..a32802fb --- /dev/null +++ b/src/main/java/li/cil/oc2/common/capabilities/NullStorage.java @@ -0,0 +1,19 @@ +package li.cil.oc2.common.capabilities; + +import net.minecraft.nbt.INBT; +import net.minecraft.util.Direction; +import net.minecraftforge.common.capabilities.Capability; + +import javax.annotation.Nullable; + +public class NullStorage implements Capability.IStorage { + @Nullable + @Override + public INBT writeNBT(final Capability capability, final T instance, final Direction side) { + return null; + } + + @Override + public void readNBT(final Capability capability, final T instance, final Direction side, final INBT nbt) { + } +} diff --git a/src/test/java/li/cil/oc2/bus/DeviceBusTests.java b/src/test/java/li/cil/oc2/bus/DeviceBusTests.java new file mode 100644 index 00000000..98b81c86 --- /dev/null +++ b/src/test/java/li/cil/oc2/bus/DeviceBusTests.java @@ -0,0 +1,79 @@ +package li.cil.oc2.bus; + +import li.cil.oc2.api.bus.DeviceBusElement; +import li.cil.oc2.api.device.Device; +import li.cil.oc2.common.capabilities.Capabilities; +import li.cil.oc2.common.vm.DeviceBusControllerImpl; +import li.cil.sedna.api.device.serial.SerialDevice; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.util.LazyOptional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.util.Collections; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class DeviceBusTests { + @Mock + private static Capability busElementCapability; + + private World world; + private SerialDevice serialDevice; + private DeviceBusControllerImpl controller; + + @BeforeAll + public static void setup() { + Capabilities.DEVICE_BUS_ELEMENT_CAPABILITY = busElementCapability; + } + + @BeforeEach + public void setupEach() { + world = mock(World.class); + serialDevice = mock(SerialDevice.class); + controller = new DeviceBusControllerImpl(serialDevice); + } + + @Test + public void scanPendingWhenTileEntityNotLoaded() { + Assertions.assertEquals(DeviceBusControllerImpl.State.SCAN_PENDING, + controller.scan(world, new BlockPos(0, 0, 0))); + } + + @Test + public void scanCompletesWhenNoNeighbors() { + when(world.chunkExists(anyInt(), anyInt())).thenReturn(true); + Assertions.assertEquals(DeviceBusControllerImpl.State.READY, + controller.scan(world, new BlockPos(0, 0, 0))); + } + + @Test + public void scanSuccessfulWithLocalElement() { + when(world.chunkExists(anyInt(), anyInt())).thenReturn(true); + + final TileEntity tileEntity = mock(TileEntity.class); + when(world.getTileEntity(eq(new BlockPos(0, 0, 0)))).thenReturn(tileEntity); + + final DeviceBusElement busElement = mock(DeviceBusElement.class); + when(tileEntity.getCapability(eq(busElementCapability), any())).thenReturn(LazyOptional.of(() -> busElement)); + + final Device device = mock(Device.class); + when(busElement.getDevices()).thenReturn(Collections.singletonList(device)); + + when(device.getUniqueId()).thenReturn(UUID.randomUUID()); + + Assertions.assertEquals(DeviceBusControllerImpl.State.READY, + controller.scan(world, new BlockPos(0, 0, 0))); + + verify(busElement).setController(controller); + Assertions.assertTrue(controller.getDevices().contains(device)); + } +} diff --git a/src/test/java/li/cil/oc2/vm/AbstractTestMethod.java b/src/test/java/li/cil/oc2/vm/AbstractTestMethod.java new file mode 100644 index 00000000..df13b7bf --- /dev/null +++ b/src/test/java/li/cil/oc2/vm/AbstractTestMethod.java @@ -0,0 +1,54 @@ +package li.cil.oc2.vm; + +import li.cil.oc2.api.device.DeviceMethod; +import li.cil.oc2.api.device.DeviceMethodParameter; + +import java.util.Optional; + +abstract class AbstractTestMethod implements DeviceMethod { + private final Class returnType; + private final DeviceMethodParameter[] parameters; + + protected AbstractTestMethod(final Class returnType, final Class... parameterTypes) { + this.returnType = returnType; + parameters = new DeviceMethodParameter[parameterTypes.length]; + for (int i = 0; i < parameterTypes.length; i++) { + parameters[i] = new TestParameter("arg" + i, parameterTypes[i]); + } + } + + @Override + public String getName() { + return getClass().getSimpleName(); + } + + @Override + public Class getReturnType() { + return returnType; + } + + @Override + public DeviceMethodParameter[] getParameters() { + return parameters; + } + + private static final class TestParameter implements DeviceMethodParameter { + private final String name; + private final Class type; + + public TestParameter(final String name, final Class type) { + this.name = name; + this.type = type; + } + + @Override + public Optional getName() { + return Optional.ofNullable(name); + } + + @Override + public Class getType() { + return type; + } + } +} diff --git a/src/test/java/li/cil/oc2/vm/ObjectDeviceProtocolTests.java b/src/test/java/li/cil/oc2/vm/ObjectDeviceProtocolTests.java new file mode 100644 index 00000000..75df9146 --- /dev/null +++ b/src/test/java/li/cil/oc2/vm/ObjectDeviceProtocolTests.java @@ -0,0 +1,256 @@ +package li.cil.oc2.vm; + +import com.google.gson.*; +import it.unimi.dsi.fastutil.bytes.ByteArrayFIFOQueue; +import li.cil.oc2.api.bus.DeviceBusElement; +import li.cil.oc2.api.device.AbstractDevice; +import li.cil.oc2.api.device.Device; +import li.cil.oc2.api.device.DeviceMethod; +import li.cil.oc2.api.device.object.Callback; +import li.cil.oc2.api.device.object.ObjectDevice; +import li.cil.oc2.api.device.object.Parameter; +import li.cil.oc2.common.capabilities.Capabilities; +import li.cil.oc2.common.vm.DeviceBusControllerImpl; +import li.cil.oc2.common.vm.DeviceBusElementImpl; +import li.cil.sedna.api.device.serial.SerialDevice; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.util.LazyOptional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import javax.annotation.Nullable; +import java.io.ByteArrayOutputStream; +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ObjectDeviceProtocolTests { + private static final BlockPos CONTROLLER_POS = new BlockPos(0, 0, 0); + + @Mock private Capability busElementCapability; + private World world; + private TestSerialDevice serialDevice; + private DeviceBusControllerImpl controller; + private DeviceBusElement busElement; + + @BeforeEach + public void setupEach() { + MockitoAnnotations.initMocks(this); + Capabilities.DEVICE_BUS_ELEMENT_CAPABILITY = busElementCapability; + + serialDevice = new TestSerialDevice(); + controller = new DeviceBusControllerImpl(serialDevice); + busElement = new DeviceBusElementImpl(); + + world = mock(World.class); + when(world.chunkExists(anyInt(), anyInt())).thenReturn(true); + + final TileEntity tileEntity = mock(TileEntity.class); + when(world.getTileEntity(any())).thenReturn(tileEntity); + + when(tileEntity.getCapability(eq(busElementCapability), any())).thenReturn(LazyOptional.of(() -> busElement)); + } + + @Test + public void resetAndReadDescriptor() { + final VoidIntMethod method = new VoidIntMethod(); + + busElement.addDevice(new TestDevice(method)); + controller.scan(world, CONTROLLER_POS); + + final JsonObject request = new JsonObject(); + request.addProperty("type", "status"); + serialDevice.putAsVM(request.toString()); + controller.step(0); // process message + + final String message = serialDevice.readMessageAsVM(); + Assertions.assertNotNull(message); + + final JsonObject json = new JsonParser().parse(message).getAsJsonObject(); + + final JsonArray devices = json.getAsJsonArray("data"); + Assertions.assertEquals(1, devices.size()); + + final JsonObject device = devices.get(0).getAsJsonObject(); + + final JsonArray methods = device.getAsJsonArray("methods"); + Assertions.assertEquals(1, methods.size()); + } + + @Test + public void simpleMethod() { + final VoidIntMethod method = new VoidIntMethod(); + final TestDevice device = new TestDevice(method); + + busElement.addDevice(device); + controller.scan(world, CONTROLLER_POS); + + invokeMethod(device, method.getName(), 0xdeadbeef); + + Assertions.assertEquals(0xdeadbeef, method.passedValue); + } + + @Test + public void returningMethod() { + final IntLongMethod method = new IntLongMethod(); + final TestDevice device = new TestDevice(method); + + busElement.addDevice(device); + controller.scan(world, CONTROLLER_POS); + + final JsonElement result = invokeMethod(device, method.getName(), 0xdeadbeefcafebabeL); + Assertions.assertNotNull(result); + Assertions.assertTrue(result.isJsonPrimitive()); + Assertions.assertEquals(0xcafebabe, result.getAsInt()); + } + + @Test + public void annotatedObject() { + final SimpleObject object = new SimpleObject(); + final ObjectDevice device = new ObjectDevice(object); + + busElement.addDevice(device); + controller.scan(world, CONTROLLER_POS); + + Assertions.assertEquals(42 + 23, invokeMethod(device, "add", 42, 23).getAsInt()); + } + + private JsonElement invokeMethod(final Device device, final String name, final Object... parameters) { + final JsonObject request = new JsonObject(); + request.addProperty("type", "invoke"); + final JsonObject methodInvocation = new JsonObject(); + methodInvocation.addProperty("deviceId", device.getUniqueId().toString()); + methodInvocation.addProperty("name", name); + final JsonArray parametersJson = new JsonArray(); + methodInvocation.add("parameters", parametersJson); + for (final Object parameter : parameters) { + parametersJson.add(new Gson().toJson(parameter)); + } + request.add("data", methodInvocation); + serialDevice.putAsVM(request.toString()); + + controller.step(0); + + final String result = serialDevice.readMessageAsVM(); + Assertions.assertNotNull(result); + final JsonObject resultJson = new JsonParser().parse(result).getAsJsonObject(); + Assertions.assertEquals("result", resultJson.get("type").getAsString()); + return resultJson.get("data"); + } + + private static final class VoidIntMethod extends AbstractTestMethod { + public int passedValue; + + VoidIntMethod() { + super(void.class, int.class); + } + + @Override + public Object invoke(final Object... parameters) { + passedValue = (int) parameters[0]; + return 0; + } + } + + private static final class IntLongMethod extends AbstractTestMethod { + public long passedValue; + + IntLongMethod() { + super(int.class, long.class); + } + + @Override + public Object invoke(final Object... parameters) { + passedValue = (long) parameters[0]; + return (int) passedValue; + } + } + + public static final class SimpleObject { + @Callback + public int add(@Parameter("a") final int a, + @Parameter("b") final int b) { + return a + b; + } + + @Callback + public int div(@Parameter("a") final long a, + @Parameter("b") final long b) { + return (int) (a / b); + } + } + + private static final class TestSerialDevice implements SerialDevice { + private final ByteArrayFIFOQueue transmit = new ByteArrayFIFOQueue(); + private final ByteArrayFIFOQueue receive = new ByteArrayFIFOQueue(); + + public void putAsVM(final String data) { + final byte[] bytes = data.getBytes(); + for (int i = 0; i < bytes.length; i++) { + transmit.enqueue(bytes[i]); + } + transmit.enqueue((byte) 0); + } + + @Nullable + public String readMessageAsVM() { + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + while (!receive.isEmpty()) { + final byte value = receive.dequeueByte(); + + if (value == 0) { + if (bytes.size() == 0) { + continue; + } else { + break; + } + } + + bytes.write(value); + } + + if (bytes.size() > 0) { + return new String(bytes.toByteArray()); + } else { + return null; + } + } + + @Override + public int read() { + return transmit.isEmpty() ? -1 : transmit.dequeueByte(); + } + + @Override + public boolean canPutByte() { + return true; + } + + @Override + public void putByte(final byte value) { + receive.enqueue(value); + } + } + + private static final class TestDevice extends AbstractDevice { + private final DeviceMethod method; + + public TestDevice(final DeviceMethod method) { + this.method = method; + } + + @Override + public List getMethods() { + return Collections.singletonList(method); + } + } +} diff --git a/src/test/java/li/cil/oc2/vm/package-info.java b/src/test/java/li/cil/oc2/vm/package-info.java new file mode 100644 index 00000000..2e7ece1f --- /dev/null +++ b/src/test/java/li/cil/oc2/vm/package-info.java @@ -0,0 +1,7 @@ +@ParametersAreNonnullByDefault +@MethodsReturnNonnullByDefault +package li.cil.oc2.vm; + +import mcp.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; \ No newline at end of file