1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2025-11-03 15:13:07 +00:00

Compare commits

...

11 Commits

Author SHA1 Message Date
Jonathan Coates
3493159a05 Bump CC:T to 1.109.7 2024-03-10 19:10:09 +00:00
Jonathan Coates
eead67e314 Fix a couple of warnings 2024-03-10 12:04:40 +00:00
Jonathan Coates
3b8813cf8f Slightly more detailed negative allocation logging
Hopefully will help debug #1739. Maybe.
2024-03-10 11:26:36 +00:00
Jonathan Coates
a9191a4d4e Don't cache the client monitor
When rendering non-origin monitors, we would fetch the origin monitor,
read its client state, and then cache that on the current monitor to
avoid repeated lookups.

However, if the origin monitor is unloaded/removed on the client, and
then loaded agin, this cache will be not be invalidated, causing us to
render both the old and new monitor!

I think the correct thing to do here is cache the origin monitor. This
allows us to check when the origin monitor has been removed, and
invalidate the cache if needed.

However, I'm wary of any other edge cases here, so for now we do
something much simpler, and remove the cache entirely. This does mean
that monitors now need to perform extra block entity lookups, but the
performance cost doesn't appear to be too bad.

Fixes #1741
2024-03-10 10:57:56 +00:00
Jonathan Coates
451a2593ce Move WiredNode default methods to the impl 2024-03-10 10:00:52 +00:00
Jonathan Coates
d38b1da974 Don't propagate redstone when blink/label changes
Historically, computers tracked whether any world-visible state
(on/off/blinking, label and redstone outputs) had changed with a single
"has changed" flag. While this is simple to use, this has the curious
side effect of that term.setCursorBlink() or os.setComputerLabel() would
cause a block update!

This isn't really a problem in practice - it just means slightly more
block updates. However, the redstone propagation sometimes causes the
computer to invalidate/recheck peripherals, which masks several other
(yet unfixed) bugs.
2024-03-06 18:59:38 +00:00
Jonathan Coates
6e374579a4 Standardise on term colour parsing
- colors.toBlit now performs bounds checks on the passed value,
   preventing weird behaviour like color.toBlit(2 ^ 16) returning "10".

 - The window API now uses colors.toBlit (or rather a copy of it) for
   parsing colours, allowing doing silly things like
   term.setTextColour(colours.blue + 5).

 - Add some top-level documentation to the term API to explain some of
   the basics.

Closes #1736
2024-03-06 10:18:40 +00:00
Jonathan Coates
4daa2a2b6a Reschedule block entities when chunks are loaded
Minecraft sometimes keeps chunks in-memory, but not actively loaded. If
we schedule a block entity to be ticked and that chunk is is then
transitioned to this partially-loaded state, then the block entity is
never actually ticked.

This is most visible with monitors. When a monitor's contents changes,
if the monitor is not already marked as changed, we set it as changed
and schedule a tick (see ServerMonitor). However, if the tick is
dropped, we don't clear the changed flag, meaning subsequent changes
don't requeue the monitor to be ticked, and so the monitor is never
updated.

We fix this by maintaining a list of block entities whose tick was
dropped. If these block entities (or rather their owning chunk) is ever
re-loaded, then we reschedule them to be ticked.

An alternative approach here would be to add the scheduled tick directly
to the LevelChunk. However, getting hold of the LevelChunk for unloaded
blocks is quiet nasty, so I think best avoided.

Fixes #1146. Fixes #1560 - I believe the second one is a duplicate, and
I noticed too late :D.
2024-02-26 19:25:38 +00:00
Jonathan Coates
84b6edab82 More efficient removal of wired nodes from networks
When we remove a wired node from a network, we need to find connected
components in the rest of the graph. Typically, this requires a
traversal of the whole graph, taking O(|V| + |E|) time.

If we remove a lot of nodes at once (such as when unloading chunks),
this ends up being quadratic in the number of nodes. In some test
networks, this can take anywhere from a few seconds, to hanging the game
indefinitely.

This attempts to reduce the cases where this can happen, with a couple
of optimisations:

 - Instead of constructing a new hash set of reachable nodes (requiring
   multiple allocations and hash lookups), we store reachability as a
   temporary field on the WiredNode.

 - We abort our traversal of the graph if we can prove the graph remains
   connected after removing the node.

There's definitely future work to be done here in optimising large wired
networks, but this is a good first step.
2024-02-24 15:02:34 +00:00
Jonathan Coates
31aaf46d09 Deprecate WiredNetwork
We don't actually need this to be in the public API.
2024-02-24 14:55:22 +00:00
Jonathan Coates
2d11b51c62 Clean up the wired network tests
- Replace usages of WiredNetwork.connect/disconnect/remove with the
   WiredNode equivalents.

 - Convert "testLarge" into a proper JMH benchmark.

 - Don't put a peripheral on every node in the benchmarks. This isn't
   entirely representative, and means the peripheral juggling code ends
   up dominating the benchmark time.
2024-02-24 14:52:44 +00:00
48 changed files with 991 additions and 429 deletions

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@
/projects/*/logs
/projects/fabric/fabricloader.log
/projects/*/build
/projects/*/src/test/generated_tests/
/buildSrc/build
/out
/buildSrc/out

View File

@@ -10,7 +10,7 @@ kotlin.jvm.target.validation.mode=error
# Mod properties
isUnstable=false
modVersion=1.109.6
modVersion=1.109.7
# Minecraft properties: We want to configure this here so we can read it in settings.gradle
mcVersion=1.20.1

View File

@@ -51,6 +51,7 @@ sodium = "mc1.20-0.4.10"
hamcrest = "2.2"
jqwik = "1.8.2"
junit = "5.10.1"
jmh = "1.37"
# Build tools
cctJavadoc = "1.8.2"
@@ -127,6 +128,8 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.re
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" }
junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" }
slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" }
jmh = { module = "org.openjdk.jmh:jmh-core", version.ref = "jmh" }
jmh-processor = { module = "org.openjdk.jmh:jmh-generator-annprocess", version.ref = "jmh" }
# LWJGL
lwjgl-bom = { module = "org.lwjgl:lwjgl-bom", version.ref = "lwjgl" }

View File

@@ -5,6 +5,7 @@
package dan200.computercraft.api.network.wired;
import dan200.computercraft.api.peripheral.IPeripheral;
import org.jetbrains.annotations.ApiStatus;
import java.util.Map;
@@ -22,6 +23,7 @@ import java.util.Map;
*
* @see WiredNode#getNetwork()
*/
@ApiStatus.NonExtendable
public interface WiredNetwork {
/**
* Create a connection between two nodes.
@@ -35,7 +37,9 @@ public interface WiredNetwork {
* @throws IllegalArgumentException If {@code left} and {@code right} are equal.
* @see WiredNode#connectTo(WiredNode)
* @see WiredNetwork#connect(WiredNode, WiredNode)
* @deprecated Use {@link WiredNode#connectTo(WiredNode)}
*/
@Deprecated
boolean connect(WiredNode left, WiredNode right);
/**
@@ -50,7 +54,9 @@ public interface WiredNetwork {
* @throws IllegalArgumentException If {@code left} and {@code right} are equal.
* @see WiredNode#disconnectFrom(WiredNode)
* @see WiredNetwork#connect(WiredNode, WiredNode)
* @deprecated Use {@link WiredNode#disconnectFrom(WiredNode)}
*/
@Deprecated
boolean disconnect(WiredNode left, WiredNode right);
/**
@@ -64,7 +70,9 @@ public interface WiredNetwork {
* only element.
* @throws IllegalArgumentException If the node is not in the network.
* @see WiredNode#remove()
* @deprecated Use {@link WiredNode#remove()}
*/
@Deprecated
boolean remove(WiredNode node);
/**
@@ -77,6 +85,8 @@ public interface WiredNetwork {
* @param peripherals The new peripherals for this node.
* @throws IllegalArgumentException If the node is not in the network.
* @see WiredNode#updatePeripherals(Map)
* @deprecated Use {@link WiredNode#updatePeripherals(Map)}
*/
@Deprecated
void updatePeripherals(WiredNode node, Map<String, IPeripheral> peripherals);
}

View File

@@ -6,6 +6,7 @@ package dan200.computercraft.api.network.wired;
import dan200.computercraft.api.network.PacketNetwork;
import dan200.computercraft.api.peripheral.IPeripheral;
import org.jetbrains.annotations.ApiStatus;
import java.util.Map;
@@ -22,6 +23,7 @@ import java.util.Map;
* Wired nodes also provide several convenience methods for interacting with a wired network. These should only ever
* be used on the main server thread.
*/
@ApiStatus.NonExtendable
public interface WiredNode extends PacketNetwork {
/**
* The associated element for this network node.
@@ -37,7 +39,9 @@ public interface WiredNode extends PacketNetwork {
* This should only be used on the server thread.
*
* @return This node's network.
* @deprecated Use the connect/disconnect/remove methods on {@link WiredNode}.
*/
@Deprecated
WiredNetwork getNetwork();
/**
@@ -47,12 +51,9 @@ public interface WiredNode extends PacketNetwork {
*
* @param node The other node to connect to.
* @return {@code true} if a connection was created or {@code false} if the connection already exists.
* @see WiredNetwork#connect(WiredNode, WiredNode)
* @see WiredNode#disconnectFrom(WiredNode)
*/
default boolean connectTo(WiredNode node) {
return getNetwork().connect(this, node);
}
boolean connectTo(WiredNode node);
/**
* Destroy a connection between this node and another.
@@ -61,13 +62,9 @@ public interface WiredNode extends PacketNetwork {
*
* @param node The other node to disconnect from.
* @return {@code true} if a connection was destroyed or {@code false} if no connection exists.
* @throws IllegalArgumentException If {@code node} is not on the same network.
* @see WiredNetwork#disconnect(WiredNode, WiredNode)
* @see WiredNode#connectTo(WiredNode)
*/
default boolean disconnectFrom(WiredNode node) {
return getNetwork().disconnect(this, node);
}
boolean disconnectFrom(WiredNode node);
/**
* Sever all connections this node has, removing it from this network.
@@ -78,11 +75,8 @@ public interface WiredNode extends PacketNetwork {
* @return Whether this node was removed from the network. One cannot remove a node from a network where it is the
* only element.
* @throws IllegalArgumentException If the node is not in the network.
* @see WiredNetwork#remove(WiredNode)
*/
default boolean remove() {
return getNetwork().remove(this);
}
boolean remove();
/**
* Mark this node's peripherals as having changed.
@@ -91,9 +85,6 @@ public interface WiredNode extends PacketNetwork {
* that your network element owns.
*
* @param peripherals The new peripherals for this node.
* @see WiredNetwork#updatePeripherals(WiredNode, Map)
*/
default void updatePeripherals(Map<String, IPeripheral> peripherals) {
getNetwork().updatePeripherals(this, peripherals);
}
void updatePeripherals(Map<String, IPeripheral> peripherals);
}

View File

@@ -46,6 +46,9 @@ dependencies {
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.bundles.testRuntime)
testImplementation(libs.jmh)
testAnnotationProcessor(libs.jmh.processor)
testModCompileOnly(libs.mixin)
testModImplementation(testFixtures(project(":core")))
testModImplementation(testFixtures(project(":common")))

View File

@@ -58,9 +58,9 @@ public class MonitorBlockEntityRenderer implements BlockEntityRenderer<MonitorBl
@Override
public void render(MonitorBlockEntity monitor, float partialTicks, PoseStack transform, MultiBufferSource bufferSource, int lightmapCoord, int overlayLight) {
// Render from the origin monitor
var originTerminal = monitor.getClientMonitor();
var originTerminal = monitor.getOriginClientMonitor();
if (originTerminal == null) return;
var origin = originTerminal.getOrigin();
var renderState = originTerminal.getRenderState(MonitorRenderState::new);
var monitorPos = monitor.getBlockPos();

View File

@@ -4,45 +4,66 @@
package dan200.computercraft.impl.network.wired;
import org.jetbrains.annotations.Contract;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
/**
* Verifies certain elements of a network are "well formed".
* Verifies certain elements of a network are well-formed.
* <p>
* This adds substantial overhead to network modification, and so should only be enabled
* in a development environment.
* This adds substantial overhead to network modification, and so is only enabled when assertions are enabled.
*/
public final class InvariantChecker {
final class InvariantChecker {
private static final Logger LOG = LoggerFactory.getLogger(InvariantChecker.class);
private static final boolean ENABLED = false;
private InvariantChecker() {
}
public static void checkNode(WiredNodeImpl node) {
if (!ENABLED) return;
static void checkNode(WiredNodeImpl node) {
assert checkNodeImpl(node) : "Node invariants failed. See logs.";
}
var network = node.network;
if (network == null) {
LOG.error("Node's network is null", new Exception());
return;
private static boolean checkNodeImpl(WiredNodeImpl node) {
var okay = true;
if (node.currentSet != null) {
okay = false;
LOG.error("{}: currentSet was not cleared.", node);
}
if (network.nodes == null || !network.nodes.contains(node)) {
LOG.error("Node's network does not contain node", new Exception());
var network = makeNullable(node.network);
if (network == null) {
okay = false;
LOG.error("{}: Node's network is null.", node);
} else if (makeNullable(network.nodes) == null || !network.nodes.contains(node)) {
okay = false;
LOG.error("{}: Node's network does not contain node.", node);
}
for (var neighbour : node.neighbours) {
if (!neighbour.neighbours.contains(node)) {
LOG.error("Neighbour is missing node", new Exception());
okay = false;
LOG.error("{}: Neighbour {}'s neighbour set does not contain origianl node.", node, neighbour);
}
}
return okay;
}
public static void checkNetwork(WiredNetworkImpl network) {
if (!ENABLED) return;
static void checkNetwork(WiredNetworkImpl network) {
assert checkNetworkImpl(network) : "Network invariants failed. See logs.";
}
for (var node : network.nodes) checkNode(node);
private static boolean checkNetworkImpl(WiredNetworkImpl network) {
var okay = true;
for (var node : network.nodes) okay &= checkNodeImpl(node);
return okay;
}
@Contract("")
private static <T> @Nullable T makeNullable(T object) {
return object;
}
}

View File

@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: 2024 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.impl.network.wired;
import dan200.computercraft.api.network.wired.WiredNode;
import javax.annotation.Nullable;
import java.util.Objects;
/**
* A disjoint-set/union-find of {@link WiredNodeImpl}s.
* <p>
* Rather than actually maintaining a list of included nodes, wired nodes store {@linkplain WiredNodeImpl#currentSet the
* set they're part of}. This means that we can only have one disjoint-set at once, but that is not a problem in
* practice.
*
* @see WiredNodeImpl#currentSet
* @see WiredNetworkImpl#remove(WiredNode)
* @see <a href="https://en.wikipedia.org/wiki/Disjoint-set_data_structure">Disjoint-set data structure</a>
*/
class NodeSet {
private NodeSet parent = this;
private int size = 1;
private @Nullable WiredNetworkImpl network;
private boolean isRoot() {
return parent == this;
}
/**
* Resolve this union, finding the root {@link NodeSet}.
*
* @return The root union.
*/
NodeSet find() {
var self = this;
while (!self.isRoot()) self = self.parent = self.parent.parent;
return self;
}
/**
* Get the size of this node set.
*
* @return The size of the set.
*/
int size() {
return find().size;
}
/**
* Add a node to this {@link NodeSet}.
*
* @param node The node to add to the set.
*/
void addNode(WiredNodeImpl node) {
if (!isRoot()) throw new IllegalStateException("Cannot grow a non-root set.");
if (node.currentSet != null) throw new IllegalArgumentException("Node is already in a set.");
node.currentSet = this;
size++;
}
/**
* Merge two nodes sets together.
*
* @param left The first union.
* @param right The second union.
* @return The union which was subsumed.
*/
public static NodeSet merge(NodeSet left, NodeSet right) {
if (!left.isRoot() || !right.isRoot()) throw new IllegalArgumentException("Cannot union a non-root set.");
if (left == right) throw new IllegalArgumentException("Cannot merge a node into itself.");
return left.size >= right.size ? mergeInto(left, right) : mergeInto(right, left);
}
private static NodeSet mergeInto(NodeSet root, NodeSet child) {
assert root.size > child.size;
child.parent = root;
root.size += child.size;
return child;
}
void setNetwork(WiredNetworkImpl network) {
if (!isRoot()) throw new IllegalStateException("Set is not the root.");
if (this.network != null) throw new IllegalStateException("Set already has a network.");
this.network = network;
}
/**
* Get the associated network.
*
* @return The associated network.
*/
WiredNetworkImpl network() {
return Objects.requireNonNull(find().network);
}
}

View File

@@ -8,6 +8,7 @@ import dan200.computercraft.api.network.Packet;
import dan200.computercraft.api.network.wired.WiredNetwork;
import dan200.computercraft.api.network.wired.WiredNode;
import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.core.util.Nullability;
import java.util.*;
import java.util.concurrent.locks.ReadWriteLock;
@@ -187,10 +188,76 @@ final class WiredNetworkImpl implements WiredNetwork {
return true;
}
var reachable = reachableNodes(neighbours.iterator().next());
assert neighbours.size() >= 2 : "Must have more than one neighbour.";
/*
Otherwise we need to find all sets of connected nodes within the graph, and split them off into their own
networks.
With our current graph representation[^1], this requires a traversal of the graph, taking O(|V| + |E))
time, which can get quite expensive for large graphs. We try to avoid this traversal where possible, by
optimising for the case where the graph remains fully connected after removing this node, for instance,
removing "A" here:
A---B B
| | => |
C---D C---D
We observe that these sorts of loops tend to be local, and so try to identify them as quickly as possible.
To do this, we do a standard breadth-first traversal of the graph starting at the neighbours of the
removed node, building sets of connected nodes.
If, at any point, all nodes visited so far are connected to each other, then we know all remaining nodes
will also be connected. This allows us to abort our traversal of the graph, and just remove the node (much
like we do in the single neighbour case above).
Otherwise, we then just create a new network for each disjoint set of connected nodes.
{^1]:
There are efficient (near-logarithmic) algorithms for this (e.g. https://arxiv.org/pdf/1609.05867.pdf),
but they are significantly more complex to implement.
*/
// Create a new set of nodes for each neighbour, and add them to our queue of nodes to visit.
List<WiredNodeImpl> queue = new ArrayList<>();
Set<NodeSet> nodeSets = new HashSet<>(neighbours.size());
for (var neighbour : neighbours) {
nodeSets.add(neighbour.currentSet = new NodeSet());
queue.add(neighbour);
}
// Perform a breadth-first search of the graph, starting from the neighbours.
graphSearch:
for (var i = 0; i < queue.size(); i++) {
var enqueuedNode = queue.get(i);
for (var neighbour : enqueuedNode.neighbours) {
var nodeSet = Nullability.assertNonNull(enqueuedNode.currentSet).find();
// The neighbour has no set and so has not been visited yet. Add it to the current set and enqueue
// it to be visited.
if (neighbour.currentSet == null) {
nodeSet.addNode(neighbour);
queue.add(neighbour);
continue;
}
// Otherwise, take the union of the two nodes' sets if needed. If we've only got a single node set
// left, then we know the whole graph is network is connected (even if not all nodes have been
// visited) and so can abort early.
var neighbourSet = neighbour.currentSet.find();
if (nodeSet != neighbourSet) {
var removed = nodeSets.remove(NodeSet.merge(nodeSet, neighbourSet));
assert removed : "Merged set should have been ";
if (nodeSets.size() == 1) break graphSearch;
}
}
}
// If we have a single subset, then all nodes are reachable - just clear the set and exit.
if (nodeSets.size() == 1) {
assert nodeSets.iterator().next().size() == queue.size();
for (var neighbour : queue) neighbour.currentSet = null;
// If all nodes are reachable then exit.
if (reachable.size() == nodes.size()) {
// Broadcast our simple peripheral changes
removeSingleNode(wired, wiredNetwork);
InvariantChecker.checkNode(wired);
@@ -198,43 +265,46 @@ final class WiredNetworkImpl implements WiredNetwork {
return true;
}
// A split may cause 2..neighbours.size() separate networks, so we
// iterate through our neighbour list, generating child networks.
neighbours.removeAll(reachable);
var maximals = new ArrayList<WiredNetworkImpl>(neighbours.size() + 1);
maximals.add(wiredNetwork);
maximals.add(new WiredNetworkImpl(reachable));
assert queue.size() == nodes.size() : "Expected queue to contain all nodes.";
while (!neighbours.isEmpty()) {
reachable = reachableNodes(neighbours.iterator().next());
neighbours.removeAll(reachable);
maximals.add(new WiredNetworkImpl(reachable));
// Otherwise we need to create our new networks.
var networks = new ArrayList<WiredNetworkImpl>(1 + nodeSets.size());
// Add the network we've created for the removed node.
networks.add(wiredNetwork);
// And then create a new network for each disjoint subset.
for (var set : nodeSets) {
var network = new WiredNetworkImpl(new HashSet<>(set.size()));
set.setNetwork(network);
networks.add(network);
}
for (var network : maximals) network.lock.writeLock().lock();
for (var network : networks) network.lock.writeLock().lock();
try {
// We special case the original node: detaching all peripherals when needed.
wired.network = wiredNetwork;
wired.peripherals = Map.of();
wired.neighbours.clear();
// Ensure every network is finalised
for (var network : maximals) {
for (var child : network.nodes) {
child.network = network;
network.peripherals.putAll(child.peripherals);
}
// Add all nodes to their appropriate network.
for (var child : queue) {
var network = Nullability.assertNonNull(child.currentSet).network();
child.currentSet = null;
child.network = network;
network.nodes.add(child);
network.peripherals.putAll(child.peripherals);
}
for (var network : maximals) InvariantChecker.checkNetwork(network);
for (var network : networks) InvariantChecker.checkNetwork(network);
InvariantChecker.checkNode(wired);
// Then broadcast network changes once all nodes are finalised
for (var network : maximals) {
for (var network : networks) {
WiredNetworkChangeImpl.changeOf(peripherals, network.peripherals).broadcast(network.nodes);
}
} finally {
for (var network : maximals) network.lock.writeLock().unlock();
for (var network : networks) network.lock.writeLock().unlock();
}
nodes.clear();
@@ -373,22 +443,4 @@ final class WiredNetworkImpl implements WiredNetwork {
throw new IllegalArgumentException("Unknown implementation of IWiredNode: " + node);
}
}
private static Set<WiredNodeImpl> reachableNodes(WiredNodeImpl start) {
Queue<WiredNodeImpl> enqueued = new ArrayDeque<>();
var reachable = new HashSet<WiredNodeImpl>();
reachable.add(start);
enqueued.add(start);
WiredNodeImpl node;
while ((node = enqueued.poll()) != null) {
for (var neighbour : node.neighbours) {
// Otherwise attempt to enqueue this neighbour as well.
if (reachable.add(neighbour)) enqueued.add(neighbour);
}
}
return reachable;
}
}

View File

@@ -27,11 +27,39 @@ public final class WiredNodeImpl implements WiredNode {
final HashSet<WiredNodeImpl> neighbours = new HashSet<>();
volatile WiredNetworkImpl network;
/**
* A temporary field used when checking network connectivity.
*
* @see WiredNetworkImpl#remove(WiredNode)
*/
@Nullable
NodeSet currentSet;
public WiredNodeImpl(WiredElement element) {
this.element = element;
network = new WiredNetworkImpl(this);
}
@Override
public boolean connectTo(WiredNode node) {
return network.connect(this, node);
}
@Override
public boolean disconnectFrom(WiredNode node) {
return network == ((WiredNodeImpl) node).network && network.disconnect(this, node);
}
@Override
public boolean remove() {
return network.remove(this);
}
@Override
public void updatePeripherals(Map<String, IPeripheral> peripherals) {
network.updatePeripherals(this, peripherals);
}
@Override
public synchronized void addReceiver(PacketReceiver receiver) {
if (receivers == null) receivers = new HashSet<>();

View File

@@ -18,6 +18,7 @@ import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.dedicated.DedicatedServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.packs.resources.PreparableReloadListener;
import net.minecraft.world.entity.Entity;
@@ -72,10 +73,19 @@ public final class CommonHooks {
NetworkUtils.reset();
}
public static void onServerChunkUnload(LevelChunk chunk) {
if (!(chunk.getLevel() instanceof ServerLevel)) throw new IllegalArgumentException("Not a server chunk.");
TickScheduler.onChunkUnload(chunk);
}
public static void onChunkWatch(LevelChunk chunk, ServerPlayer player) {
MonitorWatcher.onWatch(chunk, player);
}
public static void onChunkTicketLevelChanged(ServerLevel level, long chunkPos, int oldLevel, int newLevel) {
TickScheduler.onChunkTicketChanged(level, chunkPos, oldLevel, newLevel);
}
public static final ResourceLocation TREASURE_DISK_LOOT = new ResourceLocation(ComputerCraftAPI.MOD_ID, "treasure_disk");
private static final Set<ResourceLocation> TREASURE_DISK_LOOT_TABLES = Set.of(

View File

@@ -113,16 +113,13 @@ public abstract class AbstractComputerBlockEntity extends BlockEntity implements
return InteractionResult.PASS;
}
public void neighborChanged(BlockPos neighbour) {
updateInputAt(neighbour);
}
protected void serverTick() {
if (getLevel().isClientSide) return;
if (computerID < 0 && !startOn) return; // Don't tick if we don't need a computer!
var computer = createServerComputer();
// Update any peripherals that have changed.
if (invalidSides != 0) {
for (var direction : DirectionUtil.FACINGS) {
if ((invalidSides & (1 << direction.ordinal())) != 0) refreshPeripheral(computer, direction);
@@ -139,16 +136,30 @@ public abstract class AbstractComputerBlockEntity extends BlockEntity implements
fresh = false;
computerID = computer.getID();
label = computer.getLabel();
on = computer.isOn();
// Update the block state if needed. We don't fire a block update intentionally,
// as this only really is needed on the client side.
// If the on state has changed, mark as as dirty.
var newOn = computer.isOn();
if (on != newOn) {
on = newOn;
setChanged();
}
// If the label has changed, mark as dirty and sync to client.
var newLabel = computer.getLabel();
if (!Objects.equals(label, newLabel)) {
label = newLabel;
BlockEntityHelpers.updateBlock(this);
}
// Update the block state if needed.
updateBlockState(computer.getState());
// TODO: This should ideally be split up into label/id/on (which should save NBT and sync to client) and
// redstone (which should update outputs)
if (computer.hasOutputChanged()) updateOutput();
var changes = computer.pollAndResetChanges();
if (changes != 0) {
for (var direction : DirectionUtil.FACINGS) {
if ((changes & (1 << remapToLocalSide(direction).ordinal())) != 0) updateRedstoneTo(direction);
}
}
}
protected abstract void updateBlockState(ComputerState newState);
@@ -198,11 +209,15 @@ public abstract class AbstractComputerBlockEntity extends BlockEntity implements
return localSide;
}
private void updateRedstoneInputs(ServerComputer computer) {
var pos = getBlockPos();
for (var dir : DirectionUtil.FACINGS) updateRedstoneInput(computer, dir, pos.relative(dir));
}
/**
* Update the redstone input on a particular side.
* <p>
* This is called <em>immediately</em> when a neighbouring block changes (see {@link #neighborChanged(BlockPos)}).
*
* @param computer The current server computer.
* @param dir The direction to update in.
* @param targetPos The position of the adjacent block, equal to {@code getBlockPos().offset(dir)}.
*/
private void updateRedstoneInput(ServerComputer computer, Direction dir, BlockPos targetPos) {
var offsetSide = dir.getOpposite();
var localDir = remapToLocalSide(dir);
@@ -211,6 +226,15 @@ public abstract class AbstractComputerBlockEntity extends BlockEntity implements
computer.setBundledRedstoneInput(localDir, BundledRedstone.getOutput(getLevel(), targetPos, offsetSide));
}
/**
* Update the peripheral on a particular side.
* <p>
* This is called from {@link #serverTick()}, after a peripheral has been marked as invalid (such as in
* {@link #neighborChanged(BlockPos)})
*
* @param computer The current server computer.
* @param dir The direction to update in.
*/
private void refreshPeripheral(ServerComputer computer, Direction dir) {
invalidSides &= ~(1 << dir.ordinal());
@@ -243,7 +267,18 @@ public abstract class AbstractComputerBlockEntity extends BlockEntity implements
}
}
private void updateInputAt(BlockPos neighbour) {
/**
* Called when a neighbour block changes.
* <p>
* This finds the side the neighbour block is on, and updates the inputs accordingly.
* <p>
* We do <strong>NOT</strong> update the peripheral immediately. Blocks and block entities are sometimes
* inconsistent at the point where an update is received, and so we instead just mark that side as dirty (see
* {@link #invalidSides}) and refresh it {@linkplain #serverTick() next tick}.
*
* @param neighbour The position of the neighbour block.
*/
public void neighborChanged(BlockPos neighbour) {
var computer = getServerComputer();
if (computer == null) return;
@@ -258,22 +293,28 @@ public abstract class AbstractComputerBlockEntity extends BlockEntity implements
// If the position is not any adjacent one, update all inputs. This is pretty terrible, but some redstone mods
// handle this incorrectly.
updateRedstoneInputs(computer);
for (var dir : DirectionUtil.FACINGS) updateRedstoneInput(computer, dir, getBlockPos().relative(dir));
invalidSides = (1 << 6) - 1; // Mark all peripherals as dirty.
}
/**
* Update the block's state and propagate redstone output.
* Update outputs in a specific direction.
*
* @param direction The direction to propagate outputs in.
*/
public void updateOutput() {
BlockEntityHelpers.updateBlock(this);
for (var dir : DirectionUtil.FACINGS) RedstoneUtil.propagateRedstoneOutput(getLevel(), getBlockPos(), dir);
protected void updateRedstoneTo(Direction direction) {
RedstoneUtil.propagateRedstoneOutput(getLevel(), getBlockPos(), direction);
var computer = getServerComputer();
if (computer != null) updateRedstoneInputs(computer);
if (computer != null) updateRedstoneInput(computer, direction, getBlockPos().relative(direction));
}
protected abstract ServerComputer createComputer(int id);
/**
* Update all redstone outputs.
*/
public void updateRedstone() {
for (var dir : DirectionUtil.FACINGS) updateRedstoneTo(dir);
}
@Override
public final int getComputerID() {
@@ -331,6 +372,8 @@ public abstract class AbstractComputerBlockEntity extends BlockEntity implements
return computer;
}
protected abstract ServerComputer createComputer(int id);
@Nullable
public ServerComputer getServerComputer() {
return getLevel().isClientSide || getLevel().getServer() == null ? null : ServerContext.get(getLevel().getServer()).registry().get(instanceID);

View File

@@ -51,7 +51,7 @@ public class ComputerBlockEntity extends AbstractComputerBlockEntity {
protected void updateBlockState(ComputerState newState) {
var existing = getBlockState();
if (existing.getValue(ComputerBlock.STATE) != newState) {
getLevel().setBlock(getBlockPos(), existing.setValue(ComputerBlock.STATE, newState), 3);
getLevel().setBlock(getBlockPos(), existing.setValue(ComputerBlock.STATE, newState), ComputerBlock.UPDATE_CLIENTS);
}
}

View File

@@ -42,7 +42,6 @@ public class ServerComputer implements InputHandler, ComputerEnvironment {
private final NetworkedTerminal terminal;
private final AtomicBoolean terminalChanged = new AtomicBoolean(false);
private boolean changedLastFrame;
private int ticksSincePing;
public ServerComputer(
@@ -96,10 +95,7 @@ public class ServerComputer implements InputHandler, ComputerEnvironment {
public void tickServer() {
ticksSincePing++;
computer.tick();
changedLastFrame = computer.pollAndResetChanged();
if (terminalChanged.getAndSet(false)) onTerminalChanged();
}
@@ -119,8 +115,8 @@ public class ServerComputer implements InputHandler, ComputerEnvironment {
return ticksSincePing > 100;
}
public boolean hasOutputChanged() {
return changedLastFrame;
public int pollAndResetChanges() {
return computer.pollAndResetChanges();
}
public int register() {
@@ -167,7 +163,7 @@ public class ServerComputer implements InputHandler, ComputerEnvironment {
}
public ComputerState getState() {
if (!isOn()) return ComputerState.OFF;
if (!computer.isOn()) return ComputerState.OFF;
return computer.isBlinking() ? ComputerState.BLINKING : ComputerState.ON;
}

View File

@@ -127,7 +127,7 @@ public class CableBlock extends Block implements SimpleWaterloggedBlock, EntityB
item = new ItemStack(ModRegistry.Items.CABLE.get());
}
world.setBlock(pos, correctConnections(world, pos, newState), 3);
world.setBlockAndUpdate(pos, correctConnections(world, pos, newState));
cable.modemChanged();
cable.connectionsChanged();

View File

@@ -264,8 +264,8 @@ public class CableBlockEntity extends BlockEntity {
if (CableBlock.canConnectIn(state, facing)) {
// If we can connect to it then do so
this.node.connectTo(node);
} else if (this.node.getNetwork() == node.getNetwork()) {
// Otherwise if we're on the same network then attempt to void it.
} else {
// Otherwise break the connection.
this.node.disconnectFrom(node);
}
}

View File

@@ -30,7 +30,7 @@ public abstract class CableBlockItem extends BlockItem {
// TODO: Check entity collision.
if (!state.canSurvive(world, pos)) return false;
world.setBlock(pos, state, 3);
world.setBlockAndUpdate(pos, state);
var soundType = state.getBlock().getSoundType(state);
world.playSound(null, pos, soundType.getPlaceSound(), SoundSource.BLOCKS, (soundType.getVolume() + 1.0F) / 2.0F, soundType.getPitch() * 0.8F);

View File

@@ -15,7 +15,6 @@ import net.minecraft.nbt.Tag;
import net.minecraft.world.level.Level;
import javax.annotation.Nullable;
import java.util.Collections;
import java.util.Map;
import static dan200.computercraft.core.util.Nullability.assertNonNull;
@@ -86,11 +85,6 @@ public final class WiredModemLocalPeripheral {
return peripheral != null ? type + "_" + id : null;
}
@Nullable
public IPeripheral getPeripheral() {
return peripheral;
}
public boolean hasPeripheral() {
return peripheral != null;
}
@@ -100,9 +94,7 @@ public final class WiredModemLocalPeripheral {
}
public Map<String, IPeripheral> toMap() {
return peripheral == null
? Map.of()
: Collections.singletonMap(type + "_" + id, peripheral);
return peripheral == null ? Map.of() : Map.of(type + "_" + id, peripheral);
}
public void write(CompoundTag tag, String suffix) {

View File

@@ -45,13 +45,18 @@ public class MonitorBlockEntity extends BlockEntity {
private final boolean advanced;
private @Nullable ServerMonitor serverMonitor;
/**
* The monitor's state on the client. This is defined iff we're the origin monitor
* ({@code xIndex == 0 && yIndex == 0}).
*/
private @Nullable ClientMonitor clientMonitor;
private @Nullable MonitorPeripheral peripheral;
private final Set<IComputerAccess> computers = Collections.newSetFromMap(new ConcurrentHashMap<>());
private boolean needsUpdate = false;
private boolean needsValidating = false;
private boolean destroyed = false;
// MonitorWatcher state.
boolean enqueued;
@@ -90,7 +95,7 @@ public class MonitorBlockEntity extends BlockEntity {
@Override
public void setRemoved() {
super.setRemoved();
if (clientMonitor != null && xIndex == 0 && yIndex == 0) clientMonitor.destroy();
if (clientMonitor != null) clientMonitor.destroy();
}
@Override
@@ -144,7 +149,7 @@ public class MonitorBlockEntity extends BlockEntity {
private ServerMonitor getServerMonitor() {
if (serverMonitor != null) return serverMonitor;
var origin = getOrigin().getMonitor();
var origin = getOrigin();
if (origin == null) return null;
return serverMonitor = origin.serverMonitor;
@@ -183,13 +188,11 @@ public class MonitorBlockEntity extends BlockEntity {
}
@Nullable
public ClientMonitor getClientMonitor() {
public ClientMonitor getOriginClientMonitor() {
if (clientMonitor != null) return clientMonitor;
var te = level.getBlockEntity(toWorldPos(0, 0));
if (!(te instanceof MonitorBlockEntity monitor)) return null;
return clientMonitor = monitor.clientMonitor;
var origin = getOrigin();
return origin == null ? null : origin.clientMonitor;
}
// Networking stuff
@@ -210,17 +213,14 @@ public class MonitorBlockEntity extends BlockEntity {
}
private void onClientLoad(int oldXIndex, int oldYIndex) {
if (oldXIndex != xIndex || oldYIndex != yIndex) {
// If our index has changed then it's possible the origin monitor has changed. Thus
// we'll clear our cache. If we're the origin then we'll need to remove the glList as well.
if (oldXIndex == 0 && oldYIndex == 0 && clientMonitor != null) clientMonitor.destroy();
if ((oldXIndex != xIndex || oldYIndex != yIndex) && clientMonitor != null) {
// If our index has changed, and we were the origin, then destroy the current monitor.
clientMonitor.destroy();
clientMonitor = null;
}
if (xIndex == 0 && yIndex == 0) {
// If we're the origin terminal then create it.
if (clientMonitor == null) clientMonitor = new ClientMonitor(this);
}
// If we're the origin terminal then create it.
if (xIndex == 0 && yIndex == 0 && clientMonitor == null) clientMonitor = new ClientMonitor(this);
}
public final void read(TerminalState state) {
@@ -287,7 +287,7 @@ public class MonitorBlockEntity extends BlockEntity {
}
boolean isCompatible(MonitorBlockEntity other) {
return !other.destroyed && advanced == other.advanced && getOrientation() == other.getOrientation() && getDirection() == other.getDirection();
return advanced == other.advanced && getOrientation() == other.getOrientation() && getDirection() == other.getDirection();
}
/**
@@ -310,8 +310,8 @@ public class MonitorBlockEntity extends BlockEntity {
return isCompatible(monitor) ? MonitorState.present(monitor) : MonitorState.MISSING;
}
private MonitorState getOrigin() {
return getLoadedMonitor(0, 0);
private @Nullable MonitorBlockEntity getOrigin() {
return getLoadedMonitor(0, 0).getMonitor();
}
/**
@@ -390,7 +390,7 @@ public class MonitorBlockEntity extends BlockEntity {
}
void expand() {
var monitor = getOrigin().getMonitor();
var monitor = getOrigin();
if (monitor != null && monitor.xIndex == 0 && monitor.yIndex == 0) new Expander(monitor).expand();
}
@@ -560,7 +560,7 @@ public class MonitorBlockEntity extends BlockEntity {
}
var hasPeripheral = false;
var origin = getOrigin().getMonitor();
var origin = getOrigin();
var serverMonitor = origin != null ? origin.serverMonitor : this.serverMonitor;
for (var x = 0; x < width; x++) {
for (var y = 0; y < height; y++) {

View File

@@ -11,6 +11,7 @@ import dan200.computercraft.api.upgrades.UpgradeData;
import dan200.computercraft.core.computer.ComputerSide;
import dan200.computercraft.shared.common.IColouredItem;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.computer.core.ComputerState;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.config.Config;
import dan200.computercraft.shared.network.client.PocketComputerDataMessage;
@@ -37,7 +38,10 @@ public class PocketServerComputer extends ServerComputer implements IPocketAcces
private ItemStack stack = ItemStack.EMPTY;
private int lightColour = -1;
private boolean lightChanged = false;
// The state the previous tick, used to determine if the state needs to be sent to the client.
private int oldLightColour = -1;
private @Nullable ComputerState oldComputerState;
private final Set<ServerPlayer> tracking = new HashSet<>();
@@ -82,10 +86,7 @@ public class PocketServerComputer extends ServerComputer implements IPocketAcces
@Override
public void setLight(int colour) {
if (colour < 0 || colour > 0xFFFFFF) colour = -1;
if (lightColour == colour) return;
lightColour = colour;
lightChanged = true;
}
@Override
@@ -156,9 +157,11 @@ public class PocketServerComputer extends ServerComputer implements IPocketAcces
tracking.removeIf(player -> !player.isAlive() || player.level() != getLevel());
// And now find any new players, add them to the tracking list, and broadcast state where appropriate.
var sendState = hasOutputChanged() || lightChanged;
lightChanged = false;
if (sendState) {
var state = getState();
if (oldLightColour != lightColour || oldComputerState != state) {
oldComputerState = state;
oldLightColour = lightColour;
// Broadcast the state to all players
tracking.addAll(getLevel().players());
ServerNetworking.sendToPlayers(new PocketComputerDataMessage(this, false), tracking);

View File

@@ -186,7 +186,7 @@ public class TurtleBlockEntity extends AbstractComputerBlockEntity implements Ba
if (dir.getAxis() == Direction.Axis.Y) dir = Direction.NORTH;
level.setBlockAndUpdate(worldPosition, getBlockState().setValue(TurtleBlock.FACING, dir));
updateOutput();
updateRedstone();
updateInputsImmediately();
onTileEntityChange();

View File

@@ -296,7 +296,7 @@ public class TurtleBrain implements TurtleAccessInternal {
oldWorld.removeBlock(oldPos, false);
// Make sure everybody knows about it
newTurtle.updateOutput();
newTurtle.updateRedstone();
newTurtle.updateInputsImmediately();
return true;
}

View File

@@ -5,13 +5,20 @@
package dan200.computercraft.shared.util;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ChunkLevel;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelAccessor;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.chunk.ChunkStatus;
import net.minecraft.world.level.chunk.LevelChunk;
import java.util.Queue;
import java.util.*;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
/**
* A thread-safe version of {@link LevelAccessor#scheduleTick(BlockPos, Block, int)}.
@@ -22,26 +29,92 @@ public final class TickScheduler {
private TickScheduler() {
}
/**
* The list of block entities to tick.
*/
private static final Queue<Token> toTick = new ConcurrentLinkedDeque<>();
/**
* Block entities which we want to tick, but whose chunks not currently loaded.
* <p>
* Minecraft sometimes keeps chunks in-memory, but not actively loaded. If such a block entity is in the
* {@link #toTick} queue, we'll see that it's not loaded and so have to skip scheduling a tick.
* <p>
* However, if the block entity is ever loaded again, we need to tick it. Unfortunately, block entities in this
* state are not notified in any way (for instance, {@link BlockEntity#setRemoved()} or
* {@link BlockEntity#clearRemoved()} are not called), and so there's no way to easily reschedule them for ticking.
* <p>
* Instead, for each chunk we keep a list of all block entities whose tick we skipped. If a chunk is loaded,
* {@linkplain #onChunkTicketChanged(ServerLevel, long, int, int) we requeue all skipped ticks}.
*/
private static final Map<ChunkReference, List<Token>> delayed = new HashMap<>();
/**
* Schedule a block entity to be ticked.
*
* @param token The token whose block entity should be ticked.
*/
public static void schedule(Token token) {
var world = token.owner.getLevel();
if (world != null && !world.isClientSide && !token.scheduled.getAndSet(true)) toTick.add(token);
if (world != null && !world.isClientSide && Token.STATE.compareAndSet(token, State.IDLE, State.SCHEDULED)) {
toTick.add(token);
}
}
public static void onChunkTicketChanged(ServerLevel level, long chunkPos, int oldLevel, int newLevel) {
boolean oldLoaded = isLoaded(oldLevel), newLoaded = isLoaded(newLevel);
if (!oldLoaded && newLoaded) {
// If our chunk is becoming active, requeue all pending tokens.
var delayedTokens = delayed.remove(new ChunkReference(level.dimension(), chunkPos));
if (delayedTokens == null) return;
for (var token : delayedTokens) {
if (token.owner.isRemoved()) {
Token.STATE.set(token, State.IDLE);
} else {
Token.STATE.set(token, State.SCHEDULED);
toTick.add(token);
}
}
}
}
public static void onChunkUnload(LevelChunk chunk) {
// If our chunk is fully unloaded, all block entities are about to be removed - we need to dequeue any delayed
// tokens from the queue.
var delayedTokens = delayed.remove(new ChunkReference(chunk.getLevel().dimension(), chunk.getPos().toLong()));
if (delayedTokens == null) return;
for (var token : delayedTokens) Token.STATE.set(token, State.IDLE);
}
public static void tick() {
Token token;
while ((token = toTick.poll()) != null) {
token.scheduled.set(false);
var blockEntity = token.owner;
if (blockEntity.isRemoved()) continue;
while ((token = toTick.poll()) != null) Token.STATE.set(token, tickToken(token));
}
var world = blockEntity.getLevel();
var pos = blockEntity.getBlockPos();
private static State tickToken(Token token) {
var blockEntity = token.owner;
if (world != null && world.isLoaded(pos) && world.getBlockEntity(pos) == blockEntity) {
world.scheduleTick(pos, blockEntity.getBlockState().getBlock(), 0);
// If the block entity has been removed, then remove it from the queue.
if (blockEntity.isRemoved()) return State.IDLE;
var level = Objects.requireNonNull(blockEntity.getLevel(), "Block entity level cannot become null");
var pos = blockEntity.getBlockPos();
if (!level.isLoaded(pos)) {
// The chunk is not properly loaded, as it to our delayed set.
delayed.computeIfAbsent(new ChunkReference(level.dimension(), ChunkPos.asLong(pos)), x -> new ArrayList<>()).add(token);
return State.UNLOADED;
} else {
// This should be impossible: either the block entity is at the above position, or it has been removed.
if (level.getBlockEntity(pos) != blockEntity) {
throw new IllegalStateException("Expected " + blockEntity + " at " + pos);
}
// Otherwise schedule a tick and remove it from the queue.
level.scheduleTick(pos, blockEntity.getBlockState().getBlock(), 0);
return State.IDLE;
}
}
@@ -52,11 +125,51 @@ public final class TickScheduler {
* As such, it should be unique per {@link BlockEntity} instance to avoid it being queued multiple times.
*/
public static class Token {
static final AtomicReferenceFieldUpdater<Token, State> STATE = AtomicReferenceFieldUpdater.newUpdater(Token.class, State.class, "$state");
final BlockEntity owner;
final AtomicBoolean scheduled = new AtomicBoolean();
/**
* The current state of this token.
*/
private volatile State $state = State.IDLE;
public Token(BlockEntity owner) {
this.owner = owner;
}
}
/**
* The possible states a {@link Token} can be in.
* <p>
* This effectively stores which (if any) queue the token is currently in, allowing us to skip scheduling if the
* token is already enqueued.
*/
private enum State {
/**
* The token is not on any queues.
*/
IDLE,
/**
* The token is on the {@link #toTick} queue.
*/
SCHEDULED,
/**
* The token is on the {@link #delayed} queue.
*/
UNLOADED,
}
private record ChunkReference(ResourceKey<Level> level, Long position) {
@Override
public String toString() {
return "ChunkReference(" + level + " at " + new ChunkPos(position) + ")";
}
}
private static boolean isLoaded(int level) {
return level <= ChunkLevel.byStatus(ChunkStatus.FULL);
}
}

View File

@@ -0,0 +1,183 @@
// SPDX-FileCopyrightText: 2024 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.impl.network.wired;
import dan200.computercraft.api.network.wired.WiredNetwork;
import dan200.computercraft.impl.network.wired.NetworkTest.NetworkElement;
import dan200.computercraft.shared.util.DirectionUtil;
import it.unimi.dsi.fastutil.objects.Object2IntMap;
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
import net.minecraft.core.BlockPos;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class NetworkBenchmark {
private static final int BRUTE_SIZE = 16;
public static void main(String[] args) throws RunnerException {
var opts = new OptionsBuilder()
.include(NetworkBenchmark.class.getName() + "\\..*")
.warmupIterations(2)
.measurementIterations(5)
.forks(1)
.build();
new Runner(opts).run();
}
@Benchmark
@Warmup(time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(time = 2, timeUnit = TimeUnit.SECONDS)
public void removeEveryNode(ConnectedGrid grid) {
grid.grid.forEach((node, pos) -> node.remove());
}
@Benchmark
@Warmup(time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(time = 2, timeUnit = TimeUnit.SECONDS)
public void connectAndDisconnect(SplitGrid connectedGrid) {
WiredNodeImpl left = connectedGrid.left, right = connectedGrid.right;
assertNotEquals(left.getNetwork(), right.getNetwork());
left.connectTo(right);
assertEquals(left.getNetwork(), right.getNetwork());
left.disconnectFrom(right);
assertNotEquals(left.getNetwork(), right.getNetwork());
}
@Benchmark
@Warmup(time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(time = 2, timeUnit = TimeUnit.SECONDS)
public void connectAndRemove(SplitGrid connectedGrid) {
WiredNodeImpl left = connectedGrid.left, right = connectedGrid.right, centre = connectedGrid.centre;
assertNotEquals(left.getNetwork(), right.getNetwork());
centre.connectTo(left);
centre.connectTo(right);
assertEquals(left.getNetwork(), right.getNetwork());
centre.remove();
assertNotEquals(left.getNetwork(), right.getNetwork());
}
/**
* Create a grid where all nodes are connected to their neighbours.
*/
@State(Scope.Thread)
public static class ConnectedGrid {
Grid<WiredNodeImpl> grid;
@Setup(Level.Invocation)
public void setup() {
var grid = this.grid = new Grid<>(BRUTE_SIZE);
grid.map((existing, pos) -> new NetworkElement("n_" + pos, pos.getX() == pos.getY() && pos.getY() == pos.getZ()).getNode());
// Connect every node
grid.forEach((node, pos) -> {
for (var facing : DirectionUtil.FACINGS) {
var other = grid.get(pos.relative(facing));
if (other != null) node.connectTo(other);
}
});
var networks = countNetworks(grid);
if (networks.size() != 1) throw new AssertionError("Expected exactly one network.");
}
}
/**
* Create a grid where the nodes at {@code x < BRUTE_SIZE/2} and {@code x >= BRUTE_SIZE/2} are in separate networks,
* but otherwise connected to their neighbours.
*/
@State(Scope.Thread)
public static class SplitGrid {
Grid<WiredNodeImpl> grid;
WiredNodeImpl left, right, centre;
@Setup
public void setup() {
var grid = this.grid = new Grid<>(BRUTE_SIZE);
grid.map((existing, pos) -> new NetworkElement("n_" + pos, pos.getX() == pos.getY() && pos.getY() == pos.getZ()).getNode());
// Connect every node
grid.forEach((node, pos) -> {
for (var facing : DirectionUtil.FACINGS) {
var offset = pos.relative(facing);
if (offset.getX() >= BRUTE_SIZE / 2 == pos.getX() >= BRUTE_SIZE / 2) {
var other = grid.get(offset);
if (other != null) node.connectTo(other);
}
}
});
var networks = countNetworks(grid);
if (networks.size() != 2) throw new AssertionError("Expected exactly two networks.");
for (var network : networks.object2IntEntrySet()) {
if (network.getIntValue() != BRUTE_SIZE * BRUTE_SIZE * (BRUTE_SIZE / 2)) {
throw new AssertionError("Network is the wrong size");
}
}
left = Objects.requireNonNull(grid.get(new BlockPos(BRUTE_SIZE / 2 - 1, 0, 0)));
right = Objects.requireNonNull(grid.get(new BlockPos(BRUTE_SIZE / 2, 0, 0)));
centre = new NetworkElement("c", false).getNode();
}
}
private static Object2IntMap<WiredNetwork> countNetworks(Grid<WiredNodeImpl> grid) {
Object2IntMap<WiredNetwork> networks = new Object2IntOpenHashMap<>();
grid.forEach((node, pos) -> networks.put(node.network, networks.getOrDefault(node.network, 0) + 1));
return networks;
}
private static class Grid<T> {
private final int size;
private final T[] box;
@SuppressWarnings("unchecked")
Grid(int size) {
this.size = size;
this.box = (T[]) new Object[size * size * size];
}
public T get(BlockPos pos) {
int x = pos.getX(), y = pos.getY(), z = pos.getZ();
return x >= 0 && x < size && y >= 0 && y < size && z >= 0 && z < size
? box[x * size * size + y * size + z]
: null;
}
public void forEach(BiConsumer<T, BlockPos> transform) {
for (var x = 0; x < size; x++) {
for (var y = 0; y < size; y++) {
for (var z = 0; z < size; z++) {
transform.accept(box[x * size * size + y * size + z], new BlockPos(x, y, z));
}
}
}
}
public void map(BiFunction<T, BlockPos, T> transform) {
for (var x = 0; x < size; x++) {
for (var y = 0; y < size; y++) {
for (var z = 0; z < size; z++) {
box[x * size * size + y * size + z] = transform.apply(box[x * size * size + y * size + z], new BlockPos(x, y, z));
}
}
}
}
}
}

View File

@@ -9,19 +9,14 @@ import dan200.computercraft.api.network.wired.WiredNetwork;
import dan200.computercraft.api.network.wired.WiredNetworkChange;
import dan200.computercraft.api.network.wired.WiredNode;
import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.shared.util.DirectionUtil;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import javax.annotation.Nullable;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import static org.junit.jupiter.api.Assertions.*;
@@ -29,11 +24,11 @@ public class NetworkTest {
@Test
public void testConnect() {
NetworkElement
aE = new NetworkElement(null, null, "a"),
bE = new NetworkElement(null, null, "b"),
cE = new NetworkElement(null, null, "c");
aE = new NetworkElement("a"),
bE = new NetworkElement("b"),
cE = new NetworkElement("c");
WiredNode
WiredNodeImpl
aN = aE.getNode(),
bN = bE.getNode(),
cN = cE.getNode();
@@ -42,8 +37,8 @@ public class NetworkTest {
assertNotEquals(aN.getNetwork(), cN.getNetwork(), "A's and C's network must be different");
assertNotEquals(bN.getNetwork(), cN.getNetwork(), "B's and C's network must be different");
assertTrue(aN.getNetwork().connect(aN, bN), "Must be able to add connection");
assertFalse(aN.getNetwork().connect(aN, bN), "Cannot add connection twice");
assertTrue(aN.connectTo(bN), "Must be able to add connection");
assertFalse(aN.connectTo(bN), "Cannot add connection twice");
assertEquals(aN.getNetwork(), bN.getNetwork(), "A's and B's network must be equal");
assertEquals(Set.of(aN, bN), nodes(aN.getNetwork()), "A's network should be A and B");
@@ -51,7 +46,7 @@ public class NetworkTest {
assertEquals(Set.of("a", "b"), aE.allPeripherals().keySet(), "A's peripheral set should be A, B");
assertEquals(Set.of("a", "b"), bE.allPeripherals().keySet(), "B's peripheral set should be A, B");
aN.getNetwork().connect(aN, cN);
aN.connectTo(cN);
assertEquals(aN.getNetwork(), bN.getNetwork(), "A's and B's network must be equal");
assertEquals(aN.getNetwork(), cN.getNetwork(), "A's and C's network must be equal");
@@ -69,20 +64,20 @@ public class NetworkTest {
@Test
public void testDisconnectNoChange() {
NetworkElement
aE = new NetworkElement(null, null, "a"),
bE = new NetworkElement(null, null, "b"),
cE = new NetworkElement(null, null, "c");
aE = new NetworkElement("a"),
bE = new NetworkElement("b"),
cE = new NetworkElement("c");
WiredNode
WiredNodeImpl
aN = aE.getNode(),
bN = bE.getNode(),
cN = cE.getNode();
aN.getNetwork().connect(aN, bN);
aN.getNetwork().connect(aN, cN);
aN.getNetwork().connect(bN, cN);
aN.connectTo(bN);
aN.connectTo(cN);
bN.connectTo(cN);
aN.getNetwork().disconnect(aN, bN);
aN.disconnectFrom(bN);
assertEquals(aN.getNetwork(), bN.getNetwork(), "A's and B's network must be equal");
assertEquals(aN.getNetwork(), cN.getNetwork(), "A's and C's network must be equal");
@@ -96,19 +91,19 @@ public class NetworkTest {
@Test
public void testDisconnectLeaf() {
NetworkElement
aE = new NetworkElement(null, null, "a"),
bE = new NetworkElement(null, null, "b"),
cE = new NetworkElement(null, null, "c");
aE = new NetworkElement("a"),
bE = new NetworkElement("b"),
cE = new NetworkElement("c");
WiredNode
WiredNodeImpl
aN = aE.getNode(),
bN = bE.getNode(),
cN = cE.getNode();
aN.getNetwork().connect(aN, bN);
aN.getNetwork().connect(aN, cN);
aN.connectTo(bN);
aN.connectTo(cN);
aN.getNetwork().disconnect(aN, bN);
aN.disconnectFrom(bN);
assertNotEquals(aN.getNetwork(), bN.getNetwork(), "A's and B's network must not be equal");
assertEquals(aN.getNetwork(), cN.getNetwork(), "A's and C's network must be equal");
@@ -123,23 +118,23 @@ public class NetworkTest {
@Test
public void testDisconnectSplit() {
NetworkElement
aE = new NetworkElement(null, null, "a"),
aaE = new NetworkElement(null, null, "a_"),
bE = new NetworkElement(null, null, "b"),
bbE = new NetworkElement(null, null, "b_");
aE = new NetworkElement("a"),
aaE = new NetworkElement("a_"),
bE = new NetworkElement("b"),
bbE = new NetworkElement("b_");
WiredNode
WiredNodeImpl
aN = aE.getNode(),
aaN = aaE.getNode(),
bN = bE.getNode(),
bbN = bbE.getNode();
aN.getNetwork().connect(aN, aaN);
bN.getNetwork().connect(bN, bbN);
aN.connectTo(aaN);
bN.connectTo(bbN);
aN.getNetwork().connect(aN, bN);
aN.connectTo(bN);
aN.getNetwork().disconnect(aN, bN);
aN.disconnectFrom(bN);
assertNotEquals(aN.getNetwork(), bN.getNetwork(), "A's and B's network must not be equal");
assertEquals(aN.getNetwork(), aaN.getNetwork(), "A's and A_'s network must be equal");
@@ -154,7 +149,7 @@ public class NetworkTest {
@Test
public void testRemoveSingle() {
var aE = new NetworkElement(null, null, "a");
var aE = new NetworkElement("a");
var aN = aE.getNode();
var network = aN.getNetwork();
@@ -165,20 +160,20 @@ public class NetworkTest {
@Test
public void testRemoveLeaf() {
NetworkElement
aE = new NetworkElement(null, null, "a"),
bE = new NetworkElement(null, null, "b"),
cE = new NetworkElement(null, null, "c");
aE = new NetworkElement("a"),
bE = new NetworkElement("b"),
cE = new NetworkElement("c");
WiredNode
WiredNodeImpl
aN = aE.getNode(),
bN = bE.getNode(),
cN = cE.getNode();
aN.getNetwork().connect(aN, bN);
aN.getNetwork().connect(aN, cN);
aN.connectTo(bN);
aN.connectTo(cN);
assertTrue(aN.getNetwork().remove(bN), "Must be able to remove node");
assertFalse(aN.getNetwork().remove(bN), "Cannot remove a second time");
assertTrue(bN.remove(), "Must be able to remove node");
assertFalse(bN.remove(), "Cannot remove a second time");
assertNotEquals(aN.getNetwork(), bN.getNetwork(), "A's and B's network must not be equal");
assertEquals(aN.getNetwork(), cN.getNetwork(), "A's and C's network must be equal");
@@ -194,26 +189,26 @@ public class NetworkTest {
@Test
public void testRemoveSplit() {
NetworkElement
aE = new NetworkElement(null, null, "a"),
aaE = new NetworkElement(null, null, "a_"),
bE = new NetworkElement(null, null, "b"),
bbE = new NetworkElement(null, null, "b_"),
cE = new NetworkElement(null, null, "c");
aE = new NetworkElement("a"),
aaE = new NetworkElement("a_"),
bE = new NetworkElement("b"),
bbE = new NetworkElement("b_"),
cE = new NetworkElement("c");
WiredNode
WiredNodeImpl
aN = aE.getNode(),
aaN = aaE.getNode(),
bN = bE.getNode(),
bbN = bbE.getNode(),
cN = cE.getNode();
aN.getNetwork().connect(aN, aaN);
bN.getNetwork().connect(bN, bbN);
aN.connectTo(aaN);
bN.connectTo(bbN);
cN.getNetwork().connect(aN, cN);
cN.getNetwork().connect(bN, cN);
cN.connectTo(aN);
cN.connectTo(bN);
cN.getNetwork().remove(cN);
cN.remove();
assertNotEquals(aN.getNetwork(), bN.getNetwork(), "A's and B's network must not be equal");
assertEquals(aN.getNetwork(), aaN.getNetwork(), "A's and A_'s network must be equal");
@@ -228,96 +223,30 @@ public class NetworkTest {
assertEquals(Set.of(), cE.allPeripherals().keySet(), "C's peripheral set should be empty");
}
private static final int BRUTE_SIZE = 16;
private static final int TOGGLE_CONNECTION_TIMES = 5;
private static final int TOGGLE_NODE_TIMES = 5;
@Test
@Disabled("Takes a long time to run, mostly for stress testing")
public void testLarge() {
var grid = new Grid<WiredNode>(BRUTE_SIZE);
grid.map((existing, pos) -> new NetworkElement(null, null, "n_" + pos).getNode());
// Test connecting
{
var start = System.nanoTime();
grid.forEach((existing, pos) -> {
for (var facing : DirectionUtil.FACINGS) {
var offset = pos.relative(facing);
if (offset.getX() > BRUTE_SIZE / 2 == pos.getX() > BRUTE_SIZE / 2) {
var other = grid.get(offset);
if (other != null) existing.getNetwork().connect(existing, other);
}
}
});
var end = System.nanoTime();
System.out.printf("Connecting %s³ nodes took %s seconds\n", BRUTE_SIZE, (end - start) * 1e-9);
}
// Test toggling
{
var left = grid.get(new BlockPos(BRUTE_SIZE / 2, 0, 0));
var right = grid.get(new BlockPos(BRUTE_SIZE / 2 + 1, 0, 0));
assertNotEquals(left.getNetwork(), right.getNetwork());
var start = System.nanoTime();
for (var i = 0; i < TOGGLE_CONNECTION_TIMES; i++) {
left.getNetwork().connect(left, right);
left.getNetwork().disconnect(left, right);
}
var end = System.nanoTime();
System.out.printf("Toggling connection %s times took %s seconds\n", TOGGLE_CONNECTION_TIMES, (end - start) * 1e-9);
}
{
var left = grid.get(new BlockPos(BRUTE_SIZE / 2, 0, 0));
var right = grid.get(new BlockPos(BRUTE_SIZE / 2 + 1, 0, 0));
var centre = new NetworkElement(null, null, "c").getNode();
assertNotEquals(left.getNetwork(), right.getNetwork());
var start = System.nanoTime();
for (var i = 0; i < TOGGLE_NODE_TIMES; i++) {
left.getNetwork().connect(left, centre);
right.getNetwork().connect(right, centre);
left.getNetwork().remove(centre);
}
var end = System.nanoTime();
System.out.printf("Toggling node %s times took %s seconds\n", TOGGLE_NODE_TIMES, (end - start) * 1e-9);
}
}
private static final class NetworkElement implements WiredElement {
private final Level world;
private final Vec3 position;
static final class NetworkElement implements WiredElement {
private final String id;
private final WiredNode node;
private final WiredNodeImpl node;
private final Map<String, IPeripheral> localPeripherals = new HashMap<>();
private final Map<String, IPeripheral> remotePeripherals = new HashMap<>();
private NetworkElement(Level world, Vec3 position, String id) {
this.world = world;
this.position = position;
NetworkElement(String id) {
this(id, true);
}
NetworkElement(String id, boolean peripheral) {
this.id = id;
this.node = new WiredNodeImpl(this);
this.addPeripheral(id);
if (peripheral) addPeripheral(id);
}
@Override
public Level getLevel() {
return world;
throw new IllegalStateException("Unexpected call to getLevel()");
}
@Override
public Vec3 getPosition() {
return position;
throw new IllegalStateException("Unexpected call to getPosition()");
}
@Override
@@ -331,7 +260,7 @@ public class NetworkTest {
}
@Override
public WiredNode getNode() {
public WiredNodeImpl getNode() {
return node;
}
@@ -364,45 +293,6 @@ public class NetworkTest {
}
}
private static class Grid<T> {
private final int size;
private final T[] box;
@SuppressWarnings("unchecked")
Grid(int size) {
this.size = size;
this.box = (T[]) new Object[size * size * size];
}
public T get(BlockPos pos) {
int x = pos.getX(), y = pos.getY(), z = pos.getZ();
return x >= 0 && x < size && y >= 0 && y < size && z >= 0 && z < size
? box[x * size * size + y * size + z]
: null;
}
public void forEach(BiConsumer<T, BlockPos> transform) {
for (var x = 0; x < size; x++) {
for (var y = 0; y < size; y++) {
for (var z = 0; z < size; z++) {
transform.accept(box[x * size * size + y * size + z], new BlockPos(x, y, z));
}
}
}
}
public void map(BiFunction<T, BlockPos, T> transform) {
for (var x = 0; x < size; x++) {
for (var y = 0; y < size; y++) {
for (var z = 0; z < size; z++) {
box[x * size * size + y * size + z] = transform.apply(box[x * size * size + y * size + z], new BlockPos(x, y, z));
}
}
}
}
}
private static Set<WiredNodeImpl> nodes(WiredNetwork network) {
return ((WiredNetworkImpl) network).nodes;
}

View File

@@ -116,6 +116,7 @@ public final class MethodResult {
* @return The method result which represents this yield.
* @see #pullEvent(String, ILuaCallback)
*/
@SuppressWarnings("NamedLikeContextualKeyword")
public static MethodResult yield(@Nullable Object[] arguments, ILuaCallback callback) {
Objects.requireNonNull(callback, "callback cannot be null");
return new MethodResult(arguments, callback);

View File

@@ -13,8 +13,54 @@ import dan200.computercraft.core.util.Colour;
/**
* Interact with a computer's terminal or monitors, writing text and drawing
* ASCII graphics.
* Interact with a computer's terminal or monitors, writing text and drawing ASCII graphics.
*
* <h2>Writing to the terminal</h2>
* The simplest operation one can perform on a terminal is displaying (or writing) some text. This can be performed with
* the [`term.write`] method.
*
* <pre>{@code
* term.write("Hello, world!")
* }</pre>
* <p>
* When you write text, this advances the cursor, so the next call to [`term.write`] will write text immediately after
* the previous one.
*
* <pre>{@code
* term.write("Hello, world!")
* term.write("Some more text")
* }</pre>
* <p>
* [`term.getCursorPos`] and [`term.setCursorPos`] can be used to manually change the cursor's position.
* <p>
* <pre>{@code
* term.clear()
*
* term.setCursorPos(1, 1) -- The first column of line 1
* term.write("First line")
*
* term.setCursorPos(20, 2) -- The 20th column of line 2
* term.write("Second line")
* }</pre>
* <p>
* [`term.write`] is a relatively basic and low-level function, and does not handle more advanced features such as line
* breaks or word wrapping. If you just want to display text to the screen, you probably want to use [`print`] or
* [`write`] instead.
*
* <h2>Colours</h2>
* So far we've been writing text in black and white. However, advanced computers are also capable of displaying text
* in a variety of colours, with the [`term.setTextColour`] and [`term.setBackgroundColour`] functions.
*
* <pre>{@code
* print("This text is white")
* term.setTextColour(colours.green)
* print("This text is green")
* }</pre>
* <p>
* These functions accept any of the constants from the [`colors`] API. [Combinations of colours][`colors.combine`] may
* be accepted, but will only display a single colour (typically following the behaviour of [`colors.toBlit`]).
* <p>
* The [`paintutils`] API provides several helpful functions for displaying graphics using [`term.setBackgroundColour`].
*
* @cc.module term
*/

View File

@@ -4,6 +4,7 @@
package dan200.computercraft.core.apis.http.options;
import com.google.errorprone.annotations.Immutable;
/**
* Options for a given HTTP request or websocket, which control its resource constraints.
@@ -14,5 +15,6 @@ package dan200.computercraft.core.apis.http.options;
* @param websocketMessage The maximum size of a websocket message (outgoing and incoming).
* @param useProxy Whether to use the configured proxy.
*/
@Immutable
public record Options(Action action, long maxUpload, long maxDownload, int websocketMessage, boolean useProxy) {
}

View File

@@ -5,6 +5,7 @@
package dan200.computercraft.core.apis.http.options;
import com.google.errorprone.annotations.Immutable;
import com.google.errorprone.annotations.concurrent.LazyInit;
import javax.annotation.Nullable;
import java.util.Optional;
@@ -23,7 +24,7 @@ public final class PartialOptions {
private final OptionalInt websocketMessage;
private final Optional<Boolean> useProxy;
@SuppressWarnings("Immutable") // Lazily initialised, so this mutation is invisible in the public API
@LazyInit
private @Nullable Options options;
public PartialOptions(@Nullable Action action, OptionalLong maxUpload, OptionalLong maxDownload, OptionalInt websocketMessage, Optional<Boolean> useProxy) {

View File

@@ -27,7 +27,7 @@ final class ResultHelpers {
return throwUnchecked0(t);
}
@SuppressWarnings("unchecked")
@SuppressWarnings({ "unchecked", "TypeParameterUnusedInFormals" })
private static <T extends Throwable> T throwUnchecked0(Throwable t) throws T {
throw (T) t;
}

View File

@@ -15,8 +15,7 @@ import dan200.computercraft.core.filesystem.FileSystem;
import dan200.computercraft.core.terminal.Terminal;
import javax.annotation.Nullable;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
/**
@@ -54,9 +53,9 @@ public class Computer {
private final AtomicLong lastTaskId = new AtomicLong();
// Additional state about the computer and its environment.
private boolean blinking = false;
private final Environment internalEnvironment;
private final AtomicBoolean externalOutputChanged = new AtomicBoolean();
private final AtomicInteger externalOutputChanges = new AtomicInteger();
private boolean startRequested;
private int ticksSinceStart = -1;
@@ -140,10 +139,7 @@ public class Computer {
}
public void setLabel(@Nullable String label) {
if (!Objects.equals(label, this.label)) {
this.label = label;
externalOutputChanged.set(true);
}
this.label = label;
}
public void tick() {
@@ -164,28 +160,24 @@ public class Computer {
internalEnvironment.tick();
// Propagate the environment's output to the world.
if (internalEnvironment.updateOutput()) externalOutputChanged.set(true);
// Set output changed if the terminal has changed from blinking to not
var blinking = terminal.getCursorBlink() &&
terminal.getCursorX() >= 0 && terminal.getCursorX() < terminal.getWidth() &&
terminal.getCursorY() >= 0 && terminal.getCursorY() < terminal.getHeight();
if (blinking != this.blinking) {
this.blinking = blinking;
externalOutputChanged.set(true);
}
externalOutputChanges.accumulateAndGet(internalEnvironment.updateOutput(), (x, y) -> x | y);
}
void markChanged() {
externalOutputChanged.set(true);
}
public boolean pollAndResetChanged() {
return externalOutputChanged.getAndSet(false);
/**
* Get a bitmask returning which sides on the computer have changed, resetting the internal state.
*
* @return What sides on the computer have changed.
*/
public int pollAndResetChanges() {
return externalOutputChanges.getAndSet(0);
}
public boolean isBlinking() {
return isOn() && blinking;
if (!isOn() || !terminal.getCursorBlink()) return false;
var cursorX = terminal.getCursorX();
var cursorY = terminal.getCursorY();
return cursorX >= 0 && cursorX < terminal.getWidth() && cursorY >= 0 && cursorY < terminal.getHeight();
}
public void addApi(ILuaAPI api) {

View File

@@ -411,7 +411,6 @@ final class ComputerExecutor implements ComputerScheduler.Worker {
// Initialisation has finished, so let's mark ourselves as on.
isOn = true;
computer.markChanged();
} finally {
isOnLock.unlock();
}
@@ -446,7 +445,6 @@ final class ComputerExecutor implements ComputerScheduler.Worker {
}
computer.getEnvironment().resetOutput();
computer.markChanged();
} finally {
isOnLock.unlock();
}

View File

@@ -222,22 +222,22 @@ public final class Environment implements IAPIEnvironment {
*
* @return If the outputs have changed.
*/
boolean updateOutput() {
int updateOutput() {
// Mark output as changed if the internal redstone has changed
synchronized (internalOutput) {
if (!internalOutputChanged) return false;
if (!internalOutputChanged) return 0;
var changed = false;
var changed = 0;
for (var i = 0; i < ComputerSide.COUNT; i++) {
if (externalOutput[i] != internalOutput[i]) {
externalOutput[i] = internalOutput[i];
changed = true;
changed |= 1 << i;
}
if (externalBundledOutput[i] != internalBundledOutput[i]) {
externalBundledOutput[i] = internalBundledOutput[i];
changed = true;
changed |= 1 << i;
}
}

View File

@@ -518,6 +518,7 @@ public final class ComputerThread implements ComputerScheduler {
} else {
allocations = null;
}
var allocationTime = System.nanoTime();
for (var i = 0; i < workers.length; i++) {
var runner = workers[i];
@@ -532,7 +533,7 @@ public final class ComputerThread implements ComputerScheduler {
// And track the allocated memory.
if (allocations != null) {
executor.updateAllocations(new ThreadAllocation(workerThreadIds[i], allocations[i]));
executor.updateAllocations(new ThreadAllocation(workerThreadIds[i], allocations[i], allocationTime));
}
// If we're still within normal execution times (TIMEOUT) or soft abort (ABORT_TIMEOUT),
@@ -814,7 +815,7 @@ public final class ComputerThread implements ComputerScheduler {
if (ThreadAllocations.isSupported()) {
var current = Thread.currentThread().getId();
THREAD_ALLOCATION.set(this, new ThreadAllocation(current, ThreadAllocations.getAllocatedBytes(current)));
THREAD_ALLOCATION.set(this, new ThreadAllocation(current, ThreadAllocations.getAllocatedBytes(current), System.nanoTime()));
}
}
@@ -832,11 +833,20 @@ public final class ComputerThread implements ComputerScheduler {
var info = THREAD_ALLOCATION.getAndSet(this, null);
assert info.threadId() == current;
var allocated = ThreadAllocations.getAllocatedBytes(current) - info.allocatedBytes();
var allocatedTotal = ThreadAllocations.getAllocatedBytes(current);
var allocated = allocatedTotal - info.allocatedBytes();
if (allocated > 0) {
metrics.observe(Metrics.JAVA_ALLOCATION, allocated);
} else if (allocated < 0) {
LOG.warn("Allocated a negative number of bytes ({})!", allocated);
LOG.warn(
"""
Allocated a negative number of bytes ({})!
Previous measurement at t={} on Thread #{} = {}
Current measurement at t={} on Thread #{} = {}""",
allocated,
info.time(), info.threadId(), info.allocatedBytes(),
System.nanoTime(), current, allocatedTotal
);
}
}
@@ -901,7 +911,8 @@ public final class ComputerThread implements ComputerScheduler {
*
* @param threadId The ID of this thread.
* @param allocatedBytes The amount of memory this thread has allocated.
* @param time The time (in nanoseconds) when this time was computed.
*/
private record ThreadAllocation(long threadId, long allocatedBytes) {
private record ThreadAllocation(long threadId, long allocatedBytes, long time) {
}
}

View File

@@ -353,7 +353,8 @@ end
--[[- Converts the given color to a paint/blit hex character (0-9a-f).
This is equivalent to converting floor(log_2(color)) to hexadecimal.
This is equivalent to converting `floor(log_2(color))` to hexadecimal. Values
outside the range of a valid colour will error.
@tparam number color The color to convert.
@treturn string The blit hex code of the color.
@@ -367,7 +368,11 @@ colors.toBlit(colors.red)
]]
function toBlit(color)
expect(1, color, "number")
return color_hex_lookup[color] or string.format("%x", math.floor(math.log(color, 2)))
local hex = color_hex_lookup[color]
if hex then return hex end
if color < 0 or color > 0xffff then error("Colour out of range", 2) end
return string.format("%x", math.floor(math.log(color, 2)))
end
--[[- Converts the given paint/blit hex character (0-9a-f) to a color.

View File

@@ -58,6 +58,17 @@ local type = type
local string_rep = string.rep
local string_sub = string.sub
--- A custom version of [`colors.toBlit`], specialised for the window API.
local function parse_color(color)
if type(color) ~= "number" then
-- By tail-calling expect, we ensure expect has the right error level.
return expect(1, color, "number")
end
if color < 0 or color > 0xffff then error("Colour out of range", 3) end
return 2 ^ math.floor(math.log(color, 2))
end
--[[- Returns a terminal object that is a space within the specified parent
terminal object. This can then be used (or even redirected to) in the same
manner as eg a wrapped monitor. Refer to [the term API][`term`] for a list of
@@ -341,10 +352,7 @@ function create(parent, nX, nY, nWidth, nHeight, bStartVisible)
end
local function setTextColor(color)
if type(color) ~= "number" then expect(1, color, "number") end
if tHex[color] == nil then
error("Invalid color (got " .. color .. ")" , 2)
end
if tHex[color] == nil then color = parse_color(color) end
nTextColor = color
if bVisible then
@@ -356,11 +364,7 @@ function create(parent, nX, nY, nWidth, nHeight, bStartVisible)
window.setTextColour = setTextColor
function window.setPaletteColour(colour, r, g, b)
if type(colour) ~= "number" then expect(1, colour, "number") end
if tHex[colour] == nil then
error("Invalid color (got " .. colour .. ")" , 2)
end
if tHex[colour] == nil then colour = parse_color(colour) end
local tCol
if type(r) == "number" and g == nil and b == nil then
@@ -385,10 +389,7 @@ function create(parent, nX, nY, nWidth, nHeight, bStartVisible)
window.setPaletteColor = window.setPaletteColour
function window.getPaletteColour(colour)
if type(colour) ~= "number" then expect(1, colour, "number") end
if tHex[colour] == nil then
error("Invalid color (got " .. colour .. ")" , 2)
end
if tHex[colour] == nil then colour = parse_color(colour) end
local tCol = tPalette[colour]
return tCol[1], tCol[2], tCol[3]
end
@@ -396,10 +397,7 @@ function create(parent, nX, nY, nWidth, nHeight, bStartVisible)
window.getPaletteColor = window.getPaletteColour
local function setBackgroundColor(color)
if type(color) ~= "number" then expect(1, color, "number") end
if tHex[color] == nil then
error("Invalid color (got " .. color .. ")", 2)
end
if tHex[color] == nil then color = parse_color(color) end
nBackgroundColor = color
end

View File

@@ -1,3 +1,13 @@
# New features in CC: Tweaked 1.109.7
* Improve performance of removing and unloading wired cables/modems.
Several bug fixes:
* Fix monitors sometimes not updating on the client when chunks are unloaded and reloaded.
* `colour.toBlit` correctly errors on out-of-bounds values.
* Round non-standard colours in `window`, like `term.native()` does.
* Fix the client monitor rendering both the current and outdated contents.
# New features in CC: Tweaked 1.109.6
* Improve several Lua parser error messages.

View File

@@ -1,9 +1,11 @@
New features in CC: Tweaked 1.109.6
New features in CC: Tweaked 1.109.7
* Improve several Lua parser error messages.
* Allow addon mods to register `require`able modules.
* Improve performance of removing and unloading wired cables/modems.
Several bug fixes:
* Fix weak tables becoming malformed when keys are GCed.
* Fix monitors sometimes not updating on the client when chunks are unloaded and reloaded.
* `colour.toBlit` correctly errors on out-of-bounds values.
* Round non-standard colours in `window`, like `term.native()` does.
* Fix the client monitor rendering both the current and outdated contents.
Type "help changelog" to see the full version history.

View File

@@ -46,24 +46,9 @@ class LuaCoverage {
}
void write(Writer out) throws IOException {
Files.find(ROOT, Integer.MAX_VALUE, (path, attr) -> attr.isRegularFile()).forEach(path -> {
var relative = ROOT.relativize(path);
var full = relative.toString().replace('\\', '/');
if (!full.endsWith(".lua")) return;
var possiblePaths = coverage.remove("/" + full);
if (possiblePaths == null) possiblePaths = coverage.remove(full);
if (possiblePaths == null) {
possiblePaths = Int2IntMaps.EMPTY_MAP;
LOG.warn("{} has no coverage data", full);
}
try {
writeCoverageFor(out, path, possiblePaths);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
try (var files = Files.find(ROOT, Integer.MAX_VALUE, (path, attr) -> attr.isRegularFile())) {
files.forEach(path -> writeSingleFile(out, path));
}
for (var filename : coverage.keySet()) {
if (filename.startsWith("/test-rom/")) continue;
@@ -71,6 +56,25 @@ class LuaCoverage {
}
}
private void writeSingleFile(Writer out, Path path) {
var relative = ROOT.relativize(path);
var full = relative.toString().replace('\\', '/');
if (!full.endsWith(".lua")) return;
var possiblePaths = coverage.remove("/" + full);
if (possiblePaths == null) possiblePaths = coverage.remove(full);
if (possiblePaths == null) {
possiblePaths = Int2IntMaps.EMPTY_MAP;
LOG.warn("{} has no coverage data", full);
}
try {
writeCoverageFor(out, path, possiblePaths);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private void writeCoverageFor(Writer out, Path fullName, Map<Integer, Integer> visitedLines) throws IOException {
if (!Files.exists(fullName)) {
LOG.error("Cannot locate file {}", fullName);

View File

@@ -101,7 +101,7 @@ public class ComputerBootstrap {
try {
context.ensureClosed(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new IllegalStateException("Runtime thread was interrupted", e);
Thread.currentThread().interrupt();
}
}
}

View File

@@ -65,7 +65,7 @@ public class ComputerThreadRunner implements AutoCloseable {
} finally {
errorLock.unlock();
}
} while (worker.executed == 0 || thread.hasPendingWork());
} while (!worker.executed || thread.hasPendingWork());
}
@GuardedBy("errorLock")
@@ -87,7 +87,7 @@ public class ComputerThreadRunner implements AutoCloseable {
private final Task run;
private final ComputerScheduler.Executor executor;
private long[] totals = new long[16];
volatile int executed = 0;
private volatile boolean executed = false;
private Worker(ComputerScheduler scheduler, Task run) {
this.run = run;
@@ -102,7 +102,7 @@ public class ComputerThreadRunner implements AutoCloseable {
public void work() {
try {
run.run(executor);
executed++;
executed = true;
} catch (Throwable e) {
errorLock.lock();
try {

View File

@@ -92,6 +92,11 @@ describe("The colors library", function()
it("floors colors", function()
expect(colors.toBlit(16385)):eq("e")
end)
it("errors on out-of-range colours", function()
expect.error(colors.toBlit, -120):eq("Colour out of range")
expect.error(colors.toBlit, 0x10000):eq("Colour out of range")
end)
end)
describe("colors.fromBlit", function()

View File

@@ -58,7 +58,14 @@ describe("The window library", function()
w.setTextColour(colors.white)
expect.error(w.setTextColour, nil):eq("bad argument #1 (number expected, got nil)")
expect.error(w.setTextColour, -5):eq("Invalid color (got -5)")
expect.error(w.setTextColour, -5):eq("Colour out of range")
end)
it("supports invalid combined colours", function()
local w = mk()
w.setTextColour(colours.combine(colours.red, colours.green))
expect(w.getTextColour()):eq(colours.red)
end)
end)
@@ -69,7 +76,7 @@ describe("The window library", function()
w.setPaletteColour(colors.white, 0x000000)
expect.error(w.setPaletteColour, nil):eq("bad argument #1 (number expected, got nil)")
expect.error(w.setPaletteColour, -5):eq("Invalid color (got -5)")
expect.error(w.setPaletteColour, -5):eq("Colour out of range")
expect.error(w.setPaletteColour, colors.white):eq("bad argument #2 (number expected, got nil)")
expect.error(w.setPaletteColour, colors.white, 1, false):eq("bad argument #3 (number expected, got boolean)")
expect.error(w.setPaletteColour, colors.white, 1, nil, 1):eq("bad argument #3 (number expected, got nil)")
@@ -82,7 +89,7 @@ describe("The window library", function()
local w = mk()
w.getPaletteColour(colors.white)
expect.error(w.getPaletteColour, nil):eq("bad argument #1 (number expected, got nil)")
expect.error(w.getPaletteColour, -5):eq("Invalid color (got -5)")
expect.error(w.getPaletteColour, -5):eq("Colour out of range")
end)
end)
@@ -92,7 +99,7 @@ describe("The window library", function()
w.setBackgroundColour(colors.white)
expect.error(w.setBackgroundColour, nil):eq("bad argument #1 (number expected, got nil)")
expect.error(w.setBackgroundColour, -5):eq("Invalid color (got -5)")
expect.error(w.setBackgroundColour, -5):eq("Colour out of range")
end)
end)

View File

@@ -6,20 +6,37 @@ package dan200.computercraft.mixin;
import dan200.computercraft.shared.CommonHooks;
import net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket;
import net.minecraft.server.level.ChunkHolder;
import net.minecraft.server.level.ChunkMap;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.chunk.LevelChunk;
import org.apache.commons.lang3.mutable.MutableObject;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import javax.annotation.Nullable;
@Mixin(ChunkMap.class)
class ChunkMapMixin {
@Final
@Shadow
ServerLevel level;
@Inject(method = "playerLoadedChunk", at = @At("TAIL"))
@SuppressWarnings("UnusedMethod")
private void onPlayerLoadedChunk(ServerPlayer player, MutableObject<ClientboundLevelChunkWithLightPacket> packetCache, LevelChunk chunk, CallbackInfo callback) {
CommonHooks.onChunkWatch(chunk, player);
}
@Inject(method = "updateChunkScheduling", at = @At("HEAD"))
@SuppressWarnings("UnusedMethod")
private void onUpdateChunkScheduling(long chunkPos, int newLevel, @Nullable ChunkHolder holder, int oldLevel, CallbackInfoReturnable<ChunkHolder> callback) {
CommonHooks.onChunkTicketLevelChanged(level, chunkPos, oldLevel, newLevel);
}
}

View File

@@ -24,6 +24,7 @@ import dan200.computercraft.shared.peripheral.modem.wireless.WirelessModemBlockE
import dan200.computercraft.shared.platform.FabricConfigFile;
import dan200.computercraft.shared.platform.FabricMessageType;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerChunkEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
@@ -96,6 +97,7 @@ public class ComputerCraft {
ServerTickEvents.START_SERVER_TICK.register(CommonHooks::onServerTickStart);
ServerTickEvents.START_SERVER_TICK.register(s -> CommonHooks.onServerTickEnd());
ServerChunkEvents.CHUNK_UNLOAD.register((l, c) -> CommonHooks.onServerChunkUnload(c));
PlayerBlockBreakEvents.BEFORE.register(FabricCommonHooks::onBlockDestroy);
UseBlockCallback.EVENT.register(FabricCommonHooks::useOnBlock);

View File

@@ -22,11 +22,15 @@ import dan200.computercraft.shared.turtle.blocks.TurtleBlockEntity;
import dan200.computercraft.shared.util.CapabilityProvider;
import dan200.computercraft.shared.util.SidedCapabilityProvider;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.CommandBlockEntity;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraftforge.event.*;
import net.minecraftforge.event.entity.EntityJoinLevelEvent;
import net.minecraftforge.event.entity.living.LivingDropsEvent;
import net.minecraftforge.event.level.ChunkEvent;
import net.minecraftforge.event.level.ChunkTicketLevelUpdatedEvent;
import net.minecraftforge.event.level.ChunkWatchEvent;
import net.minecraftforge.event.server.ServerStartingEvent;
import net.minecraftforge.event.server.ServerStoppedEvent;
@@ -67,11 +71,23 @@ public class ForgeCommonHooks {
CommandComputerCraft.register(event.getDispatcher());
}
@SubscribeEvent
public static void onChunkUnload(ChunkEvent.Unload event) {
if (event.getLevel() instanceof ServerLevel && event.getChunk() instanceof LevelChunk chunk) {
CommonHooks.onServerChunkUnload(chunk);
}
}
@SubscribeEvent
public static void onChunkWatch(ChunkWatchEvent.Watch event) {
CommonHooks.onChunkWatch(event.getChunk(), event.getPlayer());
}
@SubscribeEvent
public static void onChunkTicketLevelChanged(ChunkTicketLevelUpdatedEvent event) {
CommonHooks.onChunkTicketLevelChanged(event.getLevel(), event.getChunkPos(), event.getOldTicketLevel(), event.getNewTicketLevel());
}
@SubscribeEvent
public static void onAddReloadListeners(AddReloadListenerEvent event) {
CommonHooks.onDatapackReload((id, listener) -> event.addListener(listener));

View File

@@ -30,6 +30,7 @@ import org.teavm.jso.typedarrays.ArrayBuffer;
import javax.annotation.Nullable;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* Manages the core lifecycle of an emulated {@link Computer}.
@@ -47,6 +48,9 @@ class EmulatedComputer implements ComputerEnvironment, ComputerHandle {
private boolean disposed = false;
private final MemoryMount mount = new MemoryMount();
private @Nullable String oldLabel;
private boolean oldOn;
EmulatedComputer(ComputerContext context, ComputerDisplay computerAccess) {
this.computerAccess = computerAccess;
this.computer = new Computer(context, this, terminal, 0);
@@ -68,8 +72,10 @@ class EmulatedComputer implements ComputerEnvironment, ComputerHandle {
LOG.error("Error when ticking computer", e);
}
if (computer.pollAndResetChanged()) {
computerAccess.setState(computer.getLabel(), computer.isOn());
var newLabel = computer.getLabel();
var newOn = computer.isOn();
if (!Objects.equals(oldLabel, newLabel) || oldOn != newOn) {
computerAccess.setState(oldLabel = newLabel, oldOn = newOn);
}
for (var side : SIDES) {