Skip to content

Canvas API

The core Canvas team has done extensive research and dived deep into Folias internals in an attempt to understand the API Folia has broken, why it’s broken, and how to fix it. This has lead to the documented API fixes provided in this page. Here you will learn the API changes Canvas has made to the dedicated server to provide further support for region threading in existing and new plugins.

To get started, you need to add the Canvas API to your build script, like so:

repositories {
maven {
name = "canvasmc-repo"
url = "https://maven.canvasmc.io/snapshots"
}
}
dependencies {
compileOnly "io.canvasmc:canvas-api:<version>"
}
repositories {
maven {
name = "canvasmc-repo"
url = uri("https://maven.canvasmc.io/snapshots")
}
}
dependencies {
compileOnly("io.canvasmc:canvas-api:<version>")
}
<repositories>
<repository>
<id>canvasmc-repo</id>
<url>https://maven.canvasmc.io/snapshots</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>io.canvasmc</groupId>
<artifactId>canvas-api</artifactId>
<version>VERSION</version>
</dependency>
</dependencies>

Folia breaks a few very important events with its rewrites of some Vanilla systems like login, teleport, etc. As such, Canvas provides new API to replace the old API, since simply adding back the old API actually can create a lot of issues with plugins that support both Folia and Paper.

Some plugins, like one of the Chunky addons for example(stating because this is where the problem was initally found), still listen to the teleport API events at the time of the issue, while supporting both Folia and Paper. Those teleport events don’t function on Folia, and as such weren’t updated to support region threading properly, thus creating a compatibility layer. To combat this, the Canvas team decided to steer towards adding our own API, instead of trying to fix the old API, ensuring plugins that are compatible with Folia stay compatible on Canvas, with the option of implementing directly into Canvas for further enhancements and hooks if required.

Canvas adds a few events for region threading:

The Post events are always called immediately after the entity being teleported/portaled is placed into the new region. It is guarenteed that all events provided by Canvas are run on the owning context of the entity.

Folia requires a few states before being allowed to teleport or portal. Plugins utilizing these events should never modify one of these states during the pre events, as this will result in Canvas throwing a TeleportValidationException. These states are:

Teleport
  • The teleport position is in spawnable bounds. This means that the teleport is within -30mil/30mil on the X and Z axis, and -20mil/20mil on the Y axis
  • If the TELEPORT_FLAG_UNMOUNT(1L << 2) flag is present, it will unmount the entity during checks, otherwise, it will validate each passenger entity, and they will need to pass these checks too
  • If the TELEPORT_FLAG_TELEPORT_PASSENGERS(1L << 1) flag is present, the entity can teleport if it is not a passenger, but it can be a vehicle. If not set, it both cannot be a passenger or vehicle
Same
  • The destination world does not have an unload ticket present
  • The entity is not removed
  • The entity is alive
  • If the entity instanceof LivingEntity, the living entity is not sleeping
Portaling
  • If it should consider passengers and if it should run the passenger check and the entity is a passenger, the portal attempt fails
  • If it shouldn't consider passengers, the entity must not be a vehicle, and if it shouldn't skip the passenger check, the entity must not be a passenger
  • If it should consider passengers, all passenger entities must recursively pass the same portaling checks

Canvas throws this exception because if the event calls, then these states have already been verified. If the state changes unexpectedly after calling, the entity no longer is valid for teleport, so we shouldn’t teleport and should immediately throw to prevent unsafe operations.

Plugins should never modify the entity state in the EntityTeleportAsyncEvent, EntityPortalAsyncEvent, or PlayerViewEndCreditsEvent events. In the “post” events, plugins are allowed to modify the state of the entity in that manner, since this is immediately after the placement for the entity is called.

Another API Folia breaks and outright removes is runtime world loading and unloading. This API allows the creation and unloading of worlds while the server is running. This was disabled due to a lot of complications with them like how to unload worlds effectively with there being region threading, and how to create worlds correctly.

Canvas fixes and restores this API, while also providing a replacement API for unloading.

Fixing world loading is actually relatively simple. World creation is already something that is technically region-threading safe with little to no modifications. Before 1.21.11, Folia required loading a certain area of chunks to run spawn selection, however this is no longer required post 1.21.11.

World unloading is extremely complex in comparison to fixing world loading. This required a full rewrite and Canvas provides a new API for this, since the old API was not something we could entirely keep compatible with. The new API returns void, and now also requires a callback argument passed.

The callback provides an enum WorldUnloadResult, which says if it succeded or not, and if not, then why.

The original Bukkit API version of unloading has a few requirements for the world before being allowed to unload:

  • The world is loaded and present in the server
  • The world is not the overworld
  • The world has no players
  • The WorldUnloadEvent is not canceled

For Folia, this is relatively the same except for checking that the world has no players.

With region threading, any operation involving entities moving between regions, or being placed in regions, is rewritten to some degree. For players, this means players can be removed from a world at any time, even if they are just teleporting between 2 regions on the same world. If we just simply check the way Bukkit does, we actually can cause race conditions with players being mid-teleport on same-world teleports, or players logging into the server. If say, the world is unloaded while a player is attempting to teleport between regions, or a player is joining the server, the player could be stuck in limbo until the server restarts. As an example, when the player is “removed” from the world for teleporting, they are removed from the list players in ServerLevel, which in Bukkit, is what is ensured empty before allowing the unload. This creates a lot of room for issues and race conditions causing players to be stuck in a state where they cannot even leave the server properly.

To combat this, Canvas changes how we check for teleporting players, and joining players, to ensure the unload preconditions are accurate and players remain in a consistent and correct state.

For teleporting, Canvas checks every player on the server and checks if their current world is the same as the one we are attempting to unload. This fixes cross-region teleports on the world we are trying to unload, since if the player is mid-teleport, they will be “removed” from the world, but still have the world reference in the player object, so checking that is the most accurate way to check if there are players actively present in the world, whether they are placed in a region already, or currently in-flight to another region.

For joining, Canvas adds a new “world stage lock”, which is used to synchronize players deciding the world they are joining in to, and world unload calls. We then increment/decrement a value in the ServerLevel object to track how many players are currently in-flight to joining the world. This way, we can check during unload calls that there are no actively joining players. The fallback for joining if it is unloading though is always the overworld, matching Vanilla for when a world is unknown/not present in the server for any reason.

The unload process is a bit lengthy, but is made this way to ensure the optimal performance and setup for region-threading.

  1. Once all preconditions are passed, the “unload ticket” is propagated to the world. At the start of each region tick, this ticket is checked to see if it is present. If the ticket is present, the region saves and begins to unload itself. The reason we do it this way is because a region can be created for any variety of reasons, and if we just run on all the regions present in the unload call, we may have edge cases where a region just never gets properly unloaded. This also allows us to ensure we properly shut down and unload the world.

  2. Each region follows a very similar process to the shutdown thread for individual regions. First, we check for pending teleports and complete the pending teleports for entities, but add them to the world as if the server is shutting down. If there is a player present during this, we can guarentee it is most likely due to portaling or cross-dimensional teleporting, since same-dimension cross-region teleports are already covered by the preconditions.

  3. After pending teleports are completed, we check the local players to ensure that there are none, and if there are any, we send them back to their previous position. The players can only enter here via teleporting cross-dimensionally or portaling, meaning we can send them back to their original teleport position to ensure the player doesn’t get stuck in limbo.

  4. After pending teleports and cleaning up players, we save all chunks in the region, and then we deschedule the current region from the server, so it will no longer process any further ticks.

  5. On the global tick, if the world unload ticket is present, during the entire unload process it will not process the global tick for that world.

  6. Once all regions are descheduled, the global tick handles cleanup, where it finalizes the world save, closes the chunk manager, halts the chunk system executors for that world, closes the level storage access, and then removes the world from the Bukkit worlds, regionized server worlds, and Minecraft levels map

  7. The unload callback is executed for reason SUCCESS if all goes correctly.

Folia uses an internal API it created called RegionizedData. This API is what helps manage localized world data per-region. With Canvas, plugins are capable of interfacing into this API, creating their own regionized data implementations per region.

The interface provided by Canvas for this is called IRegionizedData<T>, which represents a type of region-local data that can be fetched or attached to a region through a few different ways. But first, we need to actually dive into how to create this data.

You can create this data at any point in the server lifecycle. The IRegionizedData interface takes a generic type, T, for the type of object it is. You can create a new regionized data instance via Server#createRegionizedData. Here is an example of how to make a basic IRegionizedData<T> implementation:

static final RegionTickData.IRegionizedData<TestRegionData> EXAMPLE_REGION_DATA = Bukkit.getServer().createRegionizedData(
(tickData, world) -> new TestRegionData(tickData, world), new RegionTickData.IRegionizedData.IRegionizedCallback<>() {
@Override
public void merge(final TestRegionData from, final TestRegionData into, final long fromTickOffset) {
}
@Override
public void split(final TestRegionData from, final int chunkToRegionShift, final Long2ReferenceOpenHashMap<TestRegionData> regionToData, final ReferenceOpenHashSet<TestRegionData> dataSet) {
}
}
);

The createRegionizedData method takes 2 arguments. The first, a bi-function constructor that takes the region tick data that the instance is being created for, and the world it belongs to, and returns a new object of the generic type T. The second argument, is the regionized callback. The regionized callback is an interface that defines the logic for merging and splitting region data when a region splits or merges. On region creation, the constructor is called, and on region death(from unload or something), nothing happens.

The IRegionizedCallback is the interface that is invoked on region split and merge operations that helps to manage regionized data transferring between regions.

Merging is handled via the merge method, which provides the T from, T into, and long fromTickOffset. During this operation, all data from one region should be merged into the other region, combining the two regions. All data should be combined to the into argument, as the from region is dead now, and is descheduled. The fromTickOffset is the addend to absolute tick deadlines stored in the from region to adjust to the into region.

Each region ticks independently, so absolute tick deadlines can’t be compared directly across regions. To convert a deadline from one region to another, we need to account for the difference in their current ticks.

Say we have an absolute deadline, deadlineFrom, in region from, and we want the equivalent deadline in region into. The key here is that the relative deadline(how many ticks remain) should be the same in both regions.

Starting from that idea:

  1. The relative deadline in from is deadlineFrom - currentTickFrom (ticks remaining)
  2. To get the equivalent absolute deadline in into, we add those remaining ticks to intos current tick: deadlineTo = (deadlineFrom - currentTickFrom) + currentTickTo
  3. So, deadlineTo = deadlineFrom + (currentTickTo - currentTickFrom)

The term (currentTickTo - currentTickFrom) is constant for a given pair of regions at a given moment, so we can precompute it as fromTickOffset and simply write:

deadlineTo = deadlineFrom + fromTickOffset

This is used internally for things that store a tick-based deadline, and should be used as described above to maintain consistency of regionized data when conducting merge operations.

Splitting is hanlded via the split method, which provides T from, int chunkToRegionShift, L2RMap regionToData, and RefSet dataSet. In this operation, we are splitting the T from, into the ReferenceOpenHashSet dataSet.

Folia provides us with two utilities to help split this data, the int chunkToRegionShift, and the Long2ReferenceOpenHashMap regionToData. The chunkToRegionShift is the signed right-shift value used to convert chunk coordinates into region section coordinates.

The regiontoData map is a map of region section coordinates to the data that is owned by the region which occupies the region section. Note that all sections are strictly only in the region from the from region, meaning attempting to fetch data from another region not part of the split via this map will result in null returned. This is strictly containing owned sections from the original region to the new split regions. You can utilize these for data that is location-based, like entities and block entities for example.

You can utilize them like so, using an Entity as an example:

Entity entity = ...;
Chunk chunkAt = entity.getChunk();
long sectionKey = getRegionSectionCoordinates(chunkAt.getX(), chunkAt.getZ(), chunkToRegionShift);
T dataAtEntityPosition = regionToData.get(sectionKey);

Unlike merging, there is no absolute tick offset provided. This is because the new regions formed from the split will start at the same tick number, and so no adjustment is required.

To fetch regionized data, it is always recommended to fetch this on the local region. Regionized data can be fetched from other threads that don’t currently own the region, but this is largely discouraged as this defeats the point of regionized threading, and can cause corruption and concurrency issues depending on what you are trying to do. To fetch the current T from an IRegionizedData instance, you can call:

T localData = Bukkit.getServer().getLocalRegionizedData(EXAMPLE_REGION_DATA);

This will throw an IllegalStateException if called on a thread that doesn’t own a region. This could be because the current thread isn’t a region tick runner, or the current thread is processing the global tick.

Paper provides an API for Folia that introduces tick thread checking to see if an object or location is owned by the current ticking region, or if the current thread is ticking the global tick thread. This can assist with scheduling things and ensuring that all operations are executed on the proper scheduling context. The available tick thread checks are:

  • isOwnedByCurrentRegion(World, Position) — Returns if the current tick handle owns the position in the provided world
  • isOwnedByCurrentRegion(World, Position, radius in chunks) — Returns if the current tick handle owns the position in the provided world and the surrounding chunks in a provided radius
  • isOwnedByCurrentRegion(Location) — Returns if the current tick handle owns the location provided
  • isOwnedByCurrentRegion(Location, radius in chunks) — Returns if the current tick handle owns the location provided and the surrounding chunks in a provided radius
  • isOwnedByCurrentRegion(Block) — Returns if the current tick handle owns the position of the block provided
  • isOwnedByCurrentRegion(World, chunk x, chunk z) — Returns if the current tick handle owns the chunk coords in the provided world
  • isOwnedByCurrentRegion(World, chunk x, chunk z, radius in chunks) — Returns if the current tick handle owns the chunk coords in the provided world and the surrounding chunks in a provided radius
  • isOwnedByCurrentRegion(World, from chunk x, from chunk z, to chunk x, to chunk z) — Returns if the current tick handle owns the area of chunks specified in the provided world
  • isOwnedByCurrentRegion(Entity) — Returns if the current tick handle owns the entity provided
  • isOwnedByCurrentRegion(World, BoundingBox)Canvas exclusive — Returns if the current tick handle owns the specified bounding box in the world provided
  • isOwnedByCurrentRegion(Location, Vector, chunk buffer)Canvas exclusive — Returns if the current tick handle owns the specfied location, adjusted by the provided velocity with a provided chunk radius buffer
  • isGlobalTickThread — Returns if the current tick handle is the global tick

It is seen time and time again that plugins are incorrectly utilizing the provided schedulers for Folia, so this section is to clarify exactly what each scheduler should be used for.

  • GlobalRegionScheduler — This is strictly for things that are not tied to a specific location, and are server-wide or world-wide. Things like time, weather, tick rate changing, raids, world border, etc, should all be scheduled here. Anything that is tied to a location or entity should never be scheduled here.

  • EntityScheduler — This is for scheduling things specifically tied to an entity. Not locations, not global operations, entities. The entity state can change for any number of reasons, and as such, Paper/Folia provides the entity scheduler so that you can schedule things to the entity specifically, and it will handle states and edge cases with those states like teleporting, being removed, etc. Anything location-specific should not be scheduled here, as the entity position can change after scheduling for any reason. Things like changing the entity health or setting it on fire should be scheduled here.

  • RegionScheduler — This is for scheduling things specifically tied to a location in a world. Things like dropping items at a specified location and such should be scheduled here. This should never be used for global operations. This should not be used for entity-specific or global operations.