diff --git a/src/main/java/li/cil/oc2/common/vm/DeviceBusControllerImpl.java b/src/main/java/li/cil/oc2/common/vm/DeviceBusControllerImpl.java new file mode 100644 index 00000000..08c4d0eb --- /dev/null +++ b/src/main/java/li/cil/oc2/common/vm/DeviceBusControllerImpl.java @@ -0,0 +1,485 @@ +package li.cil.oc2.common.vm; + +import com.google.gson.*; +import li.cil.ceres.api.Serialized; +import li.cil.oc2.api.bus.DeviceBusController; +import li.cil.oc2.api.bus.DeviceBusElement; +import li.cil.oc2.api.device.Device; +import li.cil.oc2.api.device.DeviceMethod; +import li.cil.oc2.api.device.DeviceMethodParameter; +import li.cil.oc2.common.capabilities.Capabilities; +import li.cil.sedna.api.device.Steppable; +import li.cil.sedna.api.device.serial.SerialDevice; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.Direction; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.ChunkPos; +import net.minecraft.world.World; +import net.minecraftforge.common.util.LazyOptional; + +import javax.annotation.Nullable; +import java.io.ByteArrayInputStream; +import java.io.InputStreamReader; +import java.lang.reflect.Type; +import java.nio.ByteBuffer; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class DeviceBusControllerImpl implements DeviceBusController, Steppable { + public enum State { + SCAN_PENDING, + TOO_COMPLEX, + READY, + } + + private static final int MAX_BUS_ELEMENT_COUNT = 128; + private static final int DEFAULT_MAX_MESSAGE_SIZE = 32 * 1024; + private static final byte[] MESSAGE_DELIMITER = "\0".getBytes(); + + // Device -> VM + private static final String MESSAGE_TYPE_STATUS = "status"; + private static final String MESSAGE_TYPE_RESULT = "result"; + private static final String MESSAGE_TYPE_ERROR = "error"; + + private static final String ERROR_MESSAGE_TOO_LARGE = "message too large"; + private static final String ERROR_UNKNOWN_MESSAGE_TYPE = "unknown message type"; + private static final String ERROR_UNKNOWN_DEVICE = "unknown device"; + private static final String ERROR_UNKNOWN_METHOD = "unknown method"; + private static final String ERROR_INVALID_PARAMETER_SIGNATURE = "invalid parameter signature"; + + // VM -> Device + private static final String MESSAGE_TYPE_INVOKE_METHOD = "invoke"; + + private final Set elements = new HashSet<>(); + private final ConcurrentHashMap devices = new ConcurrentHashMap<>(); + + private final SerialDevice serialDevice; + private final Gson gson; + private int scanDelay; + + @Serialized private final ByteBuffer transmitBuffer; // for data written to device by VM + @Serialized private ByteBuffer receiveBuffer; // for data written by device to VM + + public DeviceBusControllerImpl(final SerialDevice serialDevice) { + this(serialDevice, DEFAULT_MAX_MESSAGE_SIZE); + } + + public DeviceBusControllerImpl(final SerialDevice serialDevice, final int maxMessageSize) { + this.serialDevice = serialDevice; + this.transmitBuffer = ByteBuffer.allocate(maxMessageSize); + this.gson = new GsonBuilder() + .serializeNulls() + .registerTypeAdapter(MethodInvocation.class, new MethodInvocationDeserializer()) + .registerTypeAdapter(Message.class, new MessageDeserializer()) + .registerTypeAdapter(Device.class, new DeviceSerializer()) + .registerTypeAdapter(DeviceMethod.class, new DeviceMethodSerializer()) + .create(); + } + + @Override + public void scheduleBusScan() { + for (final DeviceBusElement element : elements) { + assert element.getController().isPresent() && element.getController().get() == this; + element.setController(null); + } + + elements.clear(); + devices.clear(); + + scanDelay = 0; // scan as soon as possible + } + + @Override + public void scanDevices() { + devices.clear(); + for (final DeviceBusElement element : elements) { + for (final Device device : element.getLocalDevices()) { + final UUID uuid = device.getUniqueId(); + devices.putIfAbsent(uuid, device); + } + } + } + + @Override + public Collection getDevices() { + return devices.values(); + } + + public State scan(final World world, final BlockPos start) { + if (scanDelay < 0) { + return State.READY; + } + + if (scanDelay-- > 0) { + return State.SCAN_PENDING; + } + + assert scanDelay == -1; + + final Stack queue = new Stack<>(); + final HashSet seenEdges = new HashSet<>(); // to avoid duplicate edge scans + final HashSet busPositions = new HashSet<>(); // to track number of seen blocks for limit + + final Direction[] faces = Direction.values(); + for (final Direction face : faces) { + final ScanEdge edgeIn = new ScanEdge(start, face); + queue.add(edgeIn); + seenEdges.add(edgeIn); + } + + while (!queue.isEmpty()) { + final ScanEdge edge = queue.pop(); + assert seenEdges.contains(edge); + + final ChunkPos chunkPos = new ChunkPos(edge.position); + if (!world.chunkExists(chunkPos.x, chunkPos.z)) { + // If we have an unloaded chunk neighbor we cannot know whether our neighbor in that + // chunk would cause a scan once it is loaded, so we'll just retry every so often. + scanDelay = 20; + elements.clear(); + return State.SCAN_PENDING; + } + + final TileEntity tileEntity = world.getTileEntity(edge.position); + if (tileEntity == null) { + for (final Direction face : faces) { + seenEdges.add(new ScanEdge(edge.position, face)); + } + + continue; + } + + final LazyOptional capability = tileEntity.getCapability(Capabilities.DEVICE_BUS_ELEMENT_CAPABILITY, edge.face); + if (capability.isPresent()) { + if (busPositions.add(edge.position) && busPositions.size() > MAX_BUS_ELEMENT_COUNT) { + elements.clear(); + return State.TOO_COMPLEX; + } + + final DeviceBusElement element = capability.orElseThrow(AssertionError::new); + elements.add(element); + + for (final Direction face : faces) { + final LazyOptional otherCapability = tileEntity.getCapability(Capabilities.DEVICE_BUS_ELEMENT_CAPABILITY, face); + otherCapability.ifPresent(otherElement -> { + final boolean isConnectedToIncomingEdge = otherElement == element; + if (!isConnectedToIncomingEdge) { + return; + } + + final ScanEdge edgeIn = new ScanEdge(edge.position, face); + seenEdges.add(edgeIn); + + final ScanEdge edgeOut = new ScanEdge(edge.position, face.getOpposite()); + if (seenEdges.add(edgeOut)) { + queue.add(edgeOut); + } + }); + } + } + } + + for (final DeviceBusElement element : elements) { + assert !element.getController().isPresent(); + element.setController(this); + } + + scanDevices(); + + return State.READY; + } + + public void step(final int cycles) { + readFromDevice(); + writeToDevice(); + } + + private void readFromDevice() { + // Only ever allow one pending message to avoid giving the VM the + // power of uncontrollably inflating memory usage. Basically any + // method of limiting the write queue size would work, but this is + // the most simple and easy to maintain one I could think of. + int value; + while (receiveBuffer == null && (value = serialDevice.read()) >= 0) { + if (value == 0) { + if (transmitBuffer.limit() > 0) { + transmitBuffer.flip(); + if (transmitBuffer.hasRemaining()) { + final byte[] message = new byte[transmitBuffer.remaining()]; + transmitBuffer.get(message); + processMessage(message); + } + } else { + writeError(ERROR_MESSAGE_TOO_LARGE); + } + transmitBuffer.clear(); + } else if (transmitBuffer.hasRemaining()) { + transmitBuffer.put((byte) value); + } else { + transmitBuffer.clear(); + transmitBuffer.limit(0); // marks message too large + } + } + } + + private void writeToDevice() { + if (receiveBuffer == null) { + return; + } + + while (receiveBuffer.hasRemaining() && serialDevice.canPutByte()) { + serialDevice.putByte(receiveBuffer.get()); + } + + serialDevice.flush(); + + if (!receiveBuffer.hasRemaining()) { + receiveBuffer = null; + } + } + + private void processMessage(final byte[] messageData) { + if (new String(messageData).trim().isEmpty()) { + return; + } + + final InputStreamReader stream = new InputStreamReader(new ByteArrayInputStream(messageData)); + try { + final Message message = gson.fromJson(stream, Message.class); + switch (message.type) { + case MESSAGE_TYPE_STATUS: { + writeStatus(); + break; + } + case MESSAGE_TYPE_INVOKE_METHOD: { + assert message.data != null : "MethodInvocation deserializer produced null data."; + processMethodInvocation((MethodInvocation) message.data); + break; + } + default: { + writeError(ERROR_UNKNOWN_MESSAGE_TYPE); + break; + } + } + } catch (final Throwable e) { + writeError(e.getMessage()); + } + } + + private void processMethodInvocation(final MethodInvocation methodInvocation) { + final Device device = devices.get(methodInvocation.deviceId); + if (device == null) { + writeError(ERROR_UNKNOWN_DEVICE); + return; + } + + // Yes, we could hashmap this lookup, but the expectation is that we'll generally + // have relatively few methods per object where the overhead of hashing would not + // be worth it. So we just do a linear search, which also gives us maximal + // flexibility for free (devices may dynamically change their methods). + String error = ERROR_UNKNOWN_METHOD; + outer: + for (final DeviceMethod method : device.getMethods()) { + if (!Objects.equals(method.getName(), methodInvocation.methodName)) { + continue; + } + + final DeviceMethodParameter[] parametersSpec = method.getParameters(); + if (methodInvocation.parameters.size() != parametersSpec.length) { + error = ERROR_INVALID_PARAMETER_SIGNATURE; + continue; // There may be an overload with matching parameter count. + } + + final Object[] parameters = new Object[parametersSpec.length]; + for (int i = 0; i < parametersSpec.length; i++) { + final DeviceMethodParameter parameterInfo = parametersSpec[i]; + try { + parameters[i] = gson.fromJson(methodInvocation.parameters.get(i), parameterInfo.getType()); + } catch (final Throwable e) { + error = ERROR_INVALID_PARAMETER_SIGNATURE; + continue outer; // There may be an overload with matching parameter types. + } + } + + try { + final Object result = method.invoke(parameters); + writeMessage(MESSAGE_TYPE_RESULT, result); + } catch (final Throwable e) { + writeError(e.getMessage()); + } + + return; + } + + writeError(error); + } + + private void writeStatus() { + writeMessage(MESSAGE_TYPE_STATUS, devices.values().toArray(new Device[0])); + } + + private void writeError(final String message) { + writeMessage(MESSAGE_TYPE_ERROR, message); + } + + private void writeMessage(final String type, @Nullable final Object data) { + if (receiveBuffer != null) throw new IllegalStateException(); + final String json = gson.toJson(new Message(type, data)); + final byte[] bytes = json.getBytes(); + receiveBuffer = ByteBuffer.allocate(bytes.length + MESSAGE_DELIMITER.length * 2); + + // In case we went through a reset and the VM was in the middle of reading + // a message we inject a delimiter up front to cause the truncated message + // to be discarded. + receiveBuffer.put(MESSAGE_DELIMITER); + + receiveBuffer.put(bytes); + + // We follow up each message with a delimiter, too, so the VM knows when the + // message has been completed. This will lead to two delimiters between most + // messages. The VM is expected to ignore such "empty" messages. + receiveBuffer.put(MESSAGE_DELIMITER); + + receiveBuffer.flip(); + } + + private static final class ScanEdge { + public final BlockPos position; + public final Direction face; + + public ScanEdge(final BlockPos position, final Direction face) { + this.position = position; + this.face = face; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final ScanEdge scanEdge = (ScanEdge) o; + return position.equals(scanEdge.position) && + face == scanEdge.face; + } + + @Override + public int hashCode() { + return Objects.hash(position, face); + } + } + + private static final class Message { + public final String type; + @Nullable public final Object data; + + public Message(final String type, @Nullable final Object data) { + this.type = type; + this.data = data; + } + } + + private static final class MethodInvocation { + public final UUID deviceId; + public final String methodName; + public final JsonArray parameters; + + public MethodInvocation(final UUID deviceId, final String methodName, final JsonArray parameters) { + this.deviceId = deviceId; + this.methodName = methodName; + this.parameters = parameters; + } + } + + private static final class MessageDeserializer implements JsonDeserializer { + @Override + public Message deserialize(final JsonElement json, final Type typeOfT, final JsonDeserializationContext context) throws JsonParseException { + final JsonObject jsonObject = json.getAsJsonObject(); + final String messageType = jsonObject.get("type").getAsString(); + final Object messageData; + switch (messageType) { + case MESSAGE_TYPE_STATUS: { + messageData = null; + break; + } + case MESSAGE_TYPE_INVOKE_METHOD: { + messageData = context.deserialize(jsonObject.getAsJsonObject("data"), MethodInvocation.class); + break; + } + default: { + throw new JsonParseException(ERROR_UNKNOWN_MESSAGE_TYPE); + } + } + + return new Message(messageType, messageData); + } + } + + private static final class MethodInvocationDeserializer implements JsonDeserializer { + @Override + public MethodInvocation deserialize(final JsonElement json, final Type typeOfT, final JsonDeserializationContext context) throws JsonParseException { + final JsonObject jsonObject = json.getAsJsonObject(); + final UUID deviceId = context.deserialize(jsonObject.get("deviceId"), UUID.class); + final String methodName = jsonObject.get("name").getAsString(); + final JsonArray parameters = jsonObject.getAsJsonArray("parameters"); + return new MethodInvocation(deviceId, methodName, parameters != null ? parameters : new JsonArray()); + } + } + + private static final class DeviceSerializer implements JsonSerializer { + @Override + public JsonElement serialize(final Device src, final Type typeOfSrc, final JsonSerializationContext context) { + if (src == null) { + return JsonNull.INSTANCE; + } + + final JsonObject deviceJson = new JsonObject(); + deviceJson.add("deviceId", context.serialize(src.getUniqueId())); + deviceJson.add("typeNames", context.serialize(src.getTypeNames())); + + final JsonArray methodsJson = new JsonArray(); + deviceJson.add("methods", methodsJson); + for (final DeviceMethod method : src.getMethods()) { + methodsJson.add(context.serialize(method, DeviceMethod.class)); + } + + return deviceJson; + } + } + + private static final class DeviceMethodSerializer implements JsonSerializer { + @Override + public JsonElement serialize(final DeviceMethod method, final Type typeOfMethod, final JsonSerializationContext context) { + if (method == null) { + return JsonNull.INSTANCE; + } + + final JsonObject methodJson = new JsonObject(); + methodJson.addProperty("name", method.getName()); + methodJson.addProperty("returnType", method.getReturnType().getSimpleName()); + + method.getDescription().ifPresent(s -> methodJson.addProperty("description", s)); + method.getReturnValueDescription().ifPresent(s -> methodJson.addProperty("returnValueDescription", s)); + + final JsonArray parametersJson = new JsonArray(); + methodJson.add("parameters", parametersJson); + + final DeviceMethodParameter[] parameters = method.getParameters(); + if (parameters != null) { + for (final DeviceMethodParameter parameter : parameters) { + final JsonObject parameterJson = new JsonObject(); + + parameter.getName().ifPresent(s -> parameterJson.addProperty("name", s)); + parameter.getDescription().ifPresent(s -> parameterJson.addProperty("description", s)); + + final Class type = parameter.getType(); + if (type != null) { + parameterJson.addProperty("type", type.getSimpleName()); + } + + parametersJson.add(parameterJson); + } + } + + return methodJson; + } + } +} diff --git a/src/main/java/li/cil/oc2/common/vm/DeviceBusElementImpl.java b/src/main/java/li/cil/oc2/common/vm/DeviceBusElementImpl.java new file mode 100644 index 00000000..d2d305b4 --- /dev/null +++ b/src/main/java/li/cil/oc2/common/vm/DeviceBusElementImpl.java @@ -0,0 +1,62 @@ +package li.cil.oc2.common.vm; + +import li.cil.oc2.api.bus.DeviceBusController; +import li.cil.oc2.api.bus.DeviceBusElement; +import li.cil.oc2.api.device.Device; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public class DeviceBusElementImpl implements DeviceBusElement { + private final List devices = new ArrayList<>(); + @Nullable private DeviceBusController controller; + + @Override + public Optional getController() { + return Optional.ofNullable(controller); + } + + public void setController(@Nullable final DeviceBusController controller) { + this.controller = controller; + } + + @Override + public Collection getLocalDevices() { + return devices; + } + + @Override + public void addDevice(final Device device) { + devices.add(device); + if (controller != null) { + controller.scanDevices(); + } + } + + @Override + public void removeDevice(final Device device) { + devices.remove(device); + if (controller != null) { + controller.scanDevices(); + } + } + + @Override + public Collection getDevices() { + if (controller != null) { + return controller.getDevices(); + } else { + return getLocalDevices(); + } + } + + @Override + public void scheduleScan() { + if (controller != null) { + controller.scheduleBusScan(); + } + } +}