maps icon indicating copy to clipboard operation
maps copied to clipboard

ClientsideMap questions

Open twseer67875 opened this issue 1 year ago • 26 comments

Feature description

I found that in the Quickstart example, it is necessary to have a valid MapID in order to create a ClientsideMap. Is it possible to create a ClientsideMap without using a valid MapID? I don't want to require players to first create any files in the "./world/data" directory using the original method. Can alternative methods, such as custom NBTTag or other approaches, be provided to determine the ClientsideMap?

Relevant issues

No response

twseer67875 avatar Jun 25 '23 10:06 twseer67875

Hello! You do not need a valid map id. The quickstart example just happens to use a valid id, but you can also use invalid ones.

cerus avatar Jun 25 '23 10:06 cerus

Hello! You do not need a valid map id. The quickstart example just happens to use a valid id, but you can also use invalid ones.

Thank you for your response, but I still have another question. Regarding the Maven tutorial provided in the WiKi, it seems that I am unable to successfully import maps into my project. It appears that there is no relevant repository available. Should I clone the project and compile it in order to use it?

https://github.com/cerus/maps/wiki/Build-Tool-Setup:-Maven image

twseer67875 avatar Jun 25 '23 11:06 twseer67875

Hello! You do not need a valid map id. The quickstart example just happens to use a valid id, but you can also use invalid ones.

I encountered a problem when creating a map. I'm not sure why, but when I execute the following code, the original marker does not disappear. Is there no way to remove the original marker without relying solely on packets?

package com.cocobeen.Commands;

import dev.cerus.maps.api.ClientsideMap;
import dev.cerus.maps.api.graphics.ClientsideMapGraphics;
import dev.cerus.maps.api.graphics.ColorCache;
import dev.cerus.maps.version.VersionAdapterFactory;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;

public class TestCommand implements CommandExecutor {
   @Override
   public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] strings) {
       ClientsideMap map = new ClientsideMap(3);
       ClientsideMapGraphics graphics = new ClientsideMapGraphics();

       graphics.fillComplete(ColorCache.rgbToMap(255, 255, 255));
       graphics.fillRect(5, 5, 118, 118, ColorCache.rgbToMap(255, 0, 0), 1f);


       map.clearMarkers();
       map.draw(graphics);
       map.sendTo(new VersionAdapterFactory().makeAdapter(), true, (Player) commandSender);
       return true;
   }
}

https://github.com/cerus/maps/assets/70073452/e0403e9a-ee14-40c1-8183-0bfab32413c2

twseer67875 avatar Jun 25 '23 14:06 twseer67875

Hello! You do not need a valid map id. The quickstart example just happens to use a valid id, but you can also use invalid ones.

Furthermore, when using the following code to create an illegal MapID and following the instructions in the Wiki, it seems to have no effect. The map doesn't show any changes.

package com.cocobeen.Commands;

import dev.cerus.maps.api.ClientsideMap;
import dev.cerus.maps.api.graphics.ClientsideMapGraphics;
import dev.cerus.maps.api.graphics.ColorCache;
import dev.cerus.maps.version.VersionAdapterFactory;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;

public class TestCommand implements CommandExecutor {
    @Override
    public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] strings) {
        ClientsideMap map = new ClientsideMap();
        ClientsideMapGraphics graphics = new ClientsideMapGraphics();

        graphics.fillComplete(ColorCache.rgbToMap(255, 255, 255));
        graphics.fillRect(5, 5, 118, 118, ColorCache.rgbToMap(255, 0, 0), 1f);


        map.clearMarkers();
        map.draw(graphics);
        map.sendTo(new VersionAdapterFactory().makeAdapter(), true, (Player) commandSender);
        return true;
    }
}

https://github.com/cerus/maps/assets/70073452/610241e7-f380-4d09-9bb0-33812e8d6176

twseer67875 avatar Jun 25 '23 14:06 twseer67875

I encountered a problem when creating a map. I'm not sure why, but when I execute the following code, the original marker does not disappear. Is there no way to remove the original marker without relying solely on packets?

If you look closely you can see that the markers disappear, but the server updates real maps every tick, so they appear again one tick later.

Furthermore, when using the following code to create an illegal MapID and following the instructions in the Wiki, it seems to have no effect. The map doesn't show any changes.

The map item in your hand has a certain map id that it's displaying. You need to change the items map id to the id of the fake map.

cerus avatar Jun 25 '23 22:06 cerus

... it seems that I am unable to successfully import maps into my project. It appears that there is no relevant repository available. Should I clone the project and compile it in order to use it?

Looks like I forgot to deplay version 3.7.1, use 3.7.0 as a workaround

cerus avatar Jun 25 '23 22:06 cerus

I encountered a problem when creating a map. I'm not sure why, but when I execute the following code, the original marker does not disappear. Is there no way to remove the original marker without relying solely on packets?

If you look closely you can see that the markers disappear, but the server updates real maps every tick, so they appear again one tick later.

Furthermore, when using the following code to create an illegal MapID and following the instructions in the Wiki, it seems to have no effect. The map doesn't show any changes.

The map item in your hand has a certain map id that it's displaying. You need to change the items map id to the id of the fake map.

So, regardless of whether the MapID is legal or illegal, is it necessary to provide a MapID to the ClientsideMap object in order to use it properly? Because I tried modifying the 'map' tag in the NBTTag of a map to a MapID that has not been created in Minecraft, and it only works properly when I provide that MapID to the ClientsideMap. However, if I don't define a MapID within the ClientsideMap, even if the map is invalid, it cannot be used properly.

twseer67875 avatar Jun 26 '23 03:06 twseer67875

If you don't explicitly provide a map id when creating a ClientsideMap it will choose an id itself.

https://github.com/cerus/maps/blob/fff816b8b4d3c20af7649035c9f762bda1952ccf/common/src/main/java/dev/cerus/maps/api/ClientsideMap.java#L26-L28

You can get the id using ClientsideMap#getId(). You need to set that id as the map id of the map item.

cerus avatar Jun 26 '23 06:06 cerus

Hey, are the issues you experienced solved? Do you have any further questions?

cerus avatar Jun 28 '23 22:06 cerus

Hey, are the issues you experienced solved? Do you have any further questions?

I apologize for taking so long to get back to you. Most of the basic functionality issues have been resolved, but the only remaining problem is that Maven still doesn't have the new version 3.7.1. I need the plugin to work on version 1.20.1, so it would be great if you could update it for me. Thank you so much for your assistance.

twseer67875 avatar Jul 15 '23 04:07 twseer67875

Sorry about that, should be fixed now

cerus avatar Jul 15 '23 10:07 cerus

I encountered a strange issue where the player seems unable to display the map correctly when I quickly send more than around 50 map data packets to them at once. Is there a way to provide a method to send a large number of map data packets at once? image image

twseer67875 avatar Jul 16 '23 14:07 twseer67875

I'm pretty sure that's caused by some sort of bug in your code, I've never had any issues with sending lots of data packets. If you're willing to share the relevant code I might be able to help you figure this out.

cerus avatar Jul 16 '23 14:07 cerus

CrossServerMap.java

package com.cocobeen;

import com.cocobeen.Commands.MapSaveCommand;
import com.cocobeen.Commands.MapTransferCommand;
import com.cocobeen.Listener.*;
import com.cocobeen.Utils.ImageData;
import com.cocobeen.Utils.MapGraphicsDrawUtils;
import com.cocobeen.Utils.SerializationUtils;
import com.cocobeen.Utils.struct.ChunkCache;
import com.cocobeen.Utils.struct.MapCache;
import de.tr7zw.nbtapi.NBTItem;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Material;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.GlowItemFrame;
import org.bukkit.entity.ItemFrame;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.java.JavaPlugin;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

public final class CrossServerMap extends JavaPlugin {
    private static CrossServerMap plugin = null;
    private static MapDataDAO dataDAO = null;
    private static byte[] banMapData = null;
    private static ConcurrentLinkedDeque<MapCache> cache = new ConcurrentLinkedDeque<>();
    private static ConcurrentLinkedDeque<Chunk> chunk_cache = new ConcurrentLinkedDeque<>();
    private static ConcurrentHashMap<UUID, Set<Integer>> playerCache = new ConcurrentHashMap<>();
    private static ConcurrentLinkedDeque<ChunkCache> draw_caches = new ConcurrentLinkedDeque<>();


    @Override
    public void onEnable() {
        plugin = this;

        getConfig().options().copyDefaults();
        saveDefaultConfig();

        initDatabase();
        initCommands();
        initListener();

        MapGraphicsDrawTask();
        DrawTask();
        ClearCacheTask();
    }

    @Override
    public void onDisable() {
        getServer().getScheduler().cancelTasks(this);
        banMapData = null;
        cache.clear();
        chunk_cache.clear();
        playerCache.clear();
        dataDAO.closeConnection();
    }

    public static CrossServerMap getPlugin() {
        return plugin;
    }

    public static MapDataDAO getDataDAO() {
        return dataDAO;
    }

    public static byte[] getBanMapData() {
        return banMapData;
    }

    public static ConcurrentLinkedDeque<MapCache> getCache(){
        return cache;
    }

    public static ConcurrentLinkedDeque<Chunk> getChunkCache(){
        return chunk_cache;
    }

    public static ConcurrentHashMap<UUID, Set<Integer>> getPlayerCache() {
        return playerCache;
    }

    public static void setCache(ConcurrentLinkedDeque<MapCache> MapCache){
        cache = MapCache;
    }

    public static void setChunkCache(ConcurrentLinkedDeque<Chunk> chunkCache){
        chunk_cache = chunkCache;
    }

    private void initListener(){
        if (getServer().getPluginManager().getPlugin("MysqlPlayerDataBridge") != null){
            getLogger().info("MysqlPlayerDataBridge Hook!");
            getServer().getPluginManager().registerEvents(new MPDBSyncCompleteListener(), this);
        }
        else {
            getServer().getPluginManager().registerEvents(new PlayerJoinListener(), this);
            getLogger().info("MysqlPlayerDataBridge not found");
        }

        getServer().getPluginManager().registerEvents(new PlayerItemHeldListener(), this);
        getServer().getPluginManager().registerEvents(new PlayerChunkLoadListener(), this);
        getServer().getPluginManager().registerEvents(new PrepareItemCraftListener(), this);
        getServer().getPluginManager().registerEvents(new InventoryClickListener(), this);
        getServer().getPluginManager().registerEvents(new PlayerQuitListener(), this);
    }

    private void initCommands(){
        getServer().getPluginCommand("mapsave").setExecutor(new MapSaveCommand());
        getServer().getPluginCommand("maptransfer").setExecutor(new MapTransferCommand());

        getServer().getPluginCommand("mapsave").setTabCompleter(new MapSaveCommand());
        getServer().getPluginCommand("maptransfer").setTabCompleter(new MapTransferCommand());
    }

    private void initDatabase(){
        final String user = getConfig().getString("mysql.user");
        final String password = getConfig().getString("mysql.password");
        final String host = getConfig().getString("mysql.host");
        final int port = getConfig().getInt("mysql.port");
        final String database = getConfig().getString("mysql.database");

        Runnable task = () -> {
            dataDAO = new MapDataDAO(host, port, database, user, password);
            dataDAO.createTable();

            ImageData data = new ImageData();
            banMapData = data.getColors();
        };
        Bukkit.getScheduler().runTaskAsynchronously(this, task);
    }

    private void DrawTask(){
        Runnable task = () -> {
            synchronized (draw_caches){
                if (draw_caches.size() == 0){
                    return;
                }

                List<ChunkCache> elements = draw_caches.stream()
                        .limit(5)
                        .collect(Collectors.toList());

                for (ChunkCache chunkCache : elements){
                    byte[] color = chunkCache.getColor();
                    int mapId = chunkCache.getMapID();
                    draw_caches.remove(chunkCache);
                    MapGraphicsDrawUtils.MainThreadMapGraphicsDraw(color, mapId);
                }
            }
        };
        Bukkit.getScheduler().runTaskTimerAsynchronously(this, task, 3, 3);
    }


    private void MapGraphicsDrawTask(){
        Runnable task = () -> {
            ConcurrentLinkedDeque<Chunk> chunks = null;

            synchronized (chunk_cache){
                chunks = new ConcurrentLinkedDeque<>(chunk_cache);
                chunk_cache.clear();
            }

            for (Chunk chunk : chunks){
                Entity[] entities = chunk.getEntities();

                Set<ItemFrame> itemFrames = new HashSet<>();
                Set<GlowItemFrame> glowItemFrames = new HashSet<>();

                for (Entity entity : entities){
                    EntityType type = entity.getType();
                    switch (type){
                        case ITEM_FRAME:{
                            itemFrames.add((ItemFrame) entity);
                            break;
                        }
                        case GLOW_ITEM_FRAME:{
                            glowItemFrames.add((GlowItemFrame) entity);
                            break;
                        }
                    }

                }

                for (ItemFrame itemFrame : itemFrames){
                    ItemStack itemStack = itemFrame.getItem();

                    if (itemStack.getType().equals(Material.FILLED_MAP)){
                        ChunkCache chunkCache = initMapGraphicsDraw(itemStack);
                        if (chunkCache != null){
                            synchronized (draw_caches){
                                draw_caches.add(chunkCache);
                            }
                        }
                    }
                }

                for (GlowItemFrame glowItemFrame : glowItemFrames){
                    ItemStack itemStack = glowItemFrame.getItem();

                    if (itemStack.getType().equals(Material.FILLED_MAP)){
                        ChunkCache chunkCache = initMapGraphicsDraw(itemStack);
                        if (chunkCache != null){
                            synchronized (draw_caches){
                                draw_caches.add(chunkCache);
                            }
                        }
                    }
                }
            }
        };
        Bukkit.getScheduler().runTaskTimerAsynchronously(this, task, 10, 10);
    }

    private ChunkCache initMapGraphicsDraw(ItemStack itemStack){
        NBTItem nbtItem = new NBTItem(itemStack);

        if (nbtItem.hasTag("CrossServerMap_Owner")){

            UUID OwnerUUID = nbtItem.getUUID("CrossServerMap_Owner");
            int mapID = nbtItem.getInteger("map");

            synchronized (cache){
                for (MapCache mapCache : cache){
                    if (mapCache.getMapID() == mapID){
                        byte[] color = mapCache.getColor();
                        return new ChunkCache(color, mapID);
                    }
                }

                boolean isBan = dataDAO.readMapBanState(mapID);

                if (isBan){
                    MapGraphicsDrawUtils.MapGraphicsDraw(banMapData, mapID);
                    cache.addLast(new MapCache(OwnerUUID, banMapData, mapID));
                    return new ChunkCache(banMapData, mapID);
                }

                String data = dataDAO.readMapData(mapID);

                if (data == null){
                    getLogger().warning("MapID " + mapID + " not found!");
                    getLogger().warning("This problem is very serious! Please check the database information " +
                            "and plugin settings immediately!");
                    return new ChunkCache(banMapData, mapID);
                }

                byte[] color = SerializationUtils.DeserializeMapData(data);

                if (cache.size() > 1000){
                    plugin.getLogger().info("MapCaches is full! Remove caches head data");
                    cache.poll();
                }

                cache.addLast(new MapCache(OwnerUUID, color, mapID));

                return new ChunkCache(color, mapID);
            }
        }
        return null;
    }

    private void ClearCacheTask(){
        Runnable task = () -> {
            long start = System.nanoTime();
            getLogger().info("Clear expired cache data...");
            synchronized (cache){
                ConcurrentLinkedDeque<MapCache> caches = new ConcurrentLinkedDeque<>(cache);
                int count = 0;
                LocalDateTime now = LocalDateTime.now();
                for (MapCache mapCache : caches){
                    LocalDateTime time = mapCache.getTime();

                    Duration duration = Duration.between(time, now);

                    if (duration.compareTo(Duration.ofMinutes(10)) > 0){
                        count++;
                        cache.remove(mapCache);
                    }
                }
                long end = System.nanoTime();
                long countTime = TimeUnit.MICROSECONDS.convert((end - start), TimeUnit.NANOSECONDS);
                getLogger().info("There are " + cache.size() + " records in the current cache.");
                getLogger().info("Successfully cleared " + count + " expired cache data and took " + countTime + " ms.");
            }
        };
        Bukkit.getScheduler().runTaskTimerAsynchronously(this, task, 1200, 1200);
    }
}

PlayerChunkLoadListener.java

package com.cocobeen.Listener;

import com.cocobeen.CrossServerMap;
import io.papermc.paper.event.packet.PlayerChunkLoadEvent;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;

import java.util.concurrent.ConcurrentLinkedDeque;

public class PlayerChunkLoadListener implements Listener {
    private final CrossServerMap plugin = CrossServerMap.getPlugin();
    private final ConcurrentLinkedDeque<Chunk> chunks = CrossServerMap.getChunkCache();

    @EventHandler
    public void PlayerChunkLoad(PlayerChunkLoadEvent event){
        Chunk chunk = event.getChunk();
        if (Thread.holdsLock(chunks)){
            Runnable task = () -> {
                synchronized (chunk){
                    chunks.addLast(chunk);
                }
            };
            Bukkit.getScheduler().runTaskAsynchronously(plugin, task);
            return;
        }
        chunks.addLast(chunk);
    }
}

MapGraphicsDrawUtils.java

package com.cocobeen.Utils;

import com.cocobeen.CrossServerMap;
import dev.cerus.maps.api.ClientsideMap;
import dev.cerus.maps.api.graphics.ClientsideMapGraphics;
import dev.cerus.maps.api.version.VersionAdapter;
import dev.cerus.maps.version.VersionAdapterFactory;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;

import java.util.Set;
import java.util.UUID;

public class MapGraphicsDrawUtils {
    private static final VersionAdapter va = new VersionAdapterFactory().makeAdapter();

    public static void MapGraphicsDraw(byte[] color, int id, Player player){
        Runnable task = () -> {
            ClientsideMap map = BaseMapGraphics(color, id);
            map.sendTo(va, true, player);
        };
        Bukkit.getScheduler().runTaskAsynchronously(CrossServerMap.getPlugin(), task);
    }

    public static void MapGraphicsDraw(byte[] color, int id){
        Runnable task = () -> {
            ClientsideMap map = BaseMapGraphics(color, id);
            for (Player player : Bukkit.getServer().getOnlinePlayers()) {
                UUID uuid = player.getUniqueId();
                if (CrossServerMap.getPlayerCache().containsKey(uuid)){
                    Set<Integer> mapList = CrossServerMap.getPlayerCache().get(uuid);
                    if (!mapList.contains(id)){
                        mapList.add(id);
                        CrossServerMap.getPlayerCache().put(uuid, mapList);
                        map.sendTo(va, true, player);
                    }
                }
            }
        };
        Bukkit.getScheduler().runTaskAsynchronously(CrossServerMap.getPlugin(), task);
    }

    public static void MainThreadMapGraphicsDraw(byte[] color, int id){
        ClientsideMap map = BaseMapGraphics(color, id);
        for (Player player : Bukkit.getOnlinePlayers()) {
            UUID uuid = player.getUniqueId();
            if (CrossServerMap.getPlayerCache().containsKey(uuid)){
                Set<Integer> mapList = CrossServerMap.getPlayerCache().get(uuid);
                if (!mapList.contains(id)){
                    mapList.add(id);
                    CrossServerMap.getPlayerCache().put(uuid, mapList);
                    map.sendTo(va, player);
                }
            }
        }
    }

    private static ClientsideMap BaseMapGraphics(byte[] color, int id){
        ClientsideMap map = new ClientsideMap(id);
        ClientsideMapGraphics graphics = new ClientsideMapGraphics();

        for (int x = 0; x != 128; x++){
            for (int z = 0; z != 128; z++){
                graphics.setPixel(x, z, color[x + z * 128]);
            }
        }

        map.clearMarkers();
        map.draw(graphics);
        return map;
    }
}

twseer67875 avatar Jul 16 '23 14:07 twseer67875

I'm pretty sure that's caused by some sort of bug in your code, I've never had any issues with sending lots of data packets. If you're willing to share the relevant code I might be able to help you figure this out.

The above is the code I'm using. I'm trying to scan item frames on the map when a player reads a block, retrieve the NBT tags of the items on them, and the map ID. After that, I send map data packets to all players.

twseer67875 avatar Jul 16 '23 14:07 twseer67875

Are you 100% sure that your "color" arrays actually have colors in them? Looks like your sending a bunch of transparent maps to the player.

cerus avatar Jul 16 '23 15:07 cerus

Are you 100% sure that your "color" arrays actually have colors in them? Looks like your sending a bunch of transparent maps to the player.

Regarding this point, I am very certain that I have the correct color array because I also use other events to read the cache and draw individual maps, and there are no issues with them.

twseer67875 avatar Jul 16 '23 15:07 twseer67875

Are you 100% sure that your "color" arrays actually have colors in them? Looks like your sending a bunch of transparent maps to the player.

To troubleshoot the issue, I also inserted getLogger().info(String.valueOf(mapId)); at the location of imge1 to print all the drawn map IDs. I am very certain that it is executed correctly and the colors are confirmed to be present. However, there is a chance that the client displays a blank map. This situation is not 100% consistent, but rather random occurrences.

image image image

twseer67875 avatar Jul 16 '23 15:07 twseer67875

To debug the issue further: In your BaseMapGraphics(byte[] color, int id) method could draw something like a red rectangle in one corner. If the rectangle is visible but the map itself is transparent, there's an issue with the color array. If nothing is visible the issue is somewhere else.

    private static ClientsideMap BaseMapGraphics(byte[] color, int id){
        ClientsideMap map = new ClientsideMap(id);
        ClientsideMapGraphics graphics = new ClientsideMapGraphics();

        for (int x = 0; x != 128; x++){
            for (int z = 0; z != 128; z++){
                graphics.setPixel(x, z, color[x + z * 128]);
            }
        }

        // Draw rectangle in top left corner
        graphics.fillRect(0, 0, 8, 8, /* 18 = red */ (byte) 18, 1f);

        map.clearMarkers();
        map.draw(graphics);
        return map;
    }

cerus avatar Jul 16 '23 15:07 cerus

graphics.fillRect(0, 0, 8, 8, /* 18 = red */ (byte) 18, 1f);

It seems that the issue lies elsewhere.

image

twseer67875 avatar Jul 16 '23 15:07 twseer67875

Hm, that's weird. Maybe the client is ignoring packets to prevent getting overloaded by the server? I have honestly no idea why this is happening.

Maybe try sending fewer maps at the same time.

cerus avatar Jul 16 '23 15:07 cerus

I consider the probability of this to be relatively low because I have previously used a plugin called SyncStaticMapView, which also relied on map data packets, and it did not experience random display issues even under such high-density map display conditions. The problem arose when I migrated from that plugin and started using the maps library to display maps. The issue is severe, frequent, and occurs consistently in this scenario.

https://www.spigotmc.org/resources/syncstaticmapview-archive.96333/

twseer67875 avatar Jul 16 '23 15:07 twseer67875

One last idea: Change map.sendTo(va, player); to map.sendTo(va, true, player);. This will force maps to send the full map data.

cerus avatar Jul 16 '23 16:07 cerus

One last idea: Change map.sendTo(va, player); to map.sendTo(va, true, player);. This will force maps to send the full map data.

The problem is still image

twseer67875 avatar Jul 16 '23 16:07 twseer67875

Very weird. Sorry, no idea what's happening.

cerus avatar Jul 16 '23 17:07 cerus

Very weird. Sorry, no idea what's happening.

I have temporarily resolved the issue by repeatedly sending map data to the players. I will continue to update this location if any further issues are discovered.

twseer67875 avatar Jul 17 '23 09:07 twseer67875