PlayPro / CoreProtect

CoreProtect is a blazing fast data logging and anti-griefing tool for Minecraft servers.
Artistic License 2.0
645 stars 323 forks source link

Coreprotect is not rolling back the data of Custom Heads correctly #570

Open IasonKalaitzakis opened 3 months ago

IasonKalaitzakis commented 3 months ago

Custom Heads generated by the MoreMobHeads plugin or from this site are not being rollbacked correctly, and turn into Steve heads. Was told by the dev of the plugin to raise the issue here.

Error in console when rolling back a custom head:

[18:07:31 INFO]: jaskal97 issued server command: /co rb r:1 t:1minute a:-block
[18:07:32 WARN]: java.lang.NullPointerException: Cannot invoke "com.mojang.authlib.GameProfile.getName()" because "gameProfile" is null
[18:07:32 WARN]:        at net.minecraft.world.item.component.ResolvableProfile.<init>(ResolvableProfile.java:46)
[18:07:32 WARN]:        at org.bukkit.craftbukkit.block.CraftSkull.applyTo(CraftSkull.java:206)
[18:07:32 WARN]:        at org.bukkit.craftbukkit.block.CraftSkull.applyTo(CraftSkull.java:27)
[18:07:32 WARN]:        at org.bukkit.craftbukkit.block.CraftBlockEntityState.update(CraftBlockEntityState.java:177)
[18:07:32 WARN]:        at org.bukkit.craftbukkit.block.CraftBlockState.update(CraftBlockState.java:207)
[18:07:32 WARN]:        at org.bukkit.craftbukkit.block.CraftBlockState.update(CraftBlockState.java:202)
[18:07:32 WARN]:        at CoreProtect-22.4.jar//net.coreprotect.utility.Util.lambda$updateBlock$1(Util.java:1568)
[18:07:32 WARN]:        at org.bukkit.craftbukkit.scheduler.CraftTask.run(CraftTask.java:101)
[18:07:32 WARN]:        at org.bukkit.craftbukkit.scheduler.CraftScheduler.mainThreadHeartbeat(CraftScheduler.java:482)
[18:07:32 WARN]:        at net.minecraft.server.MinecraftServer.tickChildren(MinecraftServer.java:1699)
[18:07:32 WARN]:        at net.minecraft.server.dedicated.DedicatedServer.tickChildren(DedicatedServer.java:467)
[18:07:32 WARN]:        at net.minecraft.server.MinecraftServer.tickServer(MinecraftServer.java:1571)
[18:07:32 WARN]:        at net.minecraft.server.MinecraftServer.runServer(MinecraftServer.java:1231)
[18:07:32 WARN]:        at net.minecraft.server.MinecraftServer.lambda$spin$0(MinecraftServer.java:323)
[18:07:32 WARN]:        at java.base/java.lang.Thread.run(Thread.java:1583)

image

JoelGodOfwar commented 3 months ago

The CoreProtect dev can store the displayname and lore easily, the texture is stored in the NBT profile, which would require getting that string, and then using a PlayerProfile to recreate the head, note to avoid an exception do not use a name with the UUID when creating the PlayerProfile, just set the displayname in the meta.

In the next version of MoreMobHeads my heads will have persistent data storing the name, lore, uuid, texture, and noteblock sound.

Intelli commented 2 months ago

Still returning nothing under Spigot. Looks like it's now possible to read the link to a remote texture skin being used under Paper, so I'll add support for logging that.

i.e. https://jd.papermc.io/paper/1.20.6/org/bukkit/profile/PlayerTextures.html returns a skin URL, a cape URL, and the skin model (a value of either CLASSIC or SLIM).

JoelGodOfwar commented 2 months ago

This class can get the texture, lore, uuid, name, noteblocksound, etc, and make a head. In spigot. Most heads will not return a PlayerProfile for some reason. But if you grab the UUID and texture, logging the name, lore, and noteblocksound from SkullMeta, you can recreate the Head ItemStack.

`import java.lang.reflect.Field; import java.net.MalformedURLException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Collection; import java.util.List; import java.util.UUID; import java.util.logging.Logger;

import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.SkullMeta; import org.bukkit.profile.PlayerProfile; import org.bukkit.profile.PlayerTextures; import org.json.JSONObject;

import com.github.joelgodofwar.mmh.common.PluginLibrary; import com.github.joelgodofwar.mmh.common.error.DetailedErrorReporter; import com.github.joelgodofwar.mmh.common.error.Report; import com.mojang.authlib.GameProfile; import com.mojang.authlib.properties.Property; import com.mojang.authlib.properties.PropertyMap;

public class SkinUtils {

/**
 * Creates a custom head ItemStack with the specified name, texture URL (as a String),
 * associated entity type, and player who delivered the killing blow.
 *
 * @param name The name of the custom head.
 * @param texture String of the Base64-encoded string or direct URL of the texture for the custom head.
 * @param uuid String UUID of Mob
 * @param lore List<String> of Lore
 * @param noteBlockSound of the head
 * @param reporter DetailedErrorReporter
 * @return An ItemStack representing the custom head with the provided name, texture, sound, and lore.
 */
public ItemStack makeHead(String name, String texture, String uuid, List<String> lore, NamespacedKey noteBlockSound, DetailedErrorReporter reporter) {
    // Create the PlayerProfile using UUID and name
    PlayerProfile profile =  Bukkit.createPlayerProfile(UUID.fromString(uuid), "");
    // get Player head item stack
    ItemStack head = new ItemStack(Material.PLAYER_HEAD);
    // get SkullMeta from new player head
    SkullMeta meta = (SkullMeta) head.getItemMeta();
    // get textures from the profile
    PlayerTextures textures = profile.getTextures();
    URL url = null;
    // convert base64 string to URL if not URL already
    try {
        url = convertBase64ToURL(texture);
    } catch (Exception exception) {
        reporter.reportDetailed(this, Report.newBuilder(PluginLibrary.REPORT_HEAD_URL_ERROR).error(exception));
    }
    // set the skin URL
    textures.setSkin(url);
    // save skin texture to head
    profile.setTextures(textures);
    // now set the profile, noteblock sound, lore, and displayname
    meta.setOwnerProfile(profile);
    if(noteBlockSound != null) { meta.setNoteBlockSound(noteBlockSound); }
    if(lore != null) {
        // we do this twice because sometimes it doesn't take
        meta.setLore(lore);
        meta.setLore(lore);
    }
    meta.setDisplayName(name);
    // return the complete head.
    return head;
}
/**
 * Converts a Base64-encoded string containing a JSON structure or a URL string to a URL object
 * representing the texture's URL.
 *
 * @param input The Base64-encoded string containing the JSON structure with a URL or a URL string itself.
 * @return A URL object representing the texture's URL. If the input is a direct URL string,
 *         it is returned directly. If the input is a Base64-encoded string, the method
 *         decodes it, extracts the URL from the JSON structure, and returns the corresponding URL object.
 * @throws MalformedURLException If the URL extraction or URL creation encounters a malformed URL.
 */
public URL convertBase64ToURL(String base64) throws MalformedURLException {
    try {
        return new URL(base64);
    } catch (MalformedURLException ignored) {}

    String jsonString = new String(Base64.getDecoder().decode(base64), StandardCharsets.UTF_8);

    JSONObject jsonObject = new JSONObject(jsonString);
    JSONObject jstextures = jsonObject.getJSONObject("textures");
    JSONObject skin = jstextures.getJSONObject("SKIN");
    String jsurl = skin.getString("url");
    return new URL(jsurl);
}
/**
 * Extracts the Base64 texture string from an ItemStack's ItemMeta.
 *
 * @param itemStack the ItemStack from which to extract the texture
 * @return the DisplayName string, or null if not found
 */
public NamespacedKey getHeadNoteblockSound(ItemStack itemStack) {
    if (itemStack == null) {
        return null;
    }
    ItemMeta itemMeta = itemStack.getItemMeta();
    if (itemMeta == null) {
        return null;
    }
    SkullMeta skullmeta = (SkullMeta) itemMeta;
    if(skullmeta != null) {
        NamespacedKey noteblocksound = skullmeta.getNoteBlockSound();
        if(noteblocksound != null) {
            return noteblocksound;
        }
    }
    return null;
}
/**
 * Extracts the Base64 texture string from an ItemStack's ItemMeta.
 *
 * @param itemStack the ItemStack from which to extract the texture
 * @return the List<String>, or null if not found
 */
public List<String> getHeadLore(ItemStack itemStack){
    if (itemStack == null) {
        return null;
    }
    ItemMeta itemMeta = itemStack.getItemMeta();
    if (itemMeta == null) {
        return null;
    }
    List<String> lore = itemMeta.getLore();
    if(lore != null) {
        return lore;
    }
    return null;
}
/**
 * Extracts the Base64 texture string from an ItemStack's ItemMeta.
 *
 * @param itemStack the ItemStack from which to extract the texture
 * @return the DisplayName string, or null if not found
 */
public String getHeadDisplayName(ItemStack itemStack) {
    if (itemStack == null) {
        return null;
    }
    ItemMeta itemMeta = itemStack.getItemMeta();
    if (itemMeta == null) {
        return null;
    }
    String displayName = itemMeta.getDisplayName();
    if(displayName != null) {
        return displayName;
    }
    return null;
}
/**
 * Extracts the Base64 texture string from an ItemStack's ItemMeta.
 *
 * @param itemStack the ItemStack from which to extract the texture
 * @return the Base64 texture string, or null if not found
 */
public String getHeadTexture(ItemStack itemStack) {
    if (itemStack == null) {
        return null;
    }
    ItemMeta itemMeta = itemStack.getItemMeta();
    if (itemMeta == null) {
        return null;
    }
    Object profile = getPrivate(null, itemMeta, itemMeta.getClass(), "profile");
    if (!(profile instanceof GameProfile)) {
        return null;
    }
    GameProfile gameProfile = (GameProfile) profile;
    PropertyMap properties = gameProfile.getProperties();
    if (properties == null) {
        return null;
    }
    Collection<Property> textures = properties.get("textures");
    if ((textures != null) && !textures.isEmpty()) {
        Property textureProperty = textures.iterator().next();
        String input = textureProperty.toString();
        String texture = input.substring(input.indexOf("ey"), input.lastIndexOf(','));
        return texture;
    }
    return null;
}
/**
 * Extracts the Base64 texture string from an ItemStack's ItemMeta.
 *
 * @param itemStack the ItemStack from which to extract the texture
 * @return the UUID string, or null if not found
 */
public String getHeadUUID(ItemStack itemStack) {
    if (itemStack == null) {
        return null;
    }
    ItemMeta itemMeta = itemStack.getItemMeta();
    if (itemMeta == null) {
        return null;
    }
    Object profile = getPrivate(null, itemMeta, itemMeta.getClass(), "profile");
    if (!(profile instanceof GameProfile)) {
        return null;
    }
    GameProfile gameProfile = (GameProfile) profile;
    UUID uuid = gameProfile.getId();

    if ((uuid != null)) {
        return uuid.toString();
    }
    return null;
}
/**
 * Reflection to get Private field of ItemStack
 * */
public static Object getPrivate(Logger logger, Object o, Class<?> c, String field) {
    try {
        Field access = c.getDeclaredField(field);
        access.setAccessible(true);
        return access.get(o);
    } catch (Exception ex) {
        ex.printStackTrace();
        //logger.log(Level.SEVERE, "Error getting private member of " + o.getClass().getName() + "." + field, ex);
    }
    return null;
}

/**public static String getProfileURL(Object profile) {
    String url = null;
    if ((profile == null) || !(profile instanceof GameProfile)) {
        return null;
    }
    GameProfile gameProfile = (GameProfile)profile;
    PropertyMap properties = gameProfile.getProperties();
    if (properties == null) {
        return null;
    }
    Collection<Property> textures = properties.get("textures");
    if ((textures != null) && (textures.size() > 0)) {
        Property textureProperty = textures.iterator().next();

        try {
            //String decoded = Base64Coder.decodeString(texture);
            String texture = getValue(textureProperty);
            url = texture;//getTextureURL(decoded);
        } catch (Exception ex) {
            //platform.getLogger().log(Level.WARNING, "Could not parse textures in profile", ex);
        }
    }
    return url;
}
protected static String getValue(Property property) {
    return property.getValue();
}
public static Object getSkullProfile(ItemMeta itemMeta) {
    if ((itemMeta == null) || !(itemMeta instanceof SkullMeta)) {
        return null;
    }
    return ReflectionUtils.getPrivate(null, itemMeta, itemMeta.getClass(), "profile");
}//*/

}`

Intelli commented 2 months ago

That code is for ItemStacks (item data), which CoreProtect already fully logs, and has no issues rolling back custom heads that are in item form.

This issue pertains to logging of block data. For example, reading the skull data from a block that's presently placed in the world and doesn't exist as an item.

JoelGodOfwar commented 2 months ago

I was thinking if you logged the ItemMeta when it's placed, then when it's broken, should have all the data to put it back. For some reason MC deletes the displayName and lore of Heads that are placed. Which is why MMH uses PersistentHeads to save the name and lore to the PersistentDataContainer for the block, and then when it's mined it puts the displayName and lore back.

Intelli commented 2 months ago

What's the code MMH uses for reading the data from the PersistentDataContainer?

Could have CoreProtect also check that, if that's possible.

JoelGodOfwar commented 2 months ago

variables // Persistent Heads public final NamespacedKey NAME_KEY = new NamespacedKey(this, "head_name"); public final NamespacedKey LORE_KEY = new NamespacedKey(this, "head_lore"); public final NamespacedKey UUID_KEY = new NamespacedKey(this, "head_uuid"); public final NamespacedKey TEXTURE_KEY = new NamespacedKey(this, "head_texture"); public final NamespacedKey SOUND_KEY = new NamespacedKey(this, "head_sound"); public final PersistentDataType<String,String[]> LORE_PDT = new JsonDataType<>(String[].class);

Everything with skinUtils is new, but working code. `@EventHandler(priority = EventPriority.LOWEST) public void onBlockPlaceEvent(BlockPlaceEvent event) { SkinUtils skinUtils = new SkinUtils(); try { @Nonnull ItemStack headItem = event.getItemInHand(); if (headItem.getType() != Material.PLAYER_HEAD) { return; } ItemMeta meta = headItem.getItemMeta(); if (meta == null) { return; } @Nonnull String name = meta.getDisplayName(); LOGGER.debug("BPE name = " + name); @Nullable List lore = meta.getLore(); LOGGER.debug("BPE lore = " + lore); //PersistentDataContainer pdc = headItem.getItemMeta().getPersistentDataContainer(); String uuid = skinUtils.getHeadUUID(headItem); String texture = skinUtils.getHeadTexture(headItem); // pdc.get(TEXTURE_KEY, PersistentDataType.STRING); String sound = skinUtils.getHeadNoteblockSound(headItem).toString(); // pdc.get(SOUND_KEY, PersistentDataType.STRING); @Nonnull Block block = event.getBlockPlaced(); // NOTE: Not using snapshots is broken: https://github.com/PaperMC/Paper/issues/3913 //BlockStateSnapshotResult blockStateSnapshotResult = PaperLib.getBlockState(block, true); TileState skullState = (TileState) block.getState(); @Nonnull PersistentDataContainer skullPDC = skullState.getPersistentDataContainer(); skullPDC.set(NAME_KEY, PersistentDataType.STRING, name); if (lore != null) { skullPDC.set(LORE_KEY, LORE_PDT, lore.toArray(new String[0])); } if (uuid != null) { skullPDC.set(UUID_KEY, PersistentDataType.STRING, uuid); } if (texture != null) { skullPDC.set(TEXTURE_KEY, PersistentDataType.STRING, texture); } if (sound != null) { skullPDC.set(SOUND_KEY, PersistentDataType.STRING, sound); }

        skullState.update();
        String strLore = "no lore";
        if(lore != null){ strLore = lore.toString(); }
        LOGGER.debug("Player " + event.getPlayer().getName() + " placed a head named \"" + name + "\" with lore=\'" + strLore + "\' at " + event.getBlockPlaced().getLocation());
    }catch(Exception exception){
        reporter.reportDetailed(this, Report.newBuilder(PluginLibrary.BLOCK_PLACE_EVENT_ERROR).error(exception));
    }
}`

this next section of PersistentHeads partly works, but uuid, texture, and sound can be retrieved from "profile" using skinUtils.

@EventHandler(priority = EventPriority.LOWEST) public void onBlockDropItemEvent(BlockDropItemEvent event) { @Nonnull BlockState blockState = event.getBlockState(); Material blockType = blockState.getType(); if ((blockType != Material.PLAYER_HEAD) && (blockType != Material.PLAYER_WALL_HEAD)) { return; } TileState skullState = (TileState) blockState; @Nonnull PersistentDataContainer skullPDC = skullState.getPersistentDataContainer(); @Nullable String name = skullPDC.get(NAME_KEY, PersistentDataType.STRING); LOGGER.debug("BDIE name = " + name); @Nullable String[] lore = skullPDC.get(LORE_KEY, LORE_PDT); LOGGER.debug("BDIE lore = " + lore.toString()); String uuid = skullPDC.get(UUID_KEY, PersistentDataType.STRING); LOGGER.debug("BDIE uuid = " + uuid); String texture = skullPDC.get(TEXTURE_KEY, PersistentDataType.STRING); LOGGER.debug("BDIE texture = " + texture); String sound = skullPDC.get(SOUND_KEY, PersistentDataType.STRING); LOGGER.debug("BDIE sound = " + sound); if (name == null) { return; } for (Item item: event.getItems()) { // Ideally should only be one... @Nonnull ItemStack itemstack = item.getItemStack(); if (itemstack.getType() == Material.PLAYER_HEAD) { @Nullable ItemMeta meta = itemstack.getItemMeta(); if (meta == null) { continue; // This shouldn't happen } meta.setDisplayName(name); if (lore != null) { meta.setLore(Arrays.asList(lore)); } skullPDC.set(NAME_KEY, PersistentDataType.STRING, name); if (lore != null) { skullPDC.set(LORE_KEY, LORE_PDT, lore); } if (uuid != null) { skullPDC.set(UUID_KEY, PersistentDataType.STRING, uuid); } if (texture != null) { skullPDC.set(TEXTURE_KEY, PersistentDataType.STRING, texture); } if (sound != null) { skullPDC.set(SOUND_KEY, PersistentDataType.STRING, sound); } itemstack.setItemMeta(meta); } } LOGGER.debug("BDIE - Persistent head completed."); } the rest is as it has been since starting PersistentHeads after VanillaTweaks(PaperTweaks) started it. `/**