diff --git a/.run/GeyserRun.run.xml b/.run/GeyserRun.run.xml new file mode 100644 index 000000000..3f0a78544 --- /dev/null +++ b/.run/GeyserRun.run.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index bb65b8431..d01c19309 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,7 @@ import org.redlance.dima_dencep.gradle.publish2discord.utils.Emoji import me.modmuss50.mpp.ReleaseType plugins { + id("xyz.wagyourtail.jvmdowngrader") version("1.3.3") apply false id("dev.architectury.loom") version "1.13.467" apply false id("architectury-plugin") version "3.4.162" apply true id("com.gradleup.shadow") version "9.3.0" apply false @@ -36,6 +37,9 @@ allprojects { } } maven("https://maven.neoforged.net/releases") + maven("https://repo.opencollab.dev/main/") { + name = "Geyser" + } mavenLocal() } @@ -105,6 +109,7 @@ val ds = publishDiscord { links { from(":minecraft:neoforge", "modrinth") from(":minecraft:fabric", "modrinth") + from(":geyser", "modrinth") val paper = project(":paper") from(paper, "modrinth") diff --git a/emotesAPI/src/main/java/io/github/kosmx/emotes/common/tools/MathHelper.java b/emotesAPI/src/main/java/io/github/kosmx/emotes/common/tools/MathHelper.java index f42cf1639..b21b7a6fe 100644 --- a/emotesAPI/src/main/java/io/github/kosmx/emotes/common/tools/MathHelper.java +++ b/emotesAPI/src/main/java/io/github/kosmx/emotes/common/tools/MathHelper.java @@ -1,5 +1,7 @@ package io.github.kosmx.emotes.common.tools; +import io.netty.buffer.ByteBuf; + import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; @@ -23,4 +25,10 @@ public static byte[] safeGetBytesFromBuffer(ByteBuffer byteBuffer) { } else return byteBuffer.array(); } + + public static byte[] readBytes(ByteBuf buf) { + byte[] bytes = new byte[buf.readableBytes()]; + buf.readBytes(bytes); + return bytes; + } } diff --git a/geyser/build.gradle.kts b/geyser/build.gradle.kts new file mode 100644 index 000000000..56bdeaf5c --- /dev/null +++ b/geyser/build.gradle.kts @@ -0,0 +1,117 @@ +import me.modmuss50.mpp.ReleaseType + +plugins { + java + `maven-publish` + id("com.gradleup.shadow") + id("xyz.wagyourtail.jvmdowngrader") + id("me.modmuss50.mod-publish-plugin") +} + +base.archivesName = "${archives_base_name}-${name}-for-MC${minecraft_version}" +version = mod_version + +val compileApi = configurations.register("compileApi").get() +configurations.api.configure { extendsFrom(compileApi) } + +dependencies { + compileOnly("org.geysermc.geyser:core:${properties["geyser_version"] as String}") + implementation("org.geysermc.geyser:standalone:${properties["geyser_version"] as String}") + + compileApi(project(":emotesAssets")) + compileApi(project(":emotesServer")) { + exclude(group = "org.jetbrains", module = "annotations") + + exclude(module = "gson") + exclude(module = "slf4j-api") + // exclude(module = "fastutil") + exclude(module = "netty-buffer") + exclude(module = "jspecify") + exclude(module = "guava") + exclude(module = "error_prone_annotations") + exclude(module = "netty-buffer") + } +} + +tasks { + processResources { + inputs.property("version", version) + inputs.property("description", mod_description) + + filesMatching("extension.yml") { + expand("version" to version, "description" to mod_description) + } + } + + shadowJar { + configurations = listOf(compileApi) + archiveClassifier.set("shaded") + mergeServiceFiles() + + relocate("team.unnamed.mocha", "com.zigythebird.playeranim.lib.mochafloats") + relocate("javassist", "com.zigythebird.playeranim.lib.javassist") + } + + downgradeJar { + dependsOn(shadowJar) + + downgradeTo = JavaVersion.VERSION_17 + inputFile = shadowJar.get().archiveFile + archiveClassifier.set("") + } + + jar { + archiveClassifier.set("dev") + } + + assemble { + dependsOn(downgradeJar) + } +} + +java { + withSourcesJar() +} + +publishing { + publications { + register("mavenJava") { + artifactId = "emotesGeyser" + from(components["java"]) + withCustomPom("emotesGeyser", "Minecraft Emotecraft Geyser extension") + } + } + + repositories { + if (shouldPublishMaven) { + kosmxRepo(project) + } else { + mavenLocal() + } + } +} + +publishMods { + modLoaders.add("geyser") + + file.set(tasks.downgradeJar.get().archiveFile) // Java 17 + // additionalFiles.from(tasks.shadowJar.get().archiveFile) // Java 21 + + type = ReleaseType.of(releaseType) + changelog = changes + dryRun = gradle.startParameter.isDryRun + + github { + accessToken = providers.environmentVariable("GH_TOKEN") + parent(rootProject.tasks.named("publishGithub")) + } + + modrinth { + announcementTitle = "Modrinth (Geyser)" + accessToken = providers.environmentVariable("MODRINTH_TOKEN") + projectId = providers.gradleProperty("modrinth_id") + minecraftVersions.addAll(release_minecraft_versions) + displayName = mod_version + version = "${mod_version}+${removePreRc(minecraft_version)}-geyser" + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/EmotecraftExt.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/EmotecraftExt.java new file mode 100644 index 000000000..32d2b5f28 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/EmotecraftExt.java @@ -0,0 +1,228 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser; + +import com.zigythebird.playeranimcore.animation.Animation; +import io.github.kosmx.emotes.common.CommonData; +import io.github.kosmx.emotes.common.SerializableConfig; +import io.github.kosmx.emotes.common.network.EmotePacket; +import io.github.kosmx.emotes.common.tools.MathHelper; +import io.github.kosmx.emotes.server.config.ConfigSerializer; +import io.github.kosmx.emotes.server.config.Serializer; +import io.github.kosmx.emotes.server.serializer.UniversalEmoteSerializer; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import net.kyori.adventure.key.Key; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.cloudburstmc.protocol.bedrock.packet.EmoteListPacket; +import org.cloudburstmc.protocol.bedrock.packet.PlayerAuthInputPacket; +import org.geysermc.event.PostOrder; +import org.geysermc.event.subscribe.Subscribe; +import org.geysermc.geyser.api.command.Command; +import org.geysermc.geyser.api.connection.GeyserConnection; +import org.geysermc.geyser.api.event.bedrock.ClientEmoteEvent; +import org.geysermc.geyser.api.event.bedrock.SessionDisconnectEvent; +import org.geysermc.geyser.api.event.bedrock.SessionInitializeEvent; +import org.geysermc.geyser.api.event.lifecycle.GeyserDefineCommandsEvent; +import org.geysermc.geyser.api.event.lifecycle.GeyserPostInitializeEvent; +import org.geysermc.geyser.api.event.lifecycle.GeyserPreInitializeEvent; +import org.geysermc.geyser.api.extension.Extension; +import org.geysermc.geyser.pack.GeyserResourcePackManifest; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.util.MinecraftKey; +import org.geysermc.mcprotocollib.protocol.data.ProtocolState; +import org.geysermc.mcprotocollib.protocol.packet.common.clientbound.ClientboundCustomPayloadPacket; +import org.geysermc.mcprotocollib.protocol.packet.common.serverbound.ServerboundCustomPayloadPacket; +import org.redlance.dima_dencep.mods.emotecraft.geyser.commands.FixGeometryCommand; +import org.redlance.dima_dencep.mods.emotecraft.geyser.fuckery.GayserHacks; +import org.redlance.dima_dencep.mods.emotecraft.geyser.fuckery.ReflectHacks; +import org.redlance.dima_dencep.mods.emotecraft.geyser.handler.ConnectionType; +import org.redlance.dima_dencep.mods.emotecraft.geyser.handler.GeyserNetworkInstance; +import org.redlance.dima_dencep.mods.emotecraft.geyser.utils.BedrockEmoteLoader; +import org.redlance.dima_dencep.mods.emotecraft.geyser.utils.DinnerboneProtocolUtils; +import org.redlance.dima_dencep.mods.emotecraft.geyser.utils.resourcepack.EmoteResourcePack; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Some cool stuff: + * - MarketplaceEmoteList + */ +@SuppressWarnings("unused") +public class EmotecraftExt implements Extension { + private static final Map INSTANCES = new ConcurrentHashMap<>(); + + public static final Key MINECRAFT_REGISTER_TYPE = MinecraftKey.key("register"); + + public static final Key EMOTECRAFT_EMOTE_TYPE = Key.key(CommonData.MOD_ID, CommonData.playEmoteID); + public static final Key EMOTECRAFT_STREAM_TYPE = Key.key(CommonData.MOD_ID, CommonData.emoteStreamID); + private static final Set EMOTECRAFT_CHANNELS = Set.of(EMOTECRAFT_EMOTE_TYPE, EMOTECRAFT_STREAM_TYPE); + + private final EmoteResourcePack resourcePack = new EmoteResourcePack( + new GeyserResourcePackManifest.Version(1, 0, 0), CommonData.MOD_NAME, CommonData.MOD_NAME + ); + + private static EmotecraftExt instance; + + public EmotecraftExt() { + EmotecraftExt.instance = this; + } + + @Subscribe + public void onPreInitialize(GeyserPreInitializeEvent event) { + eventBus().register(this.resourcePack); + } + + @Subscribe(postOrder = PostOrder.LAST) + public void onPostInitialize(GeyserPostInitializeEvent event) { + CommonData.LOGGER.info("Loading emotecraft on geyser..."); + CommonData.LOGGER.warn("Note that this extension does some horrible hacks on geyser."); + CommonData.LOGGER.warn("Until custom packet event is added, workarounds cannot be avoided."); + + Serializer.INSTANCE = new Serializer<>(new ConfigSerializer<>(SerializableConfig::new), SerializableConfig.class); + UniversalEmoteSerializer.loadEmotes(); + + GayserHacks.addCustomJavaTranslator(ClientboundCustomPayloadPacket.class, (session, packet) -> { + Key type = packet.getChannel(); + if (CommonData.MOD_ID.equals(type.namespace())) { // Any emotecraft payload + onEmotecraftPayload(session, packet.getChannel(), packet.getData()); + return false; // Discard + + } else if (MINECRAFT_REGISTER_TYPE.equals(type)) { + onMinecraftRegisterPayload(session, packet.getChannel(), packet.getData()); + } + return true; // Pass + }); + GayserHacks.addCustomBedrockTranslator(PlayerAuthInputPacket.class, (session, packet) -> { + GeyserNetworkInstance networkInstance = EmotecraftExt.INSTANCES.get(session); + if (networkInstance != null && networkInstance.isPlaying() && session.isSneaking()) { + CommonData.LOGGER.debug("Stopping animation {}", session.name()); + networkInstance.stopEmote(session.getPlayerEntity(), null); + } + return true; + }); + GayserHacks.addCustomBedrockTranslator(EmoteListPacket.class, (session, packet) -> { + BedrockEmoteLoader.preloadEmotes(packet.getPieceIds()); // Preload emotes + return true; + }); + } + + private void onMinecraftRegisterPayload(GeyserSession session, Key type, byte[] bytes) { + ByteBuf inputByteBuf = Unpooled.wrappedBuffer(bytes); + Set channels = DinnerboneProtocolUtils.readChannels(inputByteBuf); + inputByteBuf.release(); + + CommonData.LOGGER.debug("Server listening channels: {}", channels); + if (channels.contains(EmotecraftExt.EMOTECRAFT_EMOTE_TYPE)) { + CommonData.LOGGER.debug("Has emotecraft!"); + + ByteBuf byteBuf = Unpooled.buffer(); + DinnerboneProtocolUtils.writeChannels(byteBuf, EMOTECRAFT_CHANNELS); + session.sendDownstreamPacket(new ServerboundCustomPayloadPacket(type, MathHelper.readBytes(byteBuf))); + byteBuf.release(); + + if (ReflectHacks.getProtocol(session).getOutboundState() == ProtocolState.GAME) { + GeyserNetworkInstance networkInstance = EmotecraftExt.INSTANCES.get(session); + + if (networkInstance.getConnectionType() == ConnectionType.NONE) { + CommonData.LOGGER.warn("The server failed to configure the client, attempting to configure..."); + networkInstance.sendC2SConfig(); + } + } + } /*else { + // Online-emotes integration? + }*/ + } + + private void onEmotecraftPayload(GeyserConnection session, Key channel, byte[] bytes) { + GeyserNetworkInstance networkInstance = EmotecraftExt.INSTANCES.computeIfAbsent(session, GeyserNetworkInstance::new); + if (networkInstance.getConnectionType() == ConnectionType.NONE) { + if (ReflectHacks.getProtocol((GeyserSession) session).getOutboundState() == ProtocolState.CONFIGURATION) { + CommonData.LOGGER.debug("Configuring emotecraft..."); + networkInstance.sendC2SConfig(); + } + networkInstance.setConnectionType(ConnectionType.BACKEND); + } + ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes); + networkInstance.receiveMessage(new EmotePacket(byteBuf)); + byteBuf.release(); + } + + @Subscribe + public void onSessionInitialize(SessionInitializeEvent event) { + GeyserConnection session = event.connection(); + EmotecraftExt.INSTANCES.put(session, new GeyserNetworkInstance(session)); + } + + @Subscribe + public void onSessionDisconnect(SessionDisconnectEvent event) { + GeyserNetworkInstance instance = EmotecraftExt.INSTANCES.remove(event.connection()); + if (instance != null) instance.disconnect(); + } + + @Override + public @NonNull String rootCommand() { + return "emotes-geyser"; + } + + @Subscribe + public void onDefineCommands(GeyserDefineCommandsEvent event) { + event.register(Command.builder(this) + .name("list") + .bedrockOnly(true) + .source(GeyserConnection.class) + .aliases(Collections.singletonList("emotes")) + .description("List of emotes") + .playerOnly(true) + .executor((source, cmd, args) -> EmotecraftExt.INSTANCES.get(source).showEmoteList()) + .build() + ); + event.register(Command.builder(this) + .name("fix-geometry") + .bedrockOnly(true) + .source(GeyserConnection.class) + .aliases(Collections.singletonList("fix-bends")) + .description("Fix geometry") + .playerOnly(true) + .executor(new FixGeometryCommand()) + .build() + ); + } + + @Subscribe(postOrder = PostOrder.FIRST, ignoreCancelled = true) + public void onEmote(ClientEmoteEvent event) { + GeyserNetworkInstance networkInstance = EmotecraftExt.INSTANCES.get(event.connection()); + if (networkInstance != null && networkInstance.getConnectionType() != ConnectionType.NONE) { + CompletableFuture animation = BedrockEmoteLoader.loadEmote(event.emoteId()); + + if (animation.isDone() && !animation.isCompletedExceptionally()) { + networkInstance.playEmote(animation.join(), null); + } else { + try { + networkInstance.stopEmote(UUID.fromString(event.emoteId())); + } catch (IllegalArgumentException ex) { // Not uuid + networkInstance.stopEmote(null); + } + + if (animation.isCompletedExceptionally()) { + networkInstance.sendChatMessage("emotecraft.blockedEmote"); + CommonData.LOGGER.warn("Failed to translate emote!", animation.exceptionNow()); + + } else { + animation.thenAccept(emote -> networkInstance.playEmote( + emote, event.emoteId() + )); + } + } + event.setCancelled(true); + } + } + + public EmoteResourcePack getResourcePack() { + return this.resourcePack; + } + + public static EmotecraftExt getInstance() { + return EmotecraftExt.instance; + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/EmotecraftService.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/EmotecraftService.java new file mode 100644 index 000000000..a9ce022f1 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/EmotecraftService.java @@ -0,0 +1,27 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser; + +import io.github.kosmx.emotes.server.services.InstanceService; + +import java.nio.file.Path; + +public class EmotecraftService implements InstanceService { + @Override + public Path getGameDirectory() { + return EmotecraftExt.getInstance().dataFolder(); + } + + @Override + public Path getConfigPath() { + return getGameDirectory().resolve("emotecraft.json"); + } + + @Override + public boolean isActive() { + return EmotecraftExt.getInstance() != null && EmotecraftExt.getInstance().isEnabled(); + } + + @Override + public int getPriority() { + return InstanceService.super.getPriority() - 1; + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/GeyserBootstrap.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/GeyserBootstrap.java new file mode 100644 index 000000000..5e46f37c9 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/GeyserBootstrap.java @@ -0,0 +1,84 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser; + +import io.github.kosmx.emotes.common.CommonData; +import javassist.ByteArrayClassPath; +import javassist.ClassPool; +import javassist.CtClass; +import javassist.CtMethod; +import org.geysermc.geyser.extension.GeyserExtensionContainer; +import org.geysermc.geyser.platform.standalone.GeyserStandaloneBootstrap; +import org.redlance.dima_dencep.mods.emotecraft.geyser.fuckery.ReflectHacks; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.Objects; +import java.util.function.UnaryOperator; + +/** + * Used to run Emotecraft in a dev environment. + */ +public class GeyserBootstrap { + private static final Class LEVEL_CLASS = ReflectHacks.uncheck(() -> Class.forName("org.apache.logging.log4j.Level")); + private static final MethodHandle SET_LEVEL = ReflectHacks.uncheck(() -> ReflectHacks.TRUSTED_LOOKUP.findStatic( + Class.forName("org.apache.logging.log4j.core.config.Configurator"), + "setLevel", MethodType.methodType(void.class, String.class, LEVEL_CLASS) + )); + private static final MethodHandle DEBUG_LEVEL = ReflectHacks.uncheck(() -> ReflectHacks.TRUSTED_LOOKUP.findStaticGetter( + LEVEL_CLASS, "DEBUG", LEVEL_CLASS + )); + + static { + System.setProperty("java.awt.headless", "true"); + try { + SET_LEVEL.invoke(CommonData.LOGGER.getName(), DEBUG_LEVEL.invoke()); + } catch (Throwable e) { + throw new RuntimeException(e); + } + } + + public static void main(String[] args) throws ReflectiveOperationException, IOException { + GeyserBootstrap.patchClass(GeyserExtensionContainer.class, "org/geysermc/geyser/extension/GeyserExtensionLoader.class", GeyserBootstrap::patch); + GeyserStandaloneBootstrap.main(args); + } + + private static byte[] patch(byte[] bytes) { + ClassPool pool = ClassPool.getDefault(); + pool.insertClassPath(new ByteArrayClassPath("org.geysermc.geyser.extension.GeyserExtensionLoader", bytes)); + + try { + CtClass cc = pool.get("org.geysermc.geyser.extension.GeyserExtensionLoader"); + CtMethod method = cc.getDeclaredMethod("loadAllExtensions"); + String src = """ + org.redlance.dima_dencep.mods.emotecraft.geyser.EmotecraftExt extension = new org.redlance.dima_dencep.mods.emotecraft.geyser.EmotecraftExt(); + try { + java.io.InputStreamReader reader = new java.io.InputStreamReader(org.redlance.dima_dencep.mods.emotecraft.geyser.EmotecraftExt.class.getResourceAsStream("/extension.yml")); + org.geysermc.geyser.extension.GeyserExtensionDescription description = org.geysermc.geyser.extension.GeyserExtensionDescription.fromYaml(reader); + reader.close(); + + java.nio.file.Path path = this.extensionsDirectory.resolve(description.id()); + org.geysermc.geyser.api.event.EventBus eventBus = org.geysermc.geyser.GeyserImpl.getInstance().eventBus(); + org.geysermc.geyser.extension.event.GeyserExtensionEventBus extensionEventBus = new org.geysermc.geyser.extension.event.GeyserExtensionEventBus(eventBus, extension); + org.geysermc.geyser.extension.GeyserExtensionContainer container = this.setup(extension, description, path, extensionEventBus); + + this.extensionContainers.put(extension, container); + this.register(extension, $1); + } catch (Throwable t) { + throw new RuntimeException(t); + }"""; + method.insertAfter(src, false); + return cc.toBytecode(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static void patchClass(Class nearClass, String name, UnaryOperator patcher) throws ReflectiveOperationException, IOException { + try (InputStream is = Objects.requireNonNull(nearClass.getClassLoader().getResourceAsStream(name))) { + byte[] bytecode = patcher.apply(is.readAllBytes()); + MethodHandles.privateLookupIn(nearClass, MethodHandles.lookup()).defineClass(bytecode); + } + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/ControllerHolder.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/ControllerHolder.java new file mode 100644 index 000000000..ee14ab971 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/ControllerHolder.java @@ -0,0 +1,61 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.animator; + +import io.github.kosmx.emotes.common.CommonData; +import org.geysermc.geyser.entity.type.player.AvatarEntity; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.*; + +public final class ControllerHolder { + private static final ThreadFactory THREAD_FACTORY = Thread.ofVirtual() + .name("emotecraft-animating-") + .uncaughtExceptionHandler((t, e) -> CommonData.LOGGER.warn("Failed to animate!", e)) + .factory(); + + private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(THREAD_FACTORY); + private static final ExecutorService CHILD_EXECUTOR = Executors.newCachedThreadPool(THREAD_FACTORY); + + public static final ControllerHolder INSTANCE = new ControllerHolder(); + + private final Map controllers = new ConcurrentHashMap<>(); + private final Map> inFlight = new ConcurrentHashMap<>(); + + private ControllerHolder() { + EXECUTOR.scheduleAtFixedRate(this::run, 0L, 50L, TimeUnit.MILLISECONDS); + } + + private void run() { + if (this.controllers.isEmpty()) return; + for (Map.Entry e : this.controllers.entrySet()) { + UUID uuid = e.getKey(); + + CompletableFuture current = this.inFlight.get(uuid); + if (current != null && !current.isDone()) continue; + + CompletableFuture cf = CompletableFuture.supplyAsync(e.getValue()::handleFrame, CHILD_EXECUTOR); + inFlight.put(uuid, cf); + + cf.whenComplete((remove, ex) -> { + inFlight.remove(uuid, cf); + if (remove) controllers.remove(uuid); + }); + } + } + + public GeyserAnimationController get(AvatarEntity entity) { + GeyserAnimationController controller = this.controllers.computeIfAbsent(entity.getUuid(), GeyserAnimationController::new); + controller.subscribe(entity); + return controller; + } + + public void resubscribe(AvatarEntity entity) { + for (GeyserAnimationController controller : this.controllers.values()) { + if (controller.listeners.contains(entity)) { + controller.unsubscribe(entity); + controller.subscribe(entity); + controller.subscribe(entity); + } + } + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/GeyserAnimationController.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/GeyserAnimationController.java new file mode 100644 index 000000000..f88a8e1c3 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/GeyserAnimationController.java @@ -0,0 +1,248 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.animator; + +import com.google.common.collect.Sets; +import com.zigythebird.playeranimcore.animation.AnimationData; +import com.zigythebird.playeranimcore.animation.HumanoidAnimationController; +import com.zigythebird.playeranimcore.bones.PlayerAnimBone; +import com.zigythebird.playeranimcore.enums.Axis; +import com.zigythebird.playeranimcore.enums.PlayState; +import com.zigythebird.playeranimcore.enums.TransformType; +import com.zigythebird.playeranimcore.molang.MolangLoader; +import com.zigythebird.playeranimcore.util.MatrixUtil; +import io.github.kosmx.emotes.common.CommonData; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.cloudburstmc.protocol.bedrock.data.entity.EntityProperty; +import org.cloudburstmc.protocol.bedrock.packet.SetEntityDataPacket; +import org.geysermc.geyser.api.entity.property.GeyserEntityProperty; +import org.geysermc.geyser.api.entity.property.type.GeyserIntEntityProperty; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.entity.properties.GeyserEntityPropertyManager; +import org.geysermc.geyser.entity.properties.type.PropertyType; +import org.geysermc.geyser.entity.type.player.AvatarEntity; +import org.redlance.dima_dencep.mods.emotecraft.geyser.EmotecraftExt; +import org.redlance.dima_dencep.mods.emotecraft.geyser.animator.geometry.BendingGeometry; +import org.redlance.dima_dencep.mods.emotecraft.geyser.animator.geometry.GeometryChanger; +import org.redlance.dima_dencep.mods.emotecraft.geyser.utils.BedrockPacketsUtils; +import org.redlance.dima_dencep.mods.emotecraft.geyser.utils.GeyserEntityUtils; +import org.redlance.dima_dencep.mods.emotecraft.geyser.utils.resourcepack.EmoteResourcePack; + +import java.time.Duration; +import java.util.*; + +import static org.redlance.dima_dencep.mods.emotecraft.geyser.animator.PackedProperty.pack; + +public class GeyserAnimationController extends HumanoidAnimationController { + private final Set lastUsedProperties = new HashSet<>(1); + protected final Set listeners = Sets.newConcurrentHashSet(); + private final Set dirtyBones = new HashSet<>(); + + protected final UUID avatarId; + + protected GeyserAnimationController(UUID avatarId) { + super((controller, state, animationSetter) -> PlayState.STOP, MolangLoader::createNewEngine); + this.avatarId = avatarId; + } + + @Override + protected void setupNewAnimation() { + super.setupNewAnimation(); + for (AvatarEntity avatar : this.listeners) subscribe(avatar); + this.dirtyBones.clear(); + } + + public void subscribe(AvatarEntity avatarEntity) { + if (this.listeners.add(avatarEntity)) { + GeometryChanger.changeGeometryToBending(avatarEntity).join(); + try { + Thread.sleep(Duration.ofMillis(10)); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + BedrockPacketsUtils.sendInstantAnimation(EmoteResourcePack.ANIMATION_NAME, avatarEntity); + for (String partKey : this.dirtyBones) { + updateBone(avatarEntity.getPropertyManager(), partKey, new PlayerAnimBone(partKey)); + } + } + + protected void handleFrameInternal() { + try { + AnimationData data = new AnimationData(0, 1.0F, false); + tick(data); + + if (!isActive()) return; + setupAnim(data); + + // Animate via properties + for (String partKey : this.activeBones.keySet()) { + if (!this.bones.containsKey(partKey)) { + CommonData.LOGGER.debug("Unsupported bone: {}!", partKey); + continue; + } + + PlayerAnimBone bone = get3DTransform(new PlayerAnimBone(partKey)); + + for (AvatarEntity avatarEntity : this.listeners) { + if (GeyserEntityUtils.unsubscribedFromEntity(avatarEntity)) { + unsubscribe(avatarEntity); + continue; + } + + // Check propertyManager + GeyserEntityPropertyManager propertyManager = avatarEntity.getPropertyManager(); + if (propertyManager == null) continue; + updateBone(propertyManager, partKey, bone); + } + } + + // Flush + flushPropertiesImmediately(); + if (this.dirtyBones.isEmpty()) this.dirtyBones.addAll(this.activeBones.keySet()); + } catch (Throwable th) { + CommonData.LOGGER.warn("Failed to animate {}!", this.avatarId, th); + } + } + + protected boolean handleFrame() { + handleFrameInternal(); + this.listeners.removeIf(GeyserEntityUtils::unsubscribedFromEntity); + return this.listeners.isEmpty(); + } + + @Override + public PlayerAnimBone get3DTransform(@NonNull PlayerAnimBone bone) { + bone = super.get3DTransform(bone); + + String boneName = bone.getName(); + if ("left_arm".equals(boneName) || "right_arm".equals(boneName) || "head".equals(boneName)) { + PlayerAnimBone torsoBone = get3DTransform(new PlayerAnimBone("torso")); + torsoBone.mulPos(-1); + torsoBone.mulRot(-1); + torsoBone.setScaleX(1 / bone.getScaleX()); + torsoBone.setScaleY(1 / bone.getScaleY()); + torsoBone.setScaleZ(1 / bone.getScaleZ()); + + MatrixUtil.applyParentsToChild(bone, Collections.singleton(torsoBone), this::getBonePosition); + } else if ("left_leg".equals(boneName) || "right_leg".equals(boneName)) { + PlayerAnimBone body = get3DTransform(new PlayerAnimBone("body")); + MatrixUtil.applyParentsToChild(bone, Collections.singleton(body), this::getBonePosition); + + } else if ("cape".equals(boneName)) { + bone.rotX *= -1; + } + return bone; + } + + protected void updateBone(GeyserEntityPropertyManager propertyManager, String partKey, PlayerAnimBone bone) { + if (!this.bones.containsKey(partKey)) return; + updateAxis(propertyManager, partKey, TransformType.POSITION, bone.getPosX(), bone.getPosY(), bone.getPosZ()); + updateAxis(propertyManager, partKey, TransformType.ROTATION, + (float) Math.toDegrees(bone.getRotX()), (float) Math.toDegrees(bone.getRotY()), (float) Math.toDegrees(bone.getRotZ()) + ); + updateAxis(propertyManager, partKey, TransformType.SCALE, bone.getScaleX(), bone.getScaleY(), bone.getScaleZ()); + + if (BendingGeometry.BENDABLE_BONES.contains(partKey)) { + updateBend(propertyManager, partKey + BendingGeometry.BEND_SUFFIX, bone.bend); + } + } + + protected void updateAxis(GeyserEntityPropertyManager propertyManager, String partKey, TransformType type, float x, float y, float z) { + Map ids = EmotecraftExt.getInstance().getResourcePack().getAxisIds(partKey, type); + updateProperty(propertyManager, getAvailableProperty(), pack(ids.get(Axis.X), x)); + updateProperty(propertyManager, getAvailableProperty(), pack(ids.get(Axis.Y), y)); + updateProperty(propertyManager, getAvailableProperty(), pack(ids.get(Axis.Z), z)); + } + + protected void updateBend(GeyserEntityPropertyManager propertyManager, String partKey, float bend) { + EmoteResourcePack resourcePack = EmotecraftExt.getInstance().getResourcePack(); + + updateProperty(propertyManager, getAvailableProperty(), pack( + resourcePack.getAxisIds(partKey, TransformType.ROTATION).get(Axis.X), + (float) Math.toDegrees(bend) + )); + + float radius = 2.0f; + float angle = Math.abs(bend); + + updateProperty(propertyManager, getAvailableProperty(), pack( + resourcePack.getAxisIds(partKey, TransformType.POSITION).get(Axis.Y), + (float) (radius * (1 - Math.cos(angle))) + )); + updateProperty(propertyManager, getAvailableProperty(), pack( + resourcePack.getAxisIds(partKey, TransformType.POSITION).get(Axis.Z), + (float) -(radius * Math.sin(angle)) + )); + } + + private GeyserIntEntityProperty getAvailableProperty() { + for (GeyserIntEntityProperty property : EmotecraftExt.getInstance().getResourcePack().getRegisteredProperties()) { + if (this.lastUsedProperties.contains(property.identifier())) continue; + this.lastUsedProperties.add(property.identifier()); + return property; + } + + // Try flush + flushPropertiesImmediately(); + return getAvailableProperty(); + } + + public static void updateProperty(GeyserEntityPropertyManager propertyManager, @NonNull GeyserEntityProperty property, @Nullable T value) { + Objects.requireNonNull(property, "property must not be null!"); + if (!(property instanceof PropertyType propertyType)) { + throw new IllegalArgumentException("Invalid property implementation! Got: " + property.getClass().getSimpleName()); + } + propertyType.apply(propertyManager, value); + } + + private void flushPropertiesImmediately() { + for (AvatarEntity avatarEntity : this.listeners) { + GeyserEntityPropertyManager propertyManager = avatarEntity.getPropertyManager(); + if (propertyManager == null || !propertyManager.hasProperties()) continue; + + SetEntityDataPacket packet = new SetEntityDataPacket(); + packet.setRuntimeEntityId(avatarEntity.getGeyserId()); + propertyManager.applyFloatProperties(packet.getProperties().getFloatProperties()); + propertyManager.applyIntProperties(packet.getProperties().getIntProperties()); + avatarEntity.getSession().sendUpstreamPacketImmediately(packet); + } + + try { + Thread.sleep(Duration.ofMillis(10)); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return; + } + this.lastUsedProperties.clear(); + } + + @Override + public void process(AnimationData state) { + super.process(state); + if (!this.animationState.isActive()) internalStop(); + } + + @Override + public void stop() { + super.stop(); + internalStop(); + } + + protected void internalStop() { + for (AvatarEntity avatarEntity : this.listeners) BedrockPacketsUtils.sendBobAnimation(avatarEntity); + } + + public void unsubscribe(AvatarEntity avatarEntity) { + if (this.listeners.remove(avatarEntity)) BedrockPacketsUtils.sendBobAnimation(avatarEntity); + } + + /** + * A small hack that allows us to get all registered bones. + */ + public static Collection getRegisteredBones() { + GeyserAnimationController controller = new GeyserAnimationController(UUID.randomUUID()); + Set bones = new HashSet<>(controller.bones.keySet()); + for (String bendable : BendingGeometry.BENDABLE_BONES) bones.add(bendable + BendingGeometry.BEND_SUFFIX); + return Collections.unmodifiableCollection(bones); + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/PackedProperty.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/PackedProperty.java new file mode 100644 index 000000000..5c1e7cff1 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/PackedProperty.java @@ -0,0 +1,47 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.animator; + +@SuppressWarnings("unused") // api +public final class PackedProperty { + public static final int ID_MIN = -99; + public static final int ID_MAX = 99; + public static final int ID_COUNT = ID_MAX - ID_MIN + 1; // 199 + + public static final int PROP_MIN = -1_000_000; + public static final int PROP_RANGE = 2_000_001; // [-1_000_000 .. +1_000_000] + public static final int SLOT = PROP_RANGE / ID_COUNT; // 10050 + public static final int CENTER = SLOT / 2; // 5025 + + // 10 => 0.1, 100 => 0.01, 1 => 1.0 + public static final int SCALE = 10; + + public static final float VALUE_MIN = -CENTER / (float) SCALE; + public static final float VALUE_MAX = (SLOT - 1 - CENTER) / (float) SCALE; + + public static final int PROP_MAX_USED = PROP_MIN + (SLOT * ID_COUNT) - 1; + + public static int pack(int id, float value) { + id = Math.max(ID_MIN, Math.min(ID_MAX, id)); + value = Math.max(VALUE_MIN, Math.min(VALUE_MAX, value)); + int idx = id - ID_MIN; // 0..198 + int quant = Math.round(value * SCALE); + int valueIndex = quant + CENTER; + if (valueIndex < 0) valueIndex = 0; + if (valueIndex > SLOT - 1) valueIndex = SLOT - 1; + int raw = idx * SLOT + valueIndex; + return PROP_MIN + raw; + } + + public static int unpackId(int packed) { + int raw = packed - PROP_MIN; + int idx = raw / SLOT; + return idx + ID_MIN; + } + + public static float unpackValue(int packed) { + int raw = packed - PROP_MIN; + int valueIndex = raw % SLOT; + int quant = valueIndex - CENTER; + return quant / (float) SCALE; + } +} + diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/geometry/BendingGeometry.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/geometry/BendingGeometry.java new file mode 100644 index 000000000..6c479587d --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/geometry/BendingGeometry.java @@ -0,0 +1,187 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.animator.geometry; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.zigythebird.playeranimcore.PlayerAnimLib; +import com.zigythebird.playeranimcore.loading.UniversalAnimLoader; +import io.github.kosmx.emotes.common.CommonData; +import org.geysermc.geyser.api.skin.SkinGeometry; +import org.geysermc.geyser.skin.SkinManager; +import org.redlance.dima_dencep.mods.emotecraft.geyser.fuckery.ReflectHacks; + +import java.lang.invoke.VarHandle; +import java.util.ArrayList; +import java.util.Set; + +public class BendingGeometry { + private static final VarHandle SKIN_MANAGER_GEOMETRY = ReflectHacks.uncheck(() -> ReflectHacks.TRUSTED_LOOKUP.findStaticVarHandle( + SkinManager.class, "GEOMETRY", String.class + )); + + public static final Set BENDABLE_BONES = Set.of( + "right_arm", "left_arm", + "body", + "right_leg", "left_leg" + ); + public static final String BEND_SUFFIX = "_bend"; + + public static SkinGeometry addBoneBends(SkinGeometry geometry) { + JsonObject geometryObj = PlayerAnimLib.GSON.fromJson( + geometry.geometryData().isBlank() ? (String) SKIN_MANAGER_GEOMETRY.get() : geometry.geometryData(), JsonObject.class + ); + for (JsonElement element : geometryObj.getAsJsonArray("minecraft:geometry")) { + addBoneBends(element.getAsJsonObject()); + } + return new SkinGeometry(geometry.geometryName(), PlayerAnimLib.GSON.toJson(geometryObj)); + } + + private static void addBoneBends(JsonObject geometry) { + String identifier = geometry.getAsJsonObject("description").get("identifier").getAsString(); + CommonData.LOGGER.debug("Patching '{}' for bends...", identifier); + + JsonArray bones = geometry.getAsJsonArray("bones"); + for (JsonElement element : new ArrayList<>(bones.asList())) { + JsonObject boneObj = element.getAsJsonObject(); + + if (!boneObj.has("cubes")) continue; // Skip bones without cubes + + String boneName = UniversalAnimLoader.getCorrectPlayerBoneName(boneObj.get("name").getAsString()); + if (BendingGeometry.BENDABLE_BONES.contains(boneName)) addBoneBendsToBone(bones, boneObj); + } + geometry.add("bones", bones); + } + + private static void addBoneBendsToBone(JsonArray bones, JsonObject bone) { + int boneSize = bone.getAsJsonArray("cubes").get(0).getAsJsonObject() + .getAsJsonArray("size").get(1).getAsInt(); + + JsonObject secondBone = makeCubeBendable(bone, true); + String name = bone.get("name").getAsString(); + for (JsonElement element : new ArrayList<>(bones.asList())) { // Fix hierarchy + JsonObject boneObj = element.getAsJsonObject(); + + if (boneObj.has("parent") && name.equals(boneObj.get("parent").getAsString())) { + JsonObject firstCube = boneObj.has("cubes") ? boneObj.getAsJsonArray("cubes").get(0).getAsJsonObject() : new JsonObject(); + + if (firstCube.has("inflate") && boneSize == firstCube.getAsJsonArray("size").get(1).getAsInt()) { + CommonData.LOGGER.debug("Second layer detected! {}", boneObj); + + JsonObject secondBoneSecondLayer = makeCubeBendable(boneObj, false); + boneObj.add("parent", bone.get("name")); + secondBoneSecondLayer.add("parent", secondBone.get("name")); + bones.add(secondBoneSecondLayer); + } else { + boneObj.add("parent", secondBone.get("name")); + } + } + } + bones.add(secondBone); + } + + /** + * Patches the bone and adds a second one + * @param bone Mutable bone + * @return Second bending bone + */ + private static JsonObject makeCubeBendable(JsonObject bone, boolean drawCrossSection) { + JsonObject bendableCube = bone.getAsJsonArray("cubes").get(0).getAsJsonObject(); + if (bendableCube.get("uv").isJsonArray()) bendableCube.add("uv", expandCubeUV(bendableCube)); + + { // Patch size + JsonArray size = bendableCube.getAsJsonArray("size"); + size.set(1, new JsonPrimitive(size.get(1).getAsFloat() / 2F)); + } + + JsonObject secondBendableCube = bendableCube.deepCopy(); + float secondBendableCubeSizeY = secondBendableCube.getAsJsonArray("size").get(1).getAsFloat(); + { // Patch second cube uv + pivot + JsonObject uv = secondBendableCube.getAsJsonObject("uv"); + String[] sides = {"north", "south", "east", "west"}; + + for (String side : sides) { + JsonObject face = uv.getAsJsonObject(side); + + JsonArray uvCoords = face.getAsJsonArray("uv"); + uvCoords.set(1, new JsonPrimitive(uvCoords.get(1).getAsFloat() + secondBendableCubeSizeY)); + + JsonArray uvSize = face.getAsJsonArray("uv_size"); + uvSize.set(1, new JsonPrimitive(secondBendableCubeSizeY)); + } + + if (!drawCrossSection) uv.remove("up"); + } + + { // Patch first cube uv + JsonObject uv = bendableCube.getAsJsonObject("uv"); + String[] sides = {"north", "south", "east", "west"}; + + for (String side : sides) { + JsonObject face = uv.getAsJsonObject(side); + JsonArray uvSize = face.getAsJsonArray("uv_size"); + uvSize.set(1, new JsonPrimitive(secondBendableCubeSizeY)); + } + + if (drawCrossSection) { + uv.add("down", uv.getAsJsonObject("up").deepCopy()); + } else { + uv.remove("down"); + } + } + + JsonArray secondCubes = new JsonArray(); + secondCubes.add(secondBendableCube); + + { // Patch first cube origin + float sizeY = bendableCube.getAsJsonArray("size").get(1).getAsFloat(); + JsonArray origin = bendableCube.getAsJsonArray("origin"); + origin.set(1, new JsonPrimitive(origin.get(1).getAsFloat() + sizeY)); + } + + JsonObject secondBone = new JsonObject(); + secondBone.add("parent", bone.get("name")); + secondBone.addProperty("name", UniversalAnimLoader.restorePlayerBoneName(bone.get("name").getAsString() + BEND_SUFFIX)); + secondBone.add("cubes", secondCubes); + + JsonArray pivot = new JsonArray(); + pivot.add(bone.get("pivot").getAsJsonArray().get(0)); + pivot.add(bone.get("pivot").getAsJsonArray().get(1).getAsFloat() - secondBendableCubeSizeY); + pivot.add(bone.get("pivot").getAsJsonArray().get(2)); + secondBone.add("pivot", pivot); + + return secondBone; + } + + public static JsonObject expandCubeUV(JsonObject cube) { + JsonArray uvArray = cube.getAsJsonArray("uv"); + float u = uvArray.get(0).getAsFloat(); + float v = uvArray.get(1).getAsFloat(); + + JsonArray sizeArray = cube.getAsJsonArray("size"); + float w = sizeArray.get(0).getAsFloat(); + float h = sizeArray.get(1).getAsFloat(); + float d = sizeArray.get(2).getAsFloat(); + + JsonObject root = new JsonObject(); + root.add("east", createFace(u, v + d, d, h)); + root.add("north", createFace(u + d, v + d, w, h)); + root.add("west", createFace(u + d + w, v + d, d, h)); + root.add("south", createFace(u + d + w + d, v + d, w, h)); + root.add("up", createFace(u + d, v, w, d)); + root.add("down", createFace(u + d + w, v, w, d)); + + return root; + } + + private static JsonObject createFace(float u, float v, float w, float h) { + JsonObject face = new JsonObject(); + JsonArray uv = new JsonArray(); + uv.add(u); uv.add(v); + face.add("uv", uv); + JsonArray size = new JsonArray(); + size.add(w); size.add(h); + face.add("uv_size", size); + return face; + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/geometry/GeometryChanger.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/geometry/GeometryChanger.java new file mode 100644 index 000000000..9f910bdf6 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/animator/geometry/GeometryChanger.java @@ -0,0 +1,85 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.animator.geometry; + +import org.cloudburstmc.protocol.bedrock.data.skin.SerializedSkin; +import org.cloudburstmc.protocol.bedrock.packet.PlayerListPacket; +import org.cloudburstmc.protocol.bedrock.packet.PlayerSkinPacket; +import org.geysermc.geyser.api.skin.Cape; +import org.geysermc.geyser.api.skin.Skin; +import org.geysermc.geyser.api.skin.SkinData; +import org.geysermc.geyser.api.skin.SkinGeometry; +import org.geysermc.geyser.entity.type.player.AvatarEntity; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.skin.SkinManager; +import org.geysermc.geyser.skin.SkinProvider; +import org.redlance.dima_dencep.mods.emotecraft.geyser.fuckery.ReflectHacks; + +import java.awt.*; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodType; +import java.util.concurrent.CompletableFuture; + +public class GeometryChanger { + private static final MethodHandle REQUEST_SKIN_DATA = ReflectHacks.uncheck(() -> ReflectHacks.TRUSTED_LOOKUP.findStatic( + SkinProvider.class, "requestSkinData", MethodType.methodType(CompletableFuture.class, AvatarEntity.class, GeyserSession.class) + )); + private static final MethodHandle GET_SKIN = ReflectHacks.uncheck(() -> ReflectHacks.TRUSTED_LOOKUP.findStatic( + SkinManager.class, "getSkin", MethodType.methodType(SerializedSkin.class, GeyserSession.class, String.class, Skin.class, Cape.class, SkinGeometry.class) + )); + + public static CompletableFuture changeGeometryToBending(AvatarEntity entity) { + return requestSkinData(entity, entity.getSession()) + .thenApply(skinData -> { + SkinData bendable = new SkinData(skinData.skin(), skinData.cape(), + BendingGeometry.addBoneBends(skinData.geometry()) + ); + sendSkinPacket(entity.getSession(), entity, bendable); + return bendable; + }); + } + + public static void sendSkinPacket(GeyserSession session, AvatarEntity entity, SkinData skinData) { + Skin skin = skinData.skin(); + Cape cape = skinData.cape(); + SkinGeometry geometry = skinData.geometry(); + Color color = session.getWaypointCache().getWaypointColor(entity.getUuid()).orElse(Color.WHITE); + + if (entity.getUuid().equals(session.getPlayerEntity().getUuid())) { + PlayerListPacket.Entry updatedEntry = SkinManager.buildEntryManually( + session, + entity.getUuid(), + entity.getUsername(), + entity.getGeyserId(), + skin, + cape, + geometry, + color + ); + + PlayerListPacket playerAddPacket = new PlayerListPacket(); + playerAddPacket.setAction(PlayerListPacket.Action.ADD); + playerAddPacket.getEntries().add(updatedEntry); + session.sendUpstreamPacketImmediately(playerAddPacket); + } else { + PlayerSkinPacket packet = new PlayerSkinPacket(); + packet.setUuid(entity.getUuid()); + packet.setOldSkinName(""); + packet.setNewSkinName(skin.textureUrl()); + try { + packet.setSkin((SerializedSkin) GET_SKIN.invoke(session, skin.textureUrl(), skin, cape, geometry)); + } catch (Throwable e) { + throw new RuntimeException(e); + } + packet.setTrustedSkin(true); + session.sendUpstreamPacketImmediately(packet); + } + } + + @SuppressWarnings("unchecked") + public static CompletableFuture requestSkinData(AvatarEntity entity, GeyserSession session) { + try { + return (CompletableFuture) REQUEST_SKIN_DATA.invoke(entity, session); + } catch (Throwable th) { + return CompletableFuture.failedFuture(th); + } + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/commands/FixGeometryCommand.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/commands/FixGeometryCommand.java new file mode 100644 index 000000000..757b3ac1f --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/commands/FixGeometryCommand.java @@ -0,0 +1,19 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.commands; + +import org.geysermc.geyser.api.command.Command; +import org.geysermc.geyser.api.command.CommandExecutor; +import org.geysermc.geyser.api.connection.GeyserConnection; +import org.geysermc.geyser.entity.type.Entity; +import org.geysermc.geyser.entity.type.player.AvatarEntity; +import org.geysermc.geyser.session.GeyserSession; +import org.jspecify.annotations.NonNull; +import org.redlance.dima_dencep.mods.emotecraft.geyser.animator.ControllerHolder; + +public class FixGeometryCommand implements CommandExecutor { + @Override + public void execute(@NonNull GeyserConnection source, @NonNull Command command, @NonNull String[] args) { + for (Entity entity : ((GeyserSession) source).getEntityCache().getEntities().values()) { + if (entity instanceof AvatarEntity avatar) ControllerHolder.INSTANCE.resubscribe(avatar); + } + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/fuckery/ChainedPacketTranslator.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/fuckery/ChainedPacketTranslator.java new file mode 100644 index 000000000..7f8a22076 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/fuckery/ChainedPacketTranslator.java @@ -0,0 +1,8 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.fuckery; + +import org.geysermc.geyser.session.GeyserSession; + +@FunctionalInterface +public interface ChainedPacketTranslator { + boolean translate(GeyserSession session, T packet); +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/fuckery/GayserHacks.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/fuckery/GayserHacks.java new file mode 100644 index 000000000..63bbb8441 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/fuckery/GayserHacks.java @@ -0,0 +1,41 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.fuckery; + +import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket; +import org.geysermc.geyser.registry.PacketTranslatorRegistry; +import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.translator.protocol.PacketTranslator; +import org.geysermc.mcprotocollib.network.packet.Packet; + +@SuppressWarnings({"unchecked","rawtypes"}) +public class GayserHacks { + public static void addCustomBedrockTranslator(Class packet, ChainedPacketTranslator chained) { + GayserHacks.addCustomTranslator(Registries.BEDROCK_PACKET_TRANSLATORS, packet, chained); + } + + public static void addCustomJavaTranslator(Class packet, ChainedPacketTranslator chained) { + GayserHacks.addCustomTranslator(Registries.JAVA_PACKET_TRANSLATORS, packet, chained); + } + + public static void addCustomTranslator(PacketTranslatorRegistry registry, Class packet, ChainedPacketTranslator chained) { + PacketTranslator translator = (PacketTranslator) registry.get(packet); + registry.register(packet, new WrappedTranslator<>(translator, chained)); + } + + private static class WrappedTranslator extends PacketTranslator { + private final PacketTranslator original; + private final ChainedPacketTranslator chained; + + private WrappedTranslator(PacketTranslator original, ChainedPacketTranslator chained) { + this.original = original; + this.chained = chained; + } + + @Override + public void translate(GeyserSession session, T packet) { + if (this.chained.translate(session, packet) && this.original != null) { + this.original.translate(session, packet); + } + } + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/fuckery/ReflectHacks.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/fuckery/ReflectHacks.java new file mode 100644 index 000000000..fdb1e134a --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/fuckery/ReflectHacks.java @@ -0,0 +1,39 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.fuckery; + +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.mcprotocollib.protocol.MinecraftProtocol; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.lang.reflect.Field; + +import static io.netty.util.internal.shaded.org.jctools.util.UnsafeAccess.UNSAFE; + +public class ReflectHacks { + @SuppressWarnings("removal") + public static final MethodHandles.Lookup TRUSTED_LOOKUP = uncheck(() -> { + Field hackfield = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP"); + return (MethodHandles.Lookup) UNSAFE.getObject(UNSAFE.staticFieldBase(hackfield), UNSAFE.staticFieldOffset(hackfield)); + }); + + private static final VarHandle GEYSER_SESSION_PROTOCOL = ReflectHacks.uncheck(() -> ReflectHacks.TRUSTED_LOOKUP.findVarHandle( + GeyserSession.class, "protocol", MinecraftProtocol.class + )); + + public static MinecraftProtocol getProtocol(GeyserSession session) { + return (MinecraftProtocol) GEYSER_SESSION_PROTOCOL.get(session); + } + + @FunctionalInterface + public interface Supplier_WithExceptions { + T get() throws E; + } + + public static R uncheck(Supplier_WithExceptions supplier) { + try { + return supplier.get(); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/handler/ConnectionType.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/handler/ConnectionType.java new file mode 100644 index 000000000..89d8e080c --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/handler/ConnectionType.java @@ -0,0 +1,16 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.handler; + +import org.jetbrains.annotations.Nullable; + +public enum ConnectionType { + NONE("emotecraft.no_server"), + BACKEND(null)/*, + PROXY("emotecraft.only_proxy")*/; + + @Nullable + public final String translation; + + ConnectionType(@Nullable String translation) { + this.translation = translation; + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/handler/GeyserNetworkInstance.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/handler/GeyserNetworkInstance.java new file mode 100644 index 000000000..71c6c3d26 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/handler/GeyserNetworkInstance.java @@ -0,0 +1,269 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.handler; + +import com.zigythebird.playeranimcore.animation.Animation; +import com.zigythebird.playeranimcore.event.EventResult; +import io.github.kosmx.emotes.api.events.client.ClientEmoteEvents; +import io.github.kosmx.emotes.api.proxy.AbstractNetworkInstance; +import io.github.kosmx.emotes.common.CommonData; +import io.github.kosmx.emotes.common.network.EmotePacket; +import io.github.kosmx.emotes.common.network.PacketConfig; +import io.github.kosmx.emotes.common.network.objects.NetData; +import io.github.kosmx.emotes.common.tools.MathHelper; +import io.github.kosmx.emotes.common.tools.UUIDMap; +import io.github.kosmx.emotes.server.serializer.UniversalEmoteSerializer; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.geysermc.cumulus.form.SimpleForm; +import org.geysermc.geyser.api.connection.GeyserConnection; +import org.geysermc.geyser.entity.type.player.AvatarEntity; +import org.geysermc.geyser.entity.type.player.PlayerEntity; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.mcprotocollib.protocol.packet.common.serverbound.ServerboundCustomPayloadPacket; +import org.jetbrains.annotations.Nullable; +import org.redlance.dima_dencep.mods.emotecraft.geyser.EmotecraftExt; +import org.redlance.dima_dencep.mods.emotecraft.geyser.animator.ControllerHolder; +import org.redlance.dima_dencep.mods.emotecraft.geyser.utils.*; + +import java.io.IOException; +import java.util.*; + +public class GeyserNetworkInstance extends AbstractNetworkInstance { + private final HashMap versions = new HashMap<>(); + // private final Map queue = new ConcurrentHashMap<>(); + private final UUIDMap animations = new UUIDMap<>(); + private final GeyserConnection session; + + private UUID currentEmote; + private ConnectionType connectionType = ConnectionType.NONE; + + public GeyserNetworkInstance(GeyserConnection session) { + this.session = session; + } + + @Override + public HashMap getRemoteVersions() { + return this.versions; + } + + @Override + public void setVersions(Map map) { + this.versions.clear(); + this.versions.putAll(map); + } + + @Override + public void sendMessage(EmotePacket.Builder builder, @Nullable UUID target) throws IOException { + super.sendMessage(builder.setVersion(getRemoteVersions()), target); + } + + @Override + public void sendMessage(EmotePacket packet, @Nullable UUID target) { + ByteBuf buf = Unpooled.buffer(); + try { + packet.write(buf); + ((GeyserSession) this.session).sendDownstreamPacket(new ServerboundCustomPayloadPacket( + EmotecraftExt.EMOTECRAFT_EMOTE_TYPE, MathHelper.readBytes(buf) + )); + } finally { + buf.release(); + } + } + + @Override + public void receiveMessage(EmotePacket packet, UUID player) { + try { + NetData data = packet.data; + if (!trustReceivedPlayer()) { + data.player = null; + } + if (data.player == null && data.purpose.playerBound) { + throw new IOException("Didn't received any player information"); + } + + CommonData.LOGGER.debug("[emotes client] Received message: {}", data); + if (data.purpose == null) { + CommonData.LOGGER.warn("Packet execution is not possible without a purpose"); + return; + } + + handleNetData(data); + } catch (Throwable th) { + throw new RuntimeException(th); + } + } + + private void handleNetData(NetData data) { + switch (Objects.requireNonNull(data.purpose)) { + case STREAM: + assert data.emoteData != null; + AvatarEntity avatarEntity = getAvatarFromUUID(data.player); + + EventResult result = ClientEmoteEvents.EMOTE_VERIFICATION.invoker().verify(data.emoteData, data.player); + if (result == EventResult.FAIL) break; + + if (avatarEntity != null) { + playEmote(avatarEntity, data.emoteData, null); + } /*else { + // this.queue.put(data.player, new QueueEntry(data.emoteData, data.tick, ClientMethods.getCurrentTick())); + }*/ + break; + + case STOP: + assert data.stopEmoteID != null; + AvatarEntity avatar = getAvatarFromUUID(data.player); + + if (avatar != null) { + stopEmote(avatar, data.stopEmoteID); + if (isMainAvatar(avatar) && !data.isForced) { + sendChatMessage("emotecraft.blockedEmote"); + } + } else { + // this.queue.remove(data.player); + CommonData.LOGGER.warn("Queue is not supported!"); + } + break; + case CONFIG: + setVersions(Objects.requireNonNull(data.versions)); + break; + + case FILE: + this.animations.add(data.emoteData); + break; + + case UNKNOWN: + CommonData.LOGGER.warn("Packet execution is not possible unknown purpose"); + break; + } + } + + public void showEmoteList() { + SimpleForm.Builder builder = SimpleForm.builder() + .translator(EmotecraftLocale::getLocaleString, this.session.locale()) + .title(CommonData.MOD_NAME); + if (this.connectionType.translation != null) builder.content(this.connectionType.translation); + + for (Animation animation : UniversalEmoteSerializer.getLoadedEmotes().values()) { + builder.button(FormUtils.createButtonComponent(animation, this.session.locale())); + } + for (Animation animation : this.animations.values()) { + builder.button(FormUtils.createButtonComponent(animation, this.session.locale())); + } + + SimpleForm simpleForm = builder.validResultHandler((form, response) -> { + UUID emoteId = FormUtils.extractAnimationFromButton(response.clickedButton()); + Animation animation = this.animations.getOrDefault(emoteId, UniversalEmoteSerializer.getEmote(emoteId)); + if (animation != null) playEmote(animation, null); + }).build(); + this.session.sendForm(simpleForm); + } + + public void stopEmote(@Nullable UUID stopEmoteID) { + stopEmote((AvatarEntity) this.session.entities().playerEntity(), stopEmoteID); + } + + public void stopEmote(AvatarEntity avatarEntity, @Nullable UUID stopEmoteID) { + if (stopEmoteID != null) { // TODO check + ClientEmoteEvents.EMOTE_STOP.invoker().onEmoteStop(stopEmoteID, avatarEntity.getUuid()); + } + + ControllerHolder.INSTANCE.get(avatarEntity).stop(); + BedrockPacketsUtils.sendBobAnimation(avatarEntity); + + if (isMainAvatar(avatarEntity) && this.currentEmote != null) { + if (stopEmoteID == null) { // TODO check + ClientEmoteEvents.EMOTE_STOP.invoker().onEmoteStop(this.currentEmote, avatarEntity.getUuid()); + } + + try { + sendMessage(new EmotePacket.Builder().configureToSendStop(this.currentEmote), null); + } catch (IOException e) { + CommonData.LOGGER.warn("Failed to stop animation!", e); + } + + this.currentEmote = null; + } + } + + public void sendChatMessage(String key) { + this.session.sendMessage(EmotecraftLocale.getLocaleString(key, this.session.locale())); + } + + public void playEmote(Animation animation, String bedrockId) { + playEmote((AvatarEntity) this.session.entities().playerEntity(), animation, bedrockId); + } + + public void playEmote(AvatarEntity avatarEntity, Animation animation, String bedrockId) { + ClientEmoteEvents.EMOTE_PLAY.invoker().onEmotePlay(animation, 0, this.session.javaUuid()); + + if (isMainAvatar(avatarEntity)) { + try { + sendMessage(new EmotePacket.Builder().configureToStreamEmote(animation), null); + } catch (IOException e) { + throw new RuntimeException(e); + } + this.currentEmote = animation.get(); + } + + if (avatarEntity instanceof PlayerEntity player && bedrockId != null) { + this.session.entities().showEmote(player, bedrockId); + } else { + ControllerHolder.INSTANCE.get(avatarEntity).triggerAnimation(animation, 0); + } + } + + public AvatarEntity getAvatarFromUUID(UUID uuid) { + return GeyserEntityUtils.getAvatarByUUID((GeyserSession) this.session, uuid); + } + + public boolean isMainAvatar(AvatarEntity avatarEntity) { + return ((AvatarEntity) this.session.entities().playerEntity()).getUuid().equals(avatarEntity.getUuid()); + } + + @Override + public boolean isActive() { + return this.session != null; + } + + @Override + public boolean isServerTrackingPlayState() { + return this.versions.get(PacketConfig.SERVER_TRACK_EMOTE_PLAY) != 0; + } + + @Override + public boolean sendPlayerID() { + return !isServerTrackingPlayState(); + } + + public void sendC2SConfig() { + sendC2SConfig(payload -> { + try { + sendMessage(payload, null); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + public void setConnectionType(ConnectionType type) { + this.connectionType = type; + } + + public ConnectionType getConnectionType() { + return this.connectionType; + } + + public boolean isPlaying() { + return this.currentEmote != null; + } + + @Override + public void disconnect() { + if (this.currentEmote != null) { + stopEmote(this.currentEmote); + this.currentEmote = null; + } + this.connectionType = ConnectionType.NONE; + this.animations.clear(); + this.versions.clear(); + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/BedrockEmoteLoader.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/BedrockEmoteLoader.java new file mode 100644 index 000000000..69e219c09 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/BedrockEmoteLoader.java @@ -0,0 +1,94 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.utils; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.gson.JsonObject; +import com.zigythebird.playeranimcore.animation.Animation; +import com.zigythebird.playeranimcore.loading.UniversalAnimLoader; +import com.zigythebird.playeranimcore.util.JsonUtil; +import io.github.kosmx.emotes.common.CommonData; +import org.jetbrains.annotations.NotNull; +import org.redlance.dima_dencep.mods.emotecraft.geyser.EmotecraftExt; +import org.redlance.dima_dencep.mods.emotecraft.geyser.utils.resourcepack.EmoteResourcePack; + +import java.io.*; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +public class BedrockEmoteLoader extends CacheLoader> { + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + // .version(HttpClient.Version.HTTP_2) + .followRedirects(HttpClient.Redirect.ALWAYS) + .build(); + + private static final LoadingCache> BEDROCK_KEYFRAMES = CacheBuilder.newBuilder() + .maximumSize(128) + .expireAfterAccess(5, TimeUnit.HOURS) + .build(new BedrockEmoteLoader()); + + @Override + public @NotNull CompletableFuture load(@NotNull String emoteId) { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://api.redlance.org/bugrock/v1/marketplace/get-emote-by-uuid")) + .header("user-agent", EmotecraftExt.getInstance().description().toString()) + .POST(HttpRequest.BodyPublishers.ofString(emoteId)) + .build(); + + CommonData.LOGGER.debug("Sending request: {}", request); + return BedrockEmoteLoader.HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()) + .thenApply(this::parseAnimation) + .exceptionally(throwable -> { + BedrockEmoteLoader.BEDROCK_KEYFRAMES.invalidate(emoteId); + CommonData.LOGGER.error("Failed to load emote!", throwable); + return null; + }); + } + + private Animation parseAnimation(HttpResponse response) { + try (Reader reader = new InputStreamReader(response.body())) { + JsonObject obj = EmoteResourcePack.GSON.fromJson(reader, JsonObject.class); + + if (!obj.has("present") || !obj.get("present").getAsBoolean()) { + throw new NullPointerException(JsonUtil.getAsString(obj, "message", "Animation is not present!")); + + } else if (obj.has("message")) { + CommonData.LOGGER.warn(obj.get("message").getAsString()); + } + + for (Map.Entry animation : UniversalAnimLoader.loadAnimations(obj.getAsJsonObject("emotes")).entrySet()) { + return animation.getValue(); + } + + throw new NullPointerException(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public static void preloadEmotes(List emotes) { + for (UUID emoteId : emotes) { + CommonData.LOGGER.debug("Preloading emote {}...", emoteId); + try { + BedrockEmoteLoader.BEDROCK_KEYFRAMES.get(emoteId.toString()); + } catch (Throwable th) { + CommonData.LOGGER.error("Failed to preload emote: {}", emoteId, th); + } + } + } + + public static CompletableFuture loadEmote(String emoteId) { + try { + return BedrockEmoteLoader.BEDROCK_KEYFRAMES.get(emoteId); + } catch (Throwable th) { + return CompletableFuture.failedFuture(th); + } + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/BedrockPacketsUtils.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/BedrockPacketsUtils.java new file mode 100644 index 000000000..0251ff202 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/BedrockPacketsUtils.java @@ -0,0 +1,27 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.utils; + +import org.cloudburstmc.protocol.bedrock.packet.AnimateEntityPacket; +import org.geysermc.geyser.entity.type.player.AvatarEntity; + +public class BedrockPacketsUtils { + public static final String BOB = "animation.player.bob"; + + public static void sendInstantAnimation(String animation, AvatarEntity playerEntity) { + sendAnimation(animation, "0", playerEntity); + } + + public static void sendBobAnimation(AvatarEntity playerEntity) { + sendAnimation(BOB, "1", playerEntity); + } + + public static void sendAnimation(String animation, String stopExpression, AvatarEntity playerEntity) { + AnimateEntityPacket animatePacket = new AnimateEntityPacket(); + animatePacket.setAnimation(animation); + animatePacket.setNextState("default"); + animatePacket.setBlendOutTime(0.0f); + animatePacket.setStopExpression(stopExpression); + animatePacket.setController("__runtime_controller"); + animatePacket.getRuntimeEntityIds().add(playerEntity.getGeyserId()); + playerEntity.getSession().sendUpstreamPacketImmediately(animatePacket); + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/DinnerboneProtocolUtils.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/DinnerboneProtocolUtils.java new file mode 100644 index 000000000..72379b604 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/DinnerboneProtocolUtils.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package org.redlance.dima_dencep.mods.emotecraft.geyser.utils; + +import io.github.kosmx.emotes.common.CommonData; +import io.netty.buffer.ByteBuf; +import net.kyori.adventure.key.Key; + +import java.util.HashSet; +import java.util.Set; + +/** + * Protocol utilities for communicating over Dinnerbone's protocol. + */ +public class DinnerboneProtocolUtils { + + /** + * Reads a set of channels from the buffer. + * Each channel is a null-terminated string. + * If a string is not a valid channel, it is ignored. + * + * @param buf the buffer + * @return the channels + */ + public static Set readChannels(ByteBuf buf) { + final StringBuilder builder = new StringBuilder(); + final Set channels = new HashSet<>(); + + while (buf.isReadable()) { + final char c = (char) buf.readByte(); + if (c == '\0') { + parseAndAddChannel(builder, channels); + } else { + builder.append(c); + } + } + + parseAndAddChannel(builder, channels); + + return channels; + } + + public static void parseAndAddChannel(StringBuilder builder, Set channels) { + if (builder.isEmpty()) { + return; + } + + final String channel = builder.toString(); + try { + channels.add(Key.key(channel)); + } catch (Exception e) { + CommonData.LOGGER.error("Invalid channel: '{}'!", channel, e); + } finally { + builder.setLength(0); + } + } + + /** + * Writes a set of channels to the buffer. + * Each channel is a null-terminated string. + * + * @param buf the buffer + * @param channels the channels + */ + public static void writeChannels(ByteBuf buf, Set channels) { + for (Key channel : channels) { + for (char c : channel.asString().toCharArray()) { + buf.writeByte(c); + } + buf.writeByte('\0'); + } + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/EmotecraftLocale.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/EmotecraftLocale.java new file mode 100644 index 000000000..98d50b1d4 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/EmotecraftLocale.java @@ -0,0 +1,55 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.utils; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.github.kosmx.emotes.common.CommonData; +import org.geysermc.geyser.text.GeyserLocale; +import org.geysermc.geyser.util.JsonUtils; + +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +public class EmotecraftLocale { + private static final Map> LOCALE_MAPPINGS = new HashMap<>(); + + static { + loadLocale("en_us"); + loadLocale(GeyserLocale.getDefaultLocale().toLowerCase(Locale.ROOT)); + } + + public static void loadLocale(String locale) { + if (LOCALE_MAPPINGS.containsKey(locale)) return; + + try (InputStream localeStream = EmotecraftLocale.class.getResourceAsStream("/assets/emotecraft/lang/" + locale + ".json")) { + JsonObject localeObj = JsonUtils.fromJson(Objects.requireNonNull(localeStream)); + Map langMap = new HashMap<>(); + for (Map.Entry entry : localeObj.entrySet()) { + langMap.put(entry.getKey(), entry.getValue().getAsString()); + } + LOCALE_MAPPINGS.put(locale, langMap); + } catch (FileNotFoundException e) { + throw new AssertionError(GeyserLocale.getLocaleStringLog("geyser.locale.fail.file", locale, e.getMessage())); + } catch (Exception e) { + throw new AssertionError(GeyserLocale.getLocaleStringLog("geyser.locale.fail.json", locale), e); + } + } + + public static String getLocaleString(String messageText, String locale) { + loadLocale(locale.toLowerCase(Locale.ROOT)); + + Map localeStrings = LOCALE_MAPPINGS.get(locale.toLowerCase(Locale.ROOT)); + if (localeStrings == null) { + localeStrings = LOCALE_MAPPINGS.get(GeyserLocale.getDefaultLocale().toLowerCase(Locale.ROOT)); + + if (localeStrings == null) { + CommonData.LOGGER.warn("MISSING DEFAULT LOCALE: {}", GeyserLocale.getDefaultLocale()); + return messageText; + } + } + return localeStrings.getOrDefault(messageText, messageText); + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/FormUtils.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/FormUtils.java new file mode 100644 index 000000000..47922f4cc --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/FormUtils.java @@ -0,0 +1,57 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.utils; + +import com.zigythebird.playeranimcore.animation.Animation; +import net.kyori.adventure.text.Component; +import org.geysermc.cumulus.component.ButtonComponent; +import org.geysermc.cumulus.util.FormImage; +import org.geysermc.geyser.translator.text.MessageTranslator; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.net.URI; +import java.util.UUID; + +public class FormUtils { + private static final Component AUTHOR = Component.translatable("emotecraft.emote.author"); + + public static ButtonComponent createButtonComponent(Animation animation, String locale) { + return ButtonComponent.of(formatAnimationName(animation, locale), FormImage.of(FormImage.Type.URL, + String.format("https://bot.redlance.org/api/emotes/icon/%s.png#%s", animation.boneAnimations().hashCode(), animation.uuid()) + )); + } + + private static String formatAnimationName(Animation animation, String locale) { + if (animation.data().getRaw("name") instanceof String rawName) { + String name = EmotecraftLocale.getLocaleString(MessageTranslator.convertMessageLenient(rawName, locale), locale); // TODO fallbacks + + /*if (animation.data().getRaw("description") instanceof String description) { // Doesn't fit + name = String.format("%s\n%s", name, EmotecraftLocale.getLocaleString( + MessageTranslator.convertMessageLenient(description, locale), locale + )); + }*/ + + if (animation.data().getRaw("author") instanceof String rawAuthor) { + String author = EmotecraftLocale.getLocaleString(MessageTranslator.convertMessage(AUTHOR, locale), locale); + name = String.format("%s\n(%s%s)", name, author, MessageTranslator.convertMessageLenient(rawAuthor, locale)); + } + + return name; + } + return String.format("INVALID: %s", animation.uuid()); + } + + @Nullable + public static UUID extractAnimationFromButton(@NotNull ButtonComponent button) { + FormImage image = button.image(); + if (image == null) return null; + return extractAnimationFromImage(image); + } + + @Nullable + public static UUID extractAnimationFromImage(@NotNull FormImage button) { + return switch (button.type()) { + case URL -> UUID.fromString(URI.create(button.data()).getFragment()); + case PATH -> null; // TODO + }; + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/GeyserEntityUtils.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/GeyserEntityUtils.java new file mode 100644 index 000000000..ac9e87775 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/GeyserEntityUtils.java @@ -0,0 +1,38 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.utils; + +import org.geysermc.geyser.entity.type.Entity; +import org.geysermc.geyser.entity.type.player.AvatarEntity; +import org.geysermc.geyser.entity.type.player.PlayerEntity; +import org.geysermc.geyser.session.GeyserSession; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +public class GeyserEntityUtils { + public static @Nullable AvatarEntity getAvatarByUUID(GeyserSession session, UUID uuid) { + if (session.entities().playerEntity() instanceof AvatarEntity player && player.getUuid().equals(uuid)) { + return player; + } + + PlayerEntity player = session.getEntityCache().getPlayerEntity(uuid); + if (player != null) return player; // Fast + + for (Entity entity : session.getEntityCache().getEntities().values()) { + if (entity instanceof AvatarEntity avatar && avatar.getUuid().equals(uuid)) { + return avatar; + } + } + return null; + } + + public static boolean unsubscribedFromEntity(AvatarEntity entity) { + GeyserSession session = entity.getSession(); + if (session.isClosed()) return true; + + if (session.entities().playerEntity() == entity) { + return false; + } else { + return session.getEntityCache().getEntityByGeyserId(entity.getGeyserId()) == null; + } + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/resourcepack/CollectionAdapter.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/resourcepack/CollectionAdapter.java new file mode 100644 index 000000000..f8964b8f9 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/resourcepack/CollectionAdapter.java @@ -0,0 +1,22 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.utils.resourcepack; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import java.lang.reflect.Type; +import java.util.Collection; + +public final class CollectionAdapter implements JsonSerializer> { + @Override + public JsonElement serialize(Collection src, Type typeOfSrc, JsonSerializationContext ctx) { + if (src == null || src.isEmpty()) return null; + JsonArray array = new JsonArray(); + for (Object child : src) { + array.add(ctx.serialize(child)); + } + return array; + } +} + diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/resourcepack/EmoteResourcePack.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/resourcepack/EmoteResourcePack.java new file mode 100644 index 000000000..56246bf8a --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/resourcepack/EmoteResourcePack.java @@ -0,0 +1,253 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.utils.resourcepack; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.zigythebird.playeranimcore.enums.Axis; +import com.zigythebird.playeranimcore.enums.TransformType; +import com.zigythebird.playeranimcore.loading.UniversalAnimLoader; +import io.github.kosmx.emotes.common.CommonData; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.event.PostOrder; +import org.geysermc.event.subscribe.Subscribe; +import org.geysermc.geyser.api.entity.property.type.GeyserIntEntityProperty; +import org.geysermc.geyser.api.event.EventRegistrar; +import org.geysermc.geyser.api.event.lifecycle.GeyserDefineEntityPropertiesEvent; +import org.geysermc.geyser.api.event.lifecycle.GeyserDefineResourcePacksEvent; +import org.geysermc.geyser.api.pack.PackCodec; +import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.ResourcePackManifest; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.pack.GeyserResourcePack; +import org.geysermc.geyser.pack.GeyserResourcePackManifest; +import org.redlance.dima_dencep.mods.emotecraft.geyser.animator.geometry.BendingGeometry; +import org.redlance.dima_dencep.mods.emotecraft.geyser.animator.GeyserAnimationController; +import org.redlance.dima_dencep.mods.emotecraft.geyser.animator.PackedProperty; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +@SuppressWarnings("unused") +public final class EmoteResourcePack extends PackCodec implements EventRegistrar { + private static final Identifier PLAYER_IDENTIFIER = Identifier.of(Identifier.DEFAULT_NAMESPACE, "player"); + public static final String ANIMATION_NAME = String.format("animation.%s", CommonData.MOD_ID); + + public static final Gson GSON = new GsonBuilder() + .registerTypeAdapter(GeyserResourcePackManifest.Version.class, new ResourcePackVersionSerializer()) + .registerTypeHierarchyAdapter(Collection.class, new CollectionAdapter()) + .disableHtmlEscaping() + .create(); + + private final Map>> identifiers = new HashMap<>(); + private final Set registeredProperties = new HashSet<>(1); + private final ResourcePackManifest manifest; + + private String molangScript; + private byte[] packData; + private byte[] sha256; + + public EmoteResourcePack(GeyserResourcePackManifest.Version version, String name, String description) { + this(new GeyserResourcePackManifest(2, new GeyserResourcePackManifest.Header( + UUID.randomUUID(), version, name, description, new GeyserResourcePackManifest.Version(1, 16, 0)), + + Collections.singleton(new GeyserResourcePackManifest.Module( + UUID.randomUUID(), version, "resources", description + )), null, null, null + )); + } + + private EmoteResourcePack(ResourcePackManifest manifest) { + this.manifest = manifest; + } + + private JsonObject generateAnimation() { + JsonObject bones = new JsonObject(); + + int id = -98; + + this.identifiers.clear(); + for (String boneName : GeyserAnimationController.getRegisteredBones()) { + JsonObject bone = new JsonObject(); + + EnumMap> transformType = this.identifiers.computeIfAbsent(boneName, + k -> new EnumMap<>(TransformType.class) + ); + + bone.add("rotation", generateBone(true, id++, id++, id++, 0, transformType.computeIfAbsent( + TransformType.ROTATION, k -> new EnumMap<>(Axis.class) + ))); + bone.add("position", generateBone(false, id++, id++, id++, 0, transformType.computeIfAbsent( + TransformType.POSITION, k -> new EnumMap<>(Axis.class) + ))); + if (!boneName.endsWith(BendingGeometry.BEND_SUFFIX)) { + bone.add("scale", generateBone(false, id++, id++, id++, 1, transformType.computeIfAbsent( + TransformType.SCALE, k -> new EnumMap<>(Axis.class) + ))); + } + + String bedrockBone = UniversalAnimLoader.restorePlayerBoneName(boneName); + if ("body".equals(bedrockBone)) bedrockBone = "waist"; + if ("torso".equals(bedrockBone)) bedrockBone = "body"; + bones.add(bedrockBone, bone); + } + + JsonObject animation = new JsonObject(); + animation.addProperty("animation_length", 1.0F); + animation.addProperty("loop", true); + animation.addProperty("override_previous_animation", true); + animation.add("bones", bones); + + JsonObject container = new JsonObject(); + container.add(ANIMATION_NAME, animation); + + JsonObject parent = new JsonObject(); + parent.add("animations", container); + parent.addProperty("format_version", "1.8.0"); + + return parent; + } + + private byte[] generatePackData() { + if (this.packData != null) return this.packData; + + try ( + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos) + ) { + String manifestJson = EmoteResourcePack.GSON.toJson(this.manifest); + zos.putNextEntry(new ZipEntry("manifest.json")); + zos.write(manifestJson.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + + try (InputStream is = EmoteResourcePack.class.getClassLoader().getResourceAsStream("emotecraft_mod_logo.png")) { + zos.putNextEntry(new ZipEntry("pack_icon.png")); + zos.write(Objects.requireNonNull(is).readAllBytes()); + zos.closeEntry(); + } catch (Throwable th) { + CommonData.LOGGER.warn("Failed to put icon!", th); + } + + String animationJson = generateAnimation().toString(); + zos.putNextEntry(new ZipEntry("animations/player.animation.json")); + zos.write(animationJson.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + + zos.finish(); + return this.packData = baos.toByteArray(); + } catch (IOException e) { + throw new RuntimeException("Failed to generate resource pack", e); + } + } + + private JsonArray generateBone(boolean rotate, int x, int y, int z, float defaultValue, EnumMap axisMap) { + axisMap.put(Axis.X, x); + axisMap.put(Axis.Y, y); + axisMap.put(Axis.Z, z); + + JsonArray axis = new JsonArray(); + axis.add(this.molangScript + .replace("{LERP_VALUE}", rotate ? "0" : "1") + .replace("{BONE_ID_RAW}", String.valueOf(x)) + .replace("{BONE_ID}", String.valueOf(x).replace("-", "_")) + .replace("{DEFAULT_VALUE}", String.valueOf(defaultValue)) + ); + axis.add(this.molangScript + .replace("{LERP_VALUE}", rotate ? "0" : "1") + .replace("{BONE_ID_RAW}", String.valueOf(y)) + .replace("{BONE_ID}", String.valueOf(y).replace("-", "_")) + .replace("{DEFAULT_VALUE}", String.valueOf(defaultValue)) + ); + axis.add(this.molangScript + .replace("{LERP_VALUE}", rotate ? "0" : "1") + .replace("{BONE_ID_RAW}", String.valueOf(z)) + .replace("{BONE_ID}", String.valueOf(z).replace("-", "_")) + .replace("{DEFAULT_VALUE}", String.valueOf(defaultValue)) + ); + return axis; + } + + @Override + public byte @NonNull [] sha256() { + if (this.sha256 != null) { + return this.sha256; + } + + try { + return this.sha256 = MessageDigest.getInstance("SHA-256").digest(generatePackData()); + } catch (Exception e) { + throw new RuntimeException("Could not calculate pack hash", e); + } + } + + @Override + public long size() { + return generatePackData().length; + } + + @Override + public @NonNull SeekableByteChannel serialize() { + return new SeekableInMemoryByteChannel(generatePackData()); + } + + @Override + protected @NonNull ResourcePack create() { + return new GeyserResourcePack(this, this.manifest, ""); + } + + @Override + protected ResourcePack.@NonNull Builder createBuilder() { + throw new UnsupportedOperationException(); + } + + @Subscribe(postOrder = PostOrder.LAST) + public void onDefineEntityProperties(GeyserDefineEntityPropertiesEvent event) { + int reserved = /*Math.max(1, (32 - event.properties(PLAYER_IDENTIFIER).size()) / 3)*/32; + CommonData.LOGGER.info("{} properties will be reserved by emotecraft! Please ignore the warnings below...", reserved); + + StringBuilder molangScript = new StringBuilder("variable.bone_{BONE_ID} = variable.bone_{BONE_ID} ?? {DEFAULT_VALUE};"); + molangScript.append("variable.bone_{BONE_ID}_target = variable.bone_{BONE_ID}_target ?? {DEFAULT_VALUE};"); + + this.registeredProperties.clear(); + for (int i = 0; i < reserved; i++) { + Identifier identifier = Identifier.of(CommonData.MOD_ID, String.format("property_%s", i)); + this.registeredProperties.add(event.registerIntegerProperty(PLAYER_IDENTIFIER, identifier, PackedProperty.PROP_MIN, PackedProperty.PROP_MAX_USED)); + + String prop = "variable." + identifier.path(); + + molangScript.append(prop).append(" = q.property('").append(identifier).append("');"); + molangScript.append("variable._raw = ").append(prop).append(" + 1000000;"); + molangScript.append("variable._id = math.floor(variable._raw / ").append(PackedProperty.SLOT).append(") - 99;"); + molangScript.append("variable._val = (math.mod(variable._raw, ").append(PackedProperty.SLOT).append(") - ").append(PackedProperty.CENTER).append(") / ").append(PackedProperty.SCALE).append(";"); + molangScript.append("variable.bone_{BONE_ID}_target = (variable._id == {BONE_ID_RAW}) ? ").append("variable._val : variable.bone_{BONE_ID}_target;"); + } + + molangScript.append("variable.bone_{BONE_ID} = ({LERP_VALUE} == 1) ? ("); + molangScript.append("(math.abs(variable.bone_{BONE_ID} - variable.bone_{BONE_ID}_target) < 0.001) ? variable.bone_{BONE_ID}_target : math.lerp(variable.bone_{BONE_ID}, variable.bone_{BONE_ID}_target, 0.2)"); + molangScript.append(") : variable.bone_{BONE_ID}_target;"); + molangScript.append("return variable.bone_{BONE_ID};"); + + CommonData.LOGGER.debug("Registered {} properties!", this.registeredProperties.size()); + this.molangScript = molangScript.toString(); + } + + public Set getRegisteredProperties() { + return Collections.unmodifiableSet(this.registeredProperties); + } + + public Map getAxisIds(String part, TransformType type) { + return Collections.unmodifiableMap(this.identifiers.get(part).get(type)); + } + + @Subscribe + public void onDefineResourcePacks(GeyserDefineResourcePacksEvent event) { + event.register(create()); + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/resourcepack/ResourcePackVersionSerializer.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/resourcepack/ResourcePackVersionSerializer.java new file mode 100644 index 000000000..577bc1ec2 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/resourcepack/ResourcePackVersionSerializer.java @@ -0,0 +1,17 @@ +package org.redlance.dima_dencep.mods.emotecraft.geyser.utils.resourcepack; + +import com.google.gson.*; +import org.geysermc.geyser.pack.GeyserResourcePackManifest; + +import java.lang.reflect.Type; + +public class ResourcePackVersionSerializer extends GeyserResourcePackManifest.Version.VersionDeserializer implements JsonDeserializer, JsonSerializer { + @Override + public JsonElement serialize(GeyserResourcePackManifest.Version version, Type typeOfSrc, JsonSerializationContext ctx) { + JsonArray array = new JsonArray(3); + array.add(version.major()); + array.add(version.minor()); + array.add(version.patch()); + return array; + } +} diff --git a/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/resourcepack/SeekableInMemoryByteChannel.java b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/resourcepack/SeekableInMemoryByteChannel.java new file mode 100644 index 000000000..d046a7347 --- /dev/null +++ b/geyser/src/main/java/org/redlance/dima_dencep/mods/emotecraft/geyser/utils/resourcepack/SeekableInMemoryByteChannel.java @@ -0,0 +1,208 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.redlance.dima_dencep.mods.emotecraft.geyser.utils.resourcepack; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.channels.SeekableByteChannel; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A {@link SeekableByteChannel} implementation that wraps a byte[]. + *

+ * When this channel is used for writing an internal buffer grows to accommodate incoming data. The natural size limit is the value of {@link Integer#MAX_VALUE} + * and it is not possible to {@link #position(long) set the position} or {@link #truncate truncate} to a value bigger than that. Internal buffer can be accessed + * via {@link SeekableInMemoryByteChannel#array()}. + *

+ * + * @since 1.13 + * @NotThreadSafe + */ +public class SeekableInMemoryByteChannel implements SeekableByteChannel { + + private static final int NAIVE_RESIZE_LIMIT = Integer.MAX_VALUE >> 1; + + private byte[] data; + private final AtomicBoolean closed = new AtomicBoolean(); + private int position, size; + + /** + * Constructs a new instance from a byte array. + *

+ * This constructor is intended to be used with pre-allocated buffer or when reading from a given byte array. + *

+ * + * @param data input data or pre-allocated array. + */ + public SeekableInMemoryByteChannel(final byte[] data) { + this.data = data; + this.size = data.length; + } + + /** + * Constructs a new instance from a size of storage to be allocated. + *

+ * Creates a channel and allocates internal storage of a given size. + *

+ * + * @param size size of internal buffer to allocate, in bytes. + */ + public SeekableInMemoryByteChannel(final int size) { + this(new byte[size]); + } + + /** + * Obtains the array backing this channel. + *

+ * NOTE: The returned buffer is not aligned with containing data, use {@link #size()} to obtain the size of data stored in the buffer. + *

+ * + * @return internal byte array. + */ + public byte[] array() { + return data; + } + + @Override + public void close() { + closed.set(true); + } + + private void ensureOpen() throws ClosedChannelException { + if (!isOpen()) { + throw new ClosedChannelException(); + } + } + + @Override + public boolean isOpen() { + return !closed.get(); + } + + /** + * Returns this channel's position. + *

+ * This method violates the contract of {@link SeekableByteChannel#position()} as it will not throw any exception when invoked on a closed channel. Instead + * it will return the position the channel had when close has been called. + *

+ */ + @Override + public long position() { + return position; + } + + @Override + public SeekableByteChannel position(final long newPosition) throws IOException { + ensureOpen(); + if (newPosition < 0L || newPosition > Integer.MAX_VALUE) { + throw new IOException("Position has to be in range 0.. " + Integer.MAX_VALUE); + } + position = (int) newPosition; + return this; + } + + @Override + public int read(final ByteBuffer buf) throws IOException { + ensureOpen(); + int wanted = buf.remaining(); + final int possible = size - position; + if (possible <= 0) { + return -1; + } + if (wanted > possible) { + wanted = possible; + } + buf.put(data, position, wanted); + position += wanted; + return wanted; + } + + private void resize(final int newLength) { + int len = data.length; + if (len <= 0) { + len = 1; + } + if (newLength < NAIVE_RESIZE_LIMIT) { + while (len < newLength) { + len <<= 1; + } + } else { // avoid overflow + len = newLength; + } + data = Arrays.copyOf(data, len); + } + + /** + * Returns the current size of entity to which this channel is connected. + *

+ * This method violates the contract of {@link SeekableByteChannel#size} as it will not throw any exception when invoked on a closed channel. Instead it + * will return the size the channel had when close has been called. + *

+ */ + @Override + public long size() { + return size; + } + + /** + * Truncates the entity, to which this channel is connected, to the given size. + *

+ * This method violates the contract of {@link SeekableByteChannel#truncate} as it will not throw any exception when invoked on a closed channel. + *

+ * + * @throws IllegalArgumentException if size is negative or bigger than the maximum of a Java integer + */ + @Override + public SeekableByteChannel truncate(final long newSize) { + if (newSize < 0L || newSize > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Size has to be in range 0.. " + Integer.MAX_VALUE); + } + if (size > newSize) { + size = (int) newSize; + } + if (position > newSize) { + position = (int) newSize; + } + return this; + } + + @Override + public int write(final ByteBuffer b) throws IOException { + ensureOpen(); + int wanted = b.remaining(); + final int possibleWithoutResize = size - position; + if (wanted > possibleWithoutResize) { + final int newSize = position + wanted; + if (newSize < 0) { // overflow + resize(Integer.MAX_VALUE); + wanted = Integer.MAX_VALUE - position; + } else { + resize(newSize); + } + } + b.get(data, position, wanted); + position += wanted; + if (size < position) { + size = position; + } + return wanted; + } + +} diff --git a/geyser/src/main/resources/META-INF/services/io.github.kosmx.emotes.server.services.InstanceService b/geyser/src/main/resources/META-INF/services/io.github.kosmx.emotes.server.services.InstanceService new file mode 100644 index 000000000..1102bbde7 --- /dev/null +++ b/geyser/src/main/resources/META-INF/services/io.github.kosmx.emotes.server.services.InstanceService @@ -0,0 +1 @@ +org.redlance.dima_dencep.mods.emotecraft.geyser.EmotecraftService \ No newline at end of file diff --git a/geyser/src/main/resources/extension.yml b/geyser/src/main/resources/extension.yml new file mode 100644 index 000000000..0a3fe8b3e --- /dev/null +++ b/geyser/src/main/resources/extension.yml @@ -0,0 +1,6 @@ +id: emotecraft +name: EmotecraftExt +api: 2.6.1 +main: org.redlance.dima_dencep.mods.emotecraft.geyser.EmotecraftExt +version: ${version} +authors: [dima_dencep, KosmX] diff --git a/gradle.properties b/gradle.properties index 369e8a6db..f84981c33 100644 --- a/gradle.properties +++ b/gradle.properties @@ -32,3 +32,4 @@ loom.ignoreDependencyLoomVersionValidation=true searchables_version = 1.0.2 fabric_permissions_api = 0.6.1 noteblocklib_version = 3.1.1 + geyser_version = 2.9.2-SNAPSHOT diff --git a/settings.gradle.kts b/settings.gradle.kts index 4869c8617..4c4f3021f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,3 +23,6 @@ include("minecraft:neoforge") // Paper plugin include("paper") + +// Geyser ext +include("geyser")