diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 54d00b2f..9442e52b 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -24,9 +24,10 @@ dependencies { compileOnly(group = "io.github.llamalad7", name = "mixinextras-common", version = "0.5.3") annotationProcessor(group = "io.github.llamalad7", name = "mixinextras-common", version = "0.5.3") compileOnly(group = "org.ow2.asm", name = "asm", version = "9.8") + compileOnly(group = "org.ow2.asm", name = "asm-tree", version = "9.8") compileOnly(group = "com.google.code.findbugs", name = "jsr305", version = "3.0.2") - compileOnly("org.bytedeco:javacv-platform:1.5.11") +// compileOnly("org.bytedeco:javacv-platform:1.5.11") } configurations { @@ -64,3 +65,12 @@ sourceSets.configureEach { } } } + +/*tasks.register("extractRuntimeClasspath") { + from(configurations.runtimeClasspath) + into("$projectDir/build/runtimeClasspath") + + doFirst { + file("$projectDir/build/runtimeClasspath").mkdirs() + } +}*/ diff --git a/common/src/main/java/com/github/epsilon/graphics/video/VideoManager.java b/common/src/main/java/com/github/epsilon/graphics/video/VideoManager.java index 0bb9d991..2d938405 100644 --- a/common/src/main/java/com/github/epsilon/graphics/video/VideoManager.java +++ b/common/src/main/java/com/github/epsilon/graphics/video/VideoManager.java @@ -1,41 +1,41 @@ -package com.github.epsilon.graphics.video; - -import com.github.epsilon.Epsilon; -import com.mojang.logging.LogUtils; -import net.minecraft.client.Minecraft; -import org.apache.commons.io.IOUtils; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.util.Objects; - -public class VideoManager { - - private static final File directory = new File(Minecraft.getInstance().gameDirectory, "Sakura/Background"); - private static final File backgroundFile = new File(directory, "background.mp4"); - - private static void unpackFile(File file, String name) throws IOException { - FileOutputStream fos = new FileOutputStream(file); - IOUtils.copy(Objects.requireNonNull(Epsilon.class.getClassLoader().getResourceAsStream(name)), fos); - fos.close(); - } - - public static void ensureBackgroundFile() throws IOException { - if (!directory.exists()) { - directory.mkdirs(); - } - if (!backgroundFile.exists()) { - unpackFile(backgroundFile, "assets/epsilon/background/wallpaper.mp4"); - } - } - - public static void loadBackground(int fps) throws IOException { - if (!backgroundFile.exists()) { - LogUtils.getLogger().error("Background file not found, this should not happen! Reload files."); - ensureBackgroundFile(); - } - VideoUtil.init(backgroundFile, fps); - } - -} +//package com.github.epsilon.graphics.video; +// +//import com.github.epsilon.Epsilon; +//import com.mojang.logging.LogUtils; +//import net.minecraft.client.Minecraft; +//import org.apache.commons.io.IOUtils; +// +//import java.io.File; +//import java.io.FileOutputStream; +//import java.io.IOException; +//import java.util.Objects; +// +//public class VideoManager { +// +// private static final File directory = new File(Minecraft.getInstance().gameDirectory, "Sakura/Background"); +// private static final File backgroundFile = new File(directory, "background.mp4"); +// +// private static void unpackFile(File file, String name) throws IOException { +// FileOutputStream fos = new FileOutputStream(file); +// IOUtils.copy(Objects.requireNonNull(Epsilon.class.getClassLoader().getResourceAsStream(name)), fos); +// fos.close(); +// } +// +// public static void ensureBackgroundFile() throws IOException { +// if (!directory.exists()) { +// directory.mkdirs(); +// } +// if (!backgroundFile.exists()) { +// unpackFile(backgroundFile, "assets/epsilon/background/wallpaper.mp4"); +// } +// } +// +// public static void loadBackground(int fps) throws IOException { +// if (!backgroundFile.exists()) { +// LogUtils.getLogger().error("Background file not found, this should not happen! Reload files."); +// ensureBackgroundFile(); +// } +// VideoUtil.init(backgroundFile, fps); +// } +// +//} diff --git a/common/src/main/java/com/github/epsilon/graphics/video/VideoUtil.java b/common/src/main/java/com/github/epsilon/graphics/video/VideoUtil.java index 11524837..eec3e571 100644 --- a/common/src/main/java/com/github/epsilon/graphics/video/VideoUtil.java +++ b/common/src/main/java/com/github/epsilon/graphics/video/VideoUtil.java @@ -1,383 +1,383 @@ -package com.github.epsilon.graphics.video; - -import com.github.epsilon.Epsilon; -import com.github.epsilon.assets.resources.ResourceLocationUtils; -import com.mojang.blaze3d.platform.NativeImage; -import com.mojang.blaze3d.systems.CommandEncoder; -import com.mojang.blaze3d.systems.RenderSystem; -import com.mojang.blaze3d.textures.GpuTextureView; -import net.minecraft.client.Minecraft; -import net.minecraft.client.renderer.texture.DynamicTexture; -import net.minecraft.resources.Identifier; -import org.bytedeco.javacv.FFmpegFrameGrabber; -import org.bytedeco.javacv.Frame; - -import java.io.File; -import java.nio.ByteBuffer; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.locks.LockSupport; - -import static org.bytedeco.ffmpeg.global.avutil.AV_PIX_FMT_RGBA; - -public class VideoUtil { - - private static final Minecraft mc = Minecraft.getInstance(); - - private static final VideoGpuTexture VIDEO_TEX = new VideoGpuTexture(); - - private static final Identifier GUI_TEXTURE_ID = ResourceLocationUtils.getIdentifier("dynamic/title_video"); - - private static DynamicTexture registeredTexture; - - private static FFmpegFrameGrabber grabber; - - private static double frameRate; - - private static long mediaStartUs; - private static long wallStartNs; - - - private static volatile long lastPtsUs = 0; - - private static ExecutorService executor; - private static Future decodeFuture; - - private static final Object pauseLock = new Object(); - - private static volatile boolean paused = false; - private static volatile boolean stopped = true; - - private static final Object GRABBER_LOCK = new Object(); - - private static final AtomicReference latestFrame = new AtomicReference<>(); - - private static final int POOL = 4; - private static final ByteBuffer[] pool = new ByteBuffer[POOL]; - private static int poolCursor = 0; - - private static void releaseGuiTexture() { - if (registeredTexture == null) { - return; - } - mc.getTextureManager().release(GUI_TEXTURE_ID); - registeredTexture = null; - } - - private static ByteBuffer acquire(int size) { - FrameBuffer cur = latestFrame.get(); - ByteBuffer curBuf = cur != null ? cur.buffer : null; - - for (int n = 0; n < POOL; n++) { - int idx = (poolCursor + n) % POOL; - ByteBuffer b = pool[idx]; - - if (b == curBuf) continue; - if (b == null || b.capacity() < size) { - b = ByteBuffer.allocateDirect(size); - pool[idx] = b; - } - - poolCursor = (idx + 1) % POOL; - return b; - } - - return ByteBuffer.allocateDirect(size); - } - - private static final class VideoGpuTexture { - private DynamicTexture tex; - private int w = -1, h = -1; - - void close() { - if (tex != null) { - if (registeredTexture == tex) { - releaseGuiTexture(); - } else { - try { - tex.close(); - } catch (Throwable ignored) { - } - } - tex = null; - } - w = h = -1; - } - - GpuTextureView view() { - return tex != null ? tex.getTextureView() : null; - } - - DynamicTexture texture() { - return tex; - } - - int width() { - return w; - } - - int height() { - return h; - } - - void ensureSize(int newW, int newH) { - if (tex != null && w == newW && h == newH) return; - - close(); - tex = new DynamicTexture("video", newW, newH, true); - w = newW; - h = newH; - } - - void uploadRgba(ByteBuffer rgba, int frameW, int frameH) { - ensureSize(frameW, frameH); - - ByteBuffer src = rgba.duplicate(); - src.rewind(); - - CommandEncoder enc = RenderSystem.getDevice().createCommandEncoder(); - enc.writeToTexture( - tex.getTexture(), - src, - NativeImage.Format.RGBA, - 0, - 0, - 0, - 0, - frameW, - frameH - ); - } - } - - public static void init(File file, int fps) { - stop(); - - try { - Epsilon.LOGGER.info("[VideoPlayer] Initializing... {}", file.getAbsolutePath()); - - executor = Executors.newSingleThreadExecutor(r -> { - Thread t = new Thread(r, "VideoUtil-Decode"); - t.setDaemon(true); - return t; - }); - - synchronized (GRABBER_LOCK) { - grabber = new FFmpegFrameGrabber(file); - grabber.setPixelFormat(AV_PIX_FMT_RGBA); - grabber.setOption("threads", "4"); - grabber.start(); - - frameRate = (fps == -1 ? grabber.getFrameRate() : fps); - - Frame first = grabber.grabImage(); - if (first != null && first.image != null) { - FrameBuffer fb = copyFrameLocked(first); - latestFrame.set(fb); - lastPtsUs = (first.timestamp > 0 ? first.timestamp : grabber.getTimestamp()); - } - - mediaStartUs = grabber.getTimestamp(); - wallStartNs = System.nanoTime(); - lastPtsUs = mediaStartUs; - } - - stopped = false; - paused = false; - - startDecodeThread(); - } catch (Throwable e) { - Epsilon.LOGGER.error("[VideoPlayer] Init error:", e); - stop(); - } - } - - public static void pause() { - paused = true; - } - - public static void resume() { - if (!paused) return; - - synchronized (pauseLock) { - paused = false; - wallStartNs = System.nanoTime() - (lastPtsUs - mediaStartUs) * 1000L; - pauseLock.notifyAll(); - } - } - - public static void stop() { - try { - if (stopped && grabber == null) return; - - stopped = true; - - synchronized (pauseLock) { - paused = false; - pauseLock.notifyAll(); - } - - if (decodeFuture != null) { - decodeFuture.cancel(true); - decodeFuture = null; - } - - if (executor != null) { - executor.shutdown(); - executor.awaitTermination(2, TimeUnit.SECONDS); - executor = null; - } - - synchronized (GRABBER_LOCK) { - if (grabber != null) { - try { - grabber.stop(); - } catch (Throwable ignored) { - } - try { - grabber.close(); - } catch (Throwable ignored) { - } - grabber = null; - } - } - - latestFrame.set(null); - - - Runnable closeGpu = VIDEO_TEX::close; - if (RenderSystem.isOnRenderThread()) { - closeGpu.run(); - } else { - RenderSystem.queueFencedTask(closeGpu); - } - - Epsilon.LOGGER.info("[VideoPlayer] Stopped"); - } catch (Throwable e) { - Epsilon.LOGGER.error("[VideoPlayer] Stop error:", e); - } - } - - public static Identifier getGuiTexture() { - if (stopped || paused) return null; - if (!RenderSystem.isOnRenderThread()) return null; - - FrameBuffer frame = latestFrame.get(); - if (frame == null) return null; - - VIDEO_TEX.uploadRgba(frame.buffer, frame.width, frame.height); - - DynamicTexture texture = VIDEO_TEX.texture(); - if (texture == null) return null; - - if (registeredTexture != texture) { - mc.getTextureManager().register(GUI_TEXTURE_ID, texture); - registeredTexture = texture; - } - - return GUI_TEXTURE_ID; - } - - public static int getGuiTextureWidth() { - int width = VIDEO_TEX.width(); - if (width > 0) { - return width; - } - FrameBuffer frame = latestFrame.get(); - return frame != null ? frame.width : -1; - } - - public static int getGuiTextureHeight() { - int height = VIDEO_TEX.height(); - if (height > 0) { - return height; - } - FrameBuffer frame = latestFrame.get(); - return frame != null ? frame.height : -1; - } - - private static void startDecodeThread() { - decodeFuture = executor.submit(() -> { - try { - while (!stopped) { - if (paused) { - synchronized (pauseLock) { - while (paused && !stopped) pauseLock.wait(); - } - if (stopped) break; - } - - Frame frame; - long ptsUs; - - synchronized (GRABBER_LOCK) { - if (stopped || grabber == null) break; - - frame = grabber.grabImage(); - if (frame == null || frame.image == null) { - grabber.setTimestamp(0); - Frame first = grabber.grabImage(); - if (first != null && first.image != null) { - ptsUs = (first.timestamp > 0 ? first.timestamp : grabber.getTimestamp()); - lastPtsUs = ptsUs; - - latestFrame.set(copyFrameLocked(first)); - mediaStartUs = grabber.getTimestamp(); - wallStartNs = System.nanoTime(); - } - continue; - } - - ptsUs = (frame.timestamp > 0 ? frame.timestamp : grabber.getTimestamp()); - lastPtsUs = ptsUs; - - latestFrame.set(copyFrameLocked(frame)); - } - - long targetNs = wallStartNs + (ptsUs - mediaStartUs) * 1000L; - long sleepNs = targetNs - System.nanoTime(); - if (sleepNs > 0) { - LockSupport.parkNanos(Math.min(sleepNs, 5_000_000L)); - } - } - } catch (InterruptedException ignored) { - } catch (Throwable e) { - Epsilon.LOGGER.error("[VideoPlayer] Decode thread error:", e); - } - }); - } - - private static FrameBuffer copyFrameLocked(Frame frame) { - int w = frame.imageWidth; - int h = frame.imageHeight; - - int channels = 4; - int size = w * h * channels; - - ByteBuffer src = (ByteBuffer) frame.image[0]; - - ByteBuffer s = src.duplicate(); - s.rewind(); - - - if (s.remaining() < size) { - size = s.remaining(); - } else if (s.remaining() > size) { - s.limit(size); - } - - ByteBuffer dst = acquire(size); - dst.clear(); - dst.put(s); - dst.flip(); - - return new FrameBuffer(dst, w, h); - } - - private record FrameBuffer(ByteBuffer buffer, int width, int height) { - } - -} +//package com.github.epsilon.graphics.video; +// +//import com.github.epsilon.Epsilon; +//import com.github.epsilon.assets.resources.ResourceLocationUtils; +//import com.mojang.blaze3d.platform.NativeImage; +//import com.mojang.blaze3d.systems.CommandEncoder; +//import com.mojang.blaze3d.systems.RenderSystem; +//import com.mojang.blaze3d.textures.GpuTextureView; +//import net.minecraft.client.Minecraft; +//import net.minecraft.client.renderer.texture.DynamicTexture; +//import net.minecraft.resources.Identifier; +//import org.bytedeco.javacv.FFmpegFrameGrabber; +//import org.bytedeco.javacv.Frame; +// +//import java.io.File; +//import java.nio.ByteBuffer; +//import java.util.concurrent.ExecutorService; +//import java.util.concurrent.Executors; +//import java.util.concurrent.Future; +//import java.util.concurrent.TimeUnit; +//import java.util.concurrent.atomic.AtomicReference; +//import java.util.concurrent.locks.LockSupport; +// +//import static org.bytedeco.ffmpeg.global.avutil.AV_PIX_FMT_RGBA; +// +//public class VideoUtil { +// +// private static final Minecraft mc = Minecraft.getInstance(); +// +// private static final VideoGpuTexture VIDEO_TEX = new VideoGpuTexture(); +// +// private static final Identifier GUI_TEXTURE_ID = ResourceLocationUtils.getIdentifier("dynamic/title_video"); +// +// private static DynamicTexture registeredTexture; +// +// private static FFmpegFrameGrabber grabber; +// +// private static double frameRate; +// +// private static long mediaStartUs; +// private static long wallStartNs; +// +// +// private static volatile long lastPtsUs = 0; +// +// private static ExecutorService executor; +// private static Future decodeFuture; +// +// private static final Object pauseLock = new Object(); +// +// private static volatile boolean paused = false; +// private static volatile boolean stopped = true; +// +// private static final Object GRABBER_LOCK = new Object(); +// +// private static final AtomicReference latestFrame = new AtomicReference<>(); +// +// private static final int POOL = 4; +// private static final ByteBuffer[] pool = new ByteBuffer[POOL]; +// private static int poolCursor = 0; +// +// private static void releaseGuiTexture() { +// if (registeredTexture == null) { +// return; +// } +// mc.getTextureManager().release(GUI_TEXTURE_ID); +// registeredTexture = null; +// } +// +// private static ByteBuffer acquire(int size) { +// FrameBuffer cur = latestFrame.get(); +// ByteBuffer curBuf = cur != null ? cur.buffer : null; +// +// for (int n = 0; n < POOL; n++) { +// int idx = (poolCursor + n) % POOL; +// ByteBuffer b = pool[idx]; +// +// if (b == curBuf) continue; +// if (b == null || b.capacity() < size) { +// b = ByteBuffer.allocateDirect(size); +// pool[idx] = b; +// } +// +// poolCursor = (idx + 1) % POOL; +// return b; +// } +// +// return ByteBuffer.allocateDirect(size); +// } +// +// private static final class VideoGpuTexture { +// private DynamicTexture tex; +// private int w = -1, h = -1; +// +// void close() { +// if (tex != null) { +// if (registeredTexture == tex) { +// releaseGuiTexture(); +// } else { +// try { +// tex.close(); +// } catch (Throwable ignored) { +// } +// } +// tex = null; +// } +// w = h = -1; +// } +// +// GpuTextureView view() { +// return tex != null ? tex.getTextureView() : null; +// } +// +// DynamicTexture texture() { +// return tex; +// } +// +// int width() { +// return w; +// } +// +// int height() { +// return h; +// } +// +// void ensureSize(int newW, int newH) { +// if (tex != null && w == newW && h == newH) return; +// +// close(); +// tex = new DynamicTexture("video", newW, newH, true); +// w = newW; +// h = newH; +// } +// +// void uploadRgba(ByteBuffer rgba, int frameW, int frameH) { +// ensureSize(frameW, frameH); +// +// ByteBuffer src = rgba.duplicate(); +// src.rewind(); +// +// CommandEncoder enc = RenderSystem.getDevice().createCommandEncoder(); +// enc.writeToTexture( +// tex.getTexture(), +// src, +// NativeImage.Format.RGBA, +// 0, +// 0, +// 0, +// 0, +// frameW, +// frameH +// ); +// } +// } +// +// public static void init(File file, int fps) { +// stop(); +// +// try { +// Epsilon.LOGGER.info("[VideoPlayer] Initializing... {}", file.getAbsolutePath()); +// +// executor = Executors.newSingleThreadExecutor(r -> { +// Thread t = new Thread(r, "VideoUtil-Decode"); +// t.setDaemon(true); +// return t; +// }); +// +// synchronized (GRABBER_LOCK) { +// grabber = new FFmpegFrameGrabber(file); +// grabber.setPixelFormat(AV_PIX_FMT_RGBA); +// grabber.setOption("threads", "4"); +// grabber.start(); +// +// frameRate = (fps == -1 ? grabber.getFrameRate() : fps); +// +// Frame first = grabber.grabImage(); +// if (first != null && first.image != null) { +// FrameBuffer fb = copyFrameLocked(first); +// latestFrame.set(fb); +// lastPtsUs = (first.timestamp > 0 ? first.timestamp : grabber.getTimestamp()); +// } +// +// mediaStartUs = grabber.getTimestamp(); +// wallStartNs = System.nanoTime(); +// lastPtsUs = mediaStartUs; +// } +// +// stopped = false; +// paused = false; +// +// startDecodeThread(); +// } catch (Throwable e) { +// Epsilon.LOGGER.error("[VideoPlayer] Init error:", e); +// stop(); +// } +// } +// +// public static void pause() { +// paused = true; +// } +// +// public static void resume() { +// if (!paused) return; +// +// synchronized (pauseLock) { +// paused = false; +// wallStartNs = System.nanoTime() - (lastPtsUs - mediaStartUs) * 1000L; +// pauseLock.notifyAll(); +// } +// } +// +// public static void stop() { +// try { +// if (stopped && grabber == null) return; +// +// stopped = true; +// +// synchronized (pauseLock) { +// paused = false; +// pauseLock.notifyAll(); +// } +// +// if (decodeFuture != null) { +// decodeFuture.cancel(true); +// decodeFuture = null; +// } +// +// if (executor != null) { +// executor.shutdown(); +// executor.awaitTermination(2, TimeUnit.SECONDS); +// executor = null; +// } +// +// synchronized (GRABBER_LOCK) { +// if (grabber != null) { +// try { +// grabber.stop(); +// } catch (Throwable ignored) { +// } +// try { +// grabber.close(); +// } catch (Throwable ignored) { +// } +// grabber = null; +// } +// } +// +// latestFrame.set(null); +// +// +// Runnable closeGpu = VIDEO_TEX::close; +// if (RenderSystem.isOnRenderThread()) { +// closeGpu.run(); +// } else { +// RenderSystem.queueFencedTask(closeGpu); +// } +// +// Epsilon.LOGGER.info("[VideoPlayer] Stopped"); +// } catch (Throwable e) { +// Epsilon.LOGGER.error("[VideoPlayer] Stop error:", e); +// } +// } +// +// public static Identifier getGuiTexture() { +// if (stopped || paused) return null; +// if (!RenderSystem.isOnRenderThread()) return null; +// +// FrameBuffer frame = latestFrame.get(); +// if (frame == null) return null; +// +// VIDEO_TEX.uploadRgba(frame.buffer, frame.width, frame.height); +// +// DynamicTexture texture = VIDEO_TEX.texture(); +// if (texture == null) return null; +// +// if (registeredTexture != texture) { +// mc.getTextureManager().register(GUI_TEXTURE_ID, texture); +// registeredTexture = texture; +// } +// +// return GUI_TEXTURE_ID; +// } +// +// public static int getGuiTextureWidth() { +// int width = VIDEO_TEX.width(); +// if (width > 0) { +// return width; +// } +// FrameBuffer frame = latestFrame.get(); +// return frame != null ? frame.width : -1; +// } +// +// public static int getGuiTextureHeight() { +// int height = VIDEO_TEX.height(); +// if (height > 0) { +// return height; +// } +// FrameBuffer frame = latestFrame.get(); +// return frame != null ? frame.height : -1; +// } +// +// private static void startDecodeThread() { +// decodeFuture = executor.submit(() -> { +// try { +// while (!stopped) { +// if (paused) { +// synchronized (pauseLock) { +// while (paused && !stopped) pauseLock.wait(); +// } +// if (stopped) break; +// } +// +// Frame frame; +// long ptsUs; +// +// synchronized (GRABBER_LOCK) { +// if (stopped || grabber == null) break; +// +// frame = grabber.grabImage(); +// if (frame == null || frame.image == null) { +// grabber.setTimestamp(0); +// Frame first = grabber.grabImage(); +// if (first != null && first.image != null) { +// ptsUs = (first.timestamp > 0 ? first.timestamp : grabber.getTimestamp()); +// lastPtsUs = ptsUs; +// +// latestFrame.set(copyFrameLocked(first)); +// mediaStartUs = grabber.getTimestamp(); +// wallStartNs = System.nanoTime(); +// } +// continue; +// } +// +// ptsUs = (frame.timestamp > 0 ? frame.timestamp : grabber.getTimestamp()); +// lastPtsUs = ptsUs; +// +// latestFrame.set(copyFrameLocked(frame)); +// } +// +// long targetNs = wallStartNs + (ptsUs - mediaStartUs) * 1000L; +// long sleepNs = targetNs - System.nanoTime(); +// if (sleepNs > 0) { +// LockSupport.parkNanos(Math.min(sleepNs, 5_000_000L)); +// } +// } +// } catch (InterruptedException ignored) { +// } catch (Throwable e) { +// Epsilon.LOGGER.error("[VideoPlayer] Decode thread error:", e); +// } +// }); +// } +// +// private static FrameBuffer copyFrameLocked(Frame frame) { +// int w = frame.imageWidth; +// int h = frame.imageHeight; +// +// int channels = 4; +// int size = w * h * channels; +// +// ByteBuffer src = (ByteBuffer) frame.image[0]; +// +// ByteBuffer s = src.duplicate(); +// s.rewind(); +// +// +// if (s.remaining() < size) { +// size = s.remaining(); +// } else if (s.remaining() > size) { +// s.limit(size); +// } +// +// ByteBuffer dst = acquire(size); +// dst.clear(); +// dst.put(s); +// dst.flip(); +// +// return new FrameBuffer(dst, w, h); +// } +// +// private record FrameBuffer(ByteBuffer buffer, int width, int height) { +// } +// +//} diff --git a/common/src/main/java/com/github/epsilon/gui/screen/MainMenuScreen.java b/common/src/main/java/com/github/epsilon/gui/screen/MainMenuScreen.java index 0d807bcf..858f6182 100644 --- a/common/src/main/java/com/github/epsilon/gui/screen/MainMenuScreen.java +++ b/common/src/main/java/com/github/epsilon/gui/screen/MainMenuScreen.java @@ -1,382 +1,382 @@ -package com.github.epsilon.gui.screen; - -import com.github.epsilon.Epsilon; -import com.github.epsilon.graphics.LuminRenderSystem; -import com.github.epsilon.graphics.renderers.RoundRectRenderer; -import com.github.epsilon.graphics.renderers.ShadowRenderer; -import com.github.epsilon.graphics.renderers.TextRenderer; -import com.github.epsilon.graphics.text.StaticFontLoader; -import com.github.epsilon.graphics.video.VideoManager; -import com.github.epsilon.graphics.video.VideoUtil; -import com.github.epsilon.gui.panel.MD3Theme; -import net.minecraft.client.gui.GuiGraphicsExtractor; -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.client.gui.screens.multiplayer.JoinMultiplayerScreen; -import net.minecraft.client.gui.screens.multiplayer.SafetyScreen; -import net.minecraft.client.gui.screens.options.OptionsScreen; -import net.minecraft.client.gui.screens.worldselection.SelectWorldScreen; -import net.minecraft.client.input.MouseButtonEvent; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import net.minecraft.util.Mth; -import net.minecraft.util.Util; - -import java.awt.*; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -public class MainMenuScreen extends Screen { - - private static final float VIDEO_ZOOM = 1.08f; - private static final float VIDEO_PARALLAX_STRENGTH = 0.35f; - - public static final MainMenuScreen INSTANCE = new MainMenuScreen(); - - private final ShadowRenderer shadowRenderer = new ShadowRenderer(); - private final RoundRectRenderer roundRectRenderer = new RoundRectRenderer(); - private final TextRenderer textRenderer = new TextRenderer(); - - private final List entries = new ArrayList<>(); - - private LuminRenderSystem.LuminRenderTarget renderTarget; - - private boolean videoStarted; - private long introStartMs; - - private MainMenuScreen() { - super(Component.literal("MainMenuScreen")); - entries.add(new MenuEntry("S", "Singleplayer", "Create and manage your worlds", () -> minecraft.setScreen(new SelectWorldScreen(this)))); - entries.add(new MenuEntry("M", "Multiplayer", "Servers, friends and online play", () -> { - Screen screen = this.minecraft.options.skipMultiplayerWarning ? new JoinMultiplayerScreen(this) : new SafetyScreen(this); - this.minecraft.setScreen(screen); - })); - entries.add(new MenuEntry("O", "Options", "Video, controls and client settings", () -> minecraft.setScreen(new OptionsScreen(this, minecraft.options, false)))); - entries.add(new MenuEntry("Q", "Quit", "Leave Epsilon and close the game", minecraft::stop)); - } - - @Override - protected void init() { - super.init(); - introStartMs = Util.getMillis(); - for (MenuEntry entry : entries) { - entry.hoverProgress = 0.0f; - entry.setBounds(0.0f, 0.0f, 0.0f, 0.0f); - } - } - - @Override - public void added() { - super.added(); - - if (videoStarted) { - return; - } - - try { - VideoManager.loadBackground(60); - videoStarted = true; - } catch (IOException e) { - Epsilon.LOGGER.error("MainMenu视频加载失败!", e); - } - } - - @Override - public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { - Identifier videoTexture = videoStarted ? VideoUtil.getGuiTexture() : null; - if (videoTexture != null) { - int videoWidth = VideoUtil.getGuiTextureWidth(); - int videoHeight = VideoUtil.getGuiTextureHeight(); - if (videoWidth > 0 && videoHeight > 0) { - float screenAspect = (float) this.width / (float) this.height; - float videoAspect = (float) videoWidth / (float) videoHeight; - - float visibleWidth = 1.0f; - float visibleHeight = 1.0f; - if (videoAspect > screenAspect) { - visibleWidth = screenAspect / videoAspect; - } else { - visibleHeight = videoAspect / screenAspect; - } - - visibleWidth /= VIDEO_ZOOM; - visibleHeight /= VIDEO_ZOOM; - - float mouseNormX = this.width <= 0 ? 0.0f : Mth.clamp((mouseX / (float) this.width) * 2.0f - 1.0f, -1.0f, 1.0f); - float mouseNormY = this.height <= 0 ? 0.0f : Mth.clamp((mouseY / (float) this.height) * 2.0f - 1.0f, -1.0f, 1.0f); - - float uRange = 1.0f - visibleWidth; - float vRange = 1.0f - visibleHeight; - float u0 = Mth.clamp((uRange * 0.5f) - mouseNormX * uRange * VIDEO_PARALLAX_STRENGTH, 0.0f, uRange); - float v0 = Mth.clamp((vRange * 0.5f) - mouseNormY * vRange * VIDEO_PARALLAX_STRENGTH, 0.0f, vRange); - float u1 = u0 + visibleWidth; - float v1 = v0 + visibleHeight; - - graphics.blit(videoTexture, 0, 0, this.width, this.height, u0, u1, v0, v1); - return; - } - return; - } - - // 视频已经启动但当前帧尚未准备好时,回退到全景背景,避免黑屏。 - this.minecraft.gameRenderer.getPanorama().extractRenderState(graphics, this.width, this.height, this.panoramaShouldSpin()); - } - - @Override - public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { - final var window = minecraft.getWindow(); - if (renderTarget == null) { - renderTarget = LuminRenderSystem.LuminRenderTarget.create("main-menu", window.getWidth(), window.getHeight()); - } - - renderTarget.clear(); - renderTarget.resize(window.getWidth(), window.getHeight()); - LuminRenderSystem.setActiveTarget(renderTarget); - - MD3Theme.syncFromSettings(); - drawMenu(mouseX, mouseY); - - LuminRenderSystem.setActiveTarget(null); - graphics.blit(renderTarget.getIdentifier(), 0, 0, window.getGuiScaledWidth(), window.getGuiScaledHeight(), 0, 1, 1, 0); - } - - private void drawMenu(int mouseX, int mouseY) { - long now = Util.getMillis(); - float introProgress = easeOutCubic(Mth.clamp((now - introStartMs) / 650.0f, 0.0f, 1.0f)); - Layout layout = Layout.resolve(width, height, entries.size()); - - Color panelShadow = applyAlpha(MD3Theme.SHADOW, 0.90f); - Color panelColor = applyAlpha(MD3Theme.SURFACE, 0.74f); - Color titleColor = applyAlpha(MD3Theme.TEXT_PRIMARY, 0.96f); - Color subtitleColor = applyAlpha(MD3Theme.TEXT_SECONDARY, 0.90f); - - shadowRenderer.addShadow(layout.panelX, layout.panelY, layout.panelWidth, layout.panelHeight, layout.panelRadius, 22.0f * layout.scale, panelShadow); - roundRectRenderer.addRoundRect(layout.panelX, layout.panelY, layout.panelWidth, layout.panelHeight, layout.panelRadius, panelColor); - - float titleScale = 1.04f * layout.scale; - float versionScale = 0.56f * layout.scale; - float titleY = layout.panelY + layout.padding + 2.0f * layout.scale; - float subtitleY = titleY + textRenderer.getLineHeight(titleScale, StaticFontLoader.DUCKSANS) + 2.0f * layout.scale; - - textRenderer.addText("Open Epsilon", layout.panelX + layout.padding, titleY, titleScale, titleColor, StaticFontLoader.DUCKSANS); - textRenderer.addText("Minecraft 26.1.2 | " + Epsilon.VERSION, layout.panelX + layout.padding, subtitleY, versionScale, subtitleColor); - - for (int i = 0; i < entries.size(); i++) { - renderEntry(entries.get(i), i, mouseX, mouseY, introProgress, layout); - } - - shadowRenderer.drawAndClear(); - roundRectRenderer.drawAndClear(); - textRenderer.drawAndClear(); - } - - private void renderEntry(MenuEntry entry, int index, int mouseX, int mouseY, float introProgress, Layout layout) { - float staged = Mth.clamp((introProgress - index * 0.09f) / 0.60f, 0.0f, 1.0f); - float appear = easeOutCubic(staged); - if (appear <= 0.001f) { - entry.setBounds(0.0f, 0.0f, 0.0f, 0.0f); - return; - } - - float y = layout.buttonsY + index * (layout.buttonHeight + layout.buttonGap); - float slideX = (1.0f - appear) * (-18.0f * layout.scale); - float drawX = layout.buttonsX + slideX; - - entry.setBounds(drawX, y, layout.buttonWidth, layout.buttonHeight); - - boolean hovered = entry.isHovered(mouseX, mouseY); - entry.hoverProgress = Mth.lerp(hovered ? 0.24f : 0.16f, entry.hoverProgress, hovered ? 1.0f : 0.0f); - - float hover = entry.hoverProgress; - float cardLift = hover * 2.0f * layout.scale; - float cardY = y - cardLift; - - Color baseColor = applyAlpha(MD3Theme.SURFACE_CONTAINER_HIGH, 0.80f * appear); - Color hoverColor = applyAlpha(MD3Theme.PRIMARY_CONTAINER, 0.92f * appear); - Color cardColor = blend(baseColor, hoverColor, hover); - Color titleColor = blend( - applyAlpha(MD3Theme.TEXT_PRIMARY, 0.95f * appear), - applyAlpha(MD3Theme.ON_PRIMARY_CONTAINER, 0.98f * appear), - hover - ); - Color subColor = blend( - applyAlpha(MD3Theme.TEXT_SECONDARY, 0.88f * appear), - applyAlpha(MD3Theme.ON_PRIMARY_CONTAINER, 0.78f * appear), - hover * 0.8f - ); - - Color badgeBase = entry.title.equals("Quit") - ? applyAlpha(MD3Theme.TERTIARY_CONTAINER, 0.90f * appear) - : applyAlpha(MD3Theme.SECONDARY_CONTAINER, 0.92f * appear); - Color badgeHover = entry.title.equals("Quit") - ? applyAlpha(MD3Theme.ERROR, 0.92f * appear) - : applyAlpha(MD3Theme.PRIMARY, 0.95f * appear); - Color badgeColor = blend(badgeBase, badgeHover, hover); - Color badgeTextColor = entry.title.equals("Quit") - ? applyAlpha(MD3Theme.ON_TERTIARY, 0.98f * appear) - : applyAlpha(MD3Theme.ON_SECONDARY_CONTAINER, 0.98f * appear); - - shadowRenderer.addShadow(drawX, cardY, layout.buttonWidth, layout.buttonHeight, layout.buttonRadius, (10.0f + hover * 6.0f) * layout.scale, applyAlpha(MD3Theme.SHADOW, (0.52f + hover * 0.12f) * appear)); - - roundRectRenderer.addRoundRect(drawX, cardY, layout.buttonWidth, layout.buttonHeight, layout.buttonRadius, cardColor); - - float badgeX = drawX + 10.0f * layout.scale; - float badgeY = cardY + (layout.buttonHeight - layout.badgeSize) / 2.0f; - roundRectRenderer.addRoundRect(badgeX, badgeY, layout.badgeSize, layout.badgeSize, layout.badgeRadius, badgeColor); - - float badgeScale = 0.62f * layout.scale; - float badgeTextX = badgeX + (layout.badgeSize - textRenderer.getWidth(entry.badge, badgeScale, StaticFontLoader.DUCKSANS)) / 2.0f; - float badgeTextY = badgeY + (layout.badgeSize - textRenderer.getHeight(badgeScale, StaticFontLoader.DUCKSANS)) / 2.0f - 1.0f * layout.scale; - textRenderer.addText(entry.badge, badgeTextX, badgeTextY, badgeScale, badgeTextColor, StaticFontLoader.DUCKSANS); - - float textX = badgeX + layout.badgeSize + 11.0f * layout.scale; - float titleScale = 0.74f * layout.scale; - float subtitleScale = 0.54f * layout.scale; - float titleY = cardY + 8.0f * layout.scale; - float subtitleY = titleY + textRenderer.getLineHeight(titleScale, StaticFontLoader.DUCKSANS) + 3.5f * layout.scale; - - textRenderer.addText(entry.title, textX, titleY, titleScale, titleColor, StaticFontLoader.DUCKSANS); - textRenderer.addText(entry.subtitle, textX, subtitleY, subtitleScale, subColor); - - String tip = entry.title.equals("Quit") ? "Close" : "Open"; - float tipScale = 0.50f * layout.scale; - float tipWidth = textRenderer.getWidth(tip, tipScale); - float tipX = drawX + layout.buttonWidth - tipWidth - 14.0f * layout.scale; - float tipY = cardY + (layout.buttonHeight - textRenderer.getHeight(tipScale)) / 2.0f - 1.0f * layout.scale; - textRenderer.addText(tip, tipX, tipY, tipScale, applyAlpha(MD3Theme.TEXT_MUTED, (0.86f + hover * 0.10f) * appear)); - } - - private static float easeOutCubic(float value) { - float t = Mth.clamp(value, 0.0f, 1.0f); - float inv = 1.0f - t; - return 1.0f - inv * inv * inv; - } - - private static Color applyAlpha(Color color, float alphaFactor) { - float factor = Mth.clamp(alphaFactor, 0.0f, 1.0f); - return new Color(color.getRed(), color.getGreen(), color.getBlue(), Math.round(color.getAlpha() * factor)); - } - - private static Color blend(Color start, Color end, float delta) { - float t = Mth.clamp(delta, 0.0f, 1.0f); - int r = (int) (start.getRed() + (end.getRed() - start.getRed()) * t); - int g = (int) (start.getGreen() + (end.getGreen() - start.getGreen()) * t); - int b = (int) (start.getBlue() + (end.getBlue() - start.getBlue()) * t); - int a = (int) (start.getAlpha() + (end.getAlpha() - start.getAlpha()) * t); - return new Color(r, g, b, a); - } - - @Override - public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { - if (event.button() == 0) { - for (MenuEntry entry : entries) { - if (entry.isHovered(event.x(), event.y())) { - entry.action.run(); - return true; - } - } - } - return super.mouseClicked(event, doubleClick); - } - - @Override - public void removed() { - super.removed(); - if (renderTarget != null) { - renderTarget.close(); - renderTarget = null; - } - if (videoStarted) { - videoStarted = false; - VideoUtil.stop(); - } - } - - @Override - public boolean shouldCloseOnEsc() { - return false; - } - - @Override - public boolean isPauseScreen() { - return false; - } - - private static final class MenuEntry { - private final String badge; - private final String title; - private final String subtitle; - private final Runnable action; - - private float x; - private float y; - private float width; - private float height; - private float hoverProgress; - - private MenuEntry(String badge, String title, String subtitle, Runnable action) { - this.badge = badge; - this.title = title; - this.subtitle = subtitle; - this.action = action; - } - - private void setBounds(float x, float y, float width, float height) { - this.x = x; - this.y = y; - this.width = width; - this.height = height; - } - - private boolean isHovered(double mouseX, double mouseY) { - return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; - } - } - - private record Layout(float scale, float panelX, float panelY, float panelWidth, float panelHeight, - float panelRadius, float padding, float buttonsX, float buttonsY, float buttonWidth, - float buttonHeight, float buttonGap, float buttonRadius, float badgeSize, float badgeRadius) { - private static Layout resolve(int width, int height, int entryCount) { - float minEdgePadding = 10.0f; - float baseScale = Mth.clamp(Math.min(width / 480.0f, height / 320.0f), 0.92f, 1.18f); - - float panelWidthAtScaleOne = 252.0f; - float panelHeightAtScaleOne = 45.0f + entryCount * 40.0f + Math.max(0, entryCount - 1) * 8.0f + 24.0f; - float availableWidth = Math.max(1.0f, width - minEdgePadding * 2.0f); - float availableHeight = Math.max(1.0f, height - minEdgePadding * 2.0f); - - float fitScale = Math.min(1.0f, Math.min(availableWidth / panelWidthAtScaleOne, availableHeight / panelHeightAtScaleOne)); - float scale = Mth.clamp(baseScale * fitScale, 0.58f, 1.18f); - - float buttonWidth = 228.0f * scale; - float buttonHeight = 40.0f * scale; - float buttonGap = 8.0f * scale; - float padding = 12.0f * scale; - float headerHeight = 45.0f * scale; - float panelWidth = buttonWidth + padding * 2.0f; - float panelHeight = headerHeight + entryCount * buttonHeight + (entryCount - 1) * buttonGap + padding * 2.0f; - - float panelX = Mth.clamp(width * 0.115f, minEdgePadding, Math.max(minEdgePadding, width - panelWidth - minEdgePadding)); - float targetY = height * 0.62f - panelHeight / 2.0f; - float panelY = Mth.clamp(targetY, minEdgePadding, Math.max(minEdgePadding, height - panelHeight - minEdgePadding)); - return new Layout( - scale, - panelX, - panelY, - panelWidth, - panelHeight, - 16.0f * scale, - padding, - panelX + padding, - panelY + headerHeight, - buttonWidth, - buttonHeight, - buttonGap, - 10.0f * scale, - 24.0f * scale, - 7.0f * scale - ); - } - } - -} +//package com.github.epsilon.gui.screen; +// +//import com.github.epsilon.Epsilon; +//import com.github.epsilon.graphics.LuminRenderSystem; +//import com.github.epsilon.graphics.renderers.RoundRectRenderer; +//import com.github.epsilon.graphics.renderers.ShadowRenderer; +//import com.github.epsilon.graphics.renderers.TextRenderer; +//import com.github.epsilon.graphics.text.StaticFontLoader; +//import com.github.epsilon.graphics.video.VideoManager; +//import com.github.epsilon.graphics.video.VideoUtil; +//import com.github.epsilon.gui.panel.MD3Theme; +//import net.minecraft.client.gui.GuiGraphicsExtractor; +//import net.minecraft.client.gui.screens.Screen; +//import net.minecraft.client.gui.screens.multiplayer.JoinMultiplayerScreen; +//import net.minecraft.client.gui.screens.multiplayer.SafetyScreen; +//import net.minecraft.client.gui.screens.options.OptionsScreen; +//import net.minecraft.client.gui.screens.worldselection.SelectWorldScreen; +//import net.minecraft.client.input.MouseButtonEvent; +//import net.minecraft.network.chat.Component; +//import net.minecraft.resources.Identifier; +//import net.minecraft.util.Mth; +//import net.minecraft.util.Util; +// +//import java.awt.*; +//import java.io.IOException; +//import java.util.ArrayList; +//import java.util.List; +// +//public class MainMenuScreen extends Screen { +// +// private static final float VIDEO_ZOOM = 1.08f; +// private static final float VIDEO_PARALLAX_STRENGTH = 0.35f; +// +// public static final MainMenuScreen INSTANCE = new MainMenuScreen(); +// +// private final ShadowRenderer shadowRenderer = new ShadowRenderer(); +// private final RoundRectRenderer roundRectRenderer = new RoundRectRenderer(); +// private final TextRenderer textRenderer = new TextRenderer(); +// +// private final List entries = new ArrayList<>(); +// +// private LuminRenderSystem.LuminRenderTarget renderTarget; +// +// private boolean videoStarted; +// private long introStartMs; +// +// private MainMenuScreen() { +// super(Component.literal("MainMenuScreen")); +// entries.add(new MenuEntry("S", "Singleplayer", "Create and manage your worlds", () -> minecraft.setScreen(new SelectWorldScreen(this)))); +// entries.add(new MenuEntry("M", "Multiplayer", "Servers, friends and online play", () -> { +// Screen screen = this.minecraft.options.skipMultiplayerWarning ? new JoinMultiplayerScreen(this) : new SafetyScreen(this); +// this.minecraft.setScreen(screen); +// })); +// entries.add(new MenuEntry("O", "Options", "Video, controls and client settings", () -> minecraft.setScreen(new OptionsScreen(this, minecraft.options, false)))); +// entries.add(new MenuEntry("Q", "Quit", "Leave Epsilon and close the game", minecraft::stop)); +// } +// +// @Override +// protected void init() { +// super.init(); +// introStartMs = Util.getMillis(); +// for (MenuEntry entry : entries) { +// entry.hoverProgress = 0.0f; +// entry.setBounds(0.0f, 0.0f, 0.0f, 0.0f); +// } +// } +// +// @Override +// public void added() { +// super.added(); +// +// if (videoStarted) { +// return; +// } +// +// try { +// VideoManager.loadBackground(60); +// videoStarted = true; +// } catch (IOException e) { +// Epsilon.LOGGER.error("MainMenu视频加载失败!", e); +// } +// } +// +// @Override +// public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { +// Identifier videoTexture = videoStarted ? VideoUtil.getGuiTexture() : null; +// if (videoTexture != null) { +// int videoWidth = VideoUtil.getGuiTextureWidth(); +// int videoHeight = VideoUtil.getGuiTextureHeight(); +// if (videoWidth > 0 && videoHeight > 0) { +// float screenAspect = (float) this.width / (float) this.height; +// float videoAspect = (float) videoWidth / (float) videoHeight; +// +// float visibleWidth = 1.0f; +// float visibleHeight = 1.0f; +// if (videoAspect > screenAspect) { +// visibleWidth = screenAspect / videoAspect; +// } else { +// visibleHeight = videoAspect / screenAspect; +// } +// +// visibleWidth /= VIDEO_ZOOM; +// visibleHeight /= VIDEO_ZOOM; +// +// float mouseNormX = this.width <= 0 ? 0.0f : Mth.clamp((mouseX / (float) this.width) * 2.0f - 1.0f, -1.0f, 1.0f); +// float mouseNormY = this.height <= 0 ? 0.0f : Mth.clamp((mouseY / (float) this.height) * 2.0f - 1.0f, -1.0f, 1.0f); +// +// float uRange = 1.0f - visibleWidth; +// float vRange = 1.0f - visibleHeight; +// float u0 = Mth.clamp((uRange * 0.5f) - mouseNormX * uRange * VIDEO_PARALLAX_STRENGTH, 0.0f, uRange); +// float v0 = Mth.clamp((vRange * 0.5f) - mouseNormY * vRange * VIDEO_PARALLAX_STRENGTH, 0.0f, vRange); +// float u1 = u0 + visibleWidth; +// float v1 = v0 + visibleHeight; +// +// graphics.blit(videoTexture, 0, 0, this.width, this.height, u0, u1, v0, v1); +// return; +// } +// return; +// } +// +// // 视频已经启动但当前帧尚未准备好时,回退到全景背景,避免黑屏。 +// this.minecraft.gameRenderer.getPanorama().extractRenderState(graphics, this.width, this.height, this.panoramaShouldSpin()); +// } +// +// @Override +// public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { +// final var window = minecraft.getWindow(); +// if (renderTarget == null) { +// renderTarget = LuminRenderSystem.LuminRenderTarget.create("main-menu", window.getWidth(), window.getHeight()); +// } +// +// renderTarget.clear(); +// renderTarget.resize(window.getWidth(), window.getHeight()); +// LuminRenderSystem.setActiveTarget(renderTarget); +// +// MD3Theme.syncFromSettings(); +// drawMenu(mouseX, mouseY); +// +// LuminRenderSystem.setActiveTarget(null); +// graphics.blit(renderTarget.getIdentifier(), 0, 0, window.getGuiScaledWidth(), window.getGuiScaledHeight(), 0, 1, 1, 0); +// } +// +// private void drawMenu(int mouseX, int mouseY) { +// long now = Util.getMillis(); +// float introProgress = easeOutCubic(Mth.clamp((now - introStartMs) / 650.0f, 0.0f, 1.0f)); +// Layout layout = Layout.resolve(width, height, entries.size()); +// +// Color panelShadow = applyAlpha(MD3Theme.SHADOW, 0.90f); +// Color panelColor = applyAlpha(MD3Theme.SURFACE, 0.74f); +// Color titleColor = applyAlpha(MD3Theme.TEXT_PRIMARY, 0.96f); +// Color subtitleColor = applyAlpha(MD3Theme.TEXT_SECONDARY, 0.90f); +// +// shadowRenderer.addShadow(layout.panelX, layout.panelY, layout.panelWidth, layout.panelHeight, layout.panelRadius, 22.0f * layout.scale, panelShadow); +// roundRectRenderer.addRoundRect(layout.panelX, layout.panelY, layout.panelWidth, layout.panelHeight, layout.panelRadius, panelColor); +// +// float titleScale = 1.04f * layout.scale; +// float versionScale = 0.56f * layout.scale; +// float titleY = layout.panelY + layout.padding + 2.0f * layout.scale; +// float subtitleY = titleY + textRenderer.getLineHeight(titleScale, StaticFontLoader.DUCKSANS) + 2.0f * layout.scale; +// +// textRenderer.addText("Open Epsilon", layout.panelX + layout.padding, titleY, titleScale, titleColor, StaticFontLoader.DUCKSANS); +// textRenderer.addText("Minecraft 26.1.2 | " + Epsilon.VERSION, layout.panelX + layout.padding, subtitleY, versionScale, subtitleColor); +// +// for (int i = 0; i < entries.size(); i++) { +// renderEntry(entries.get(i), i, mouseX, mouseY, introProgress, layout); +// } +// +// shadowRenderer.drawAndClear(); +// roundRectRenderer.drawAndClear(); +// textRenderer.drawAndClear(); +// } +// +// private void renderEntry(MenuEntry entry, int index, int mouseX, int mouseY, float introProgress, Layout layout) { +// float staged = Mth.clamp((introProgress - index * 0.09f) / 0.60f, 0.0f, 1.0f); +// float appear = easeOutCubic(staged); +// if (appear <= 0.001f) { +// entry.setBounds(0.0f, 0.0f, 0.0f, 0.0f); +// return; +// } +// +// float y = layout.buttonsY + index * (layout.buttonHeight + layout.buttonGap); +// float slideX = (1.0f - appear) * (-18.0f * layout.scale); +// float drawX = layout.buttonsX + slideX; +// +// entry.setBounds(drawX, y, layout.buttonWidth, layout.buttonHeight); +// +// boolean hovered = entry.isHovered(mouseX, mouseY); +// entry.hoverProgress = Mth.lerp(hovered ? 0.24f : 0.16f, entry.hoverProgress, hovered ? 1.0f : 0.0f); +// +// float hover = entry.hoverProgress; +// float cardLift = hover * 2.0f * layout.scale; +// float cardY = y - cardLift; +// +// Color baseColor = applyAlpha(MD3Theme.SURFACE_CONTAINER_HIGH, 0.80f * appear); +// Color hoverColor = applyAlpha(MD3Theme.PRIMARY_CONTAINER, 0.92f * appear); +// Color cardColor = blend(baseColor, hoverColor, hover); +// Color titleColor = blend( +// applyAlpha(MD3Theme.TEXT_PRIMARY, 0.95f * appear), +// applyAlpha(MD3Theme.ON_PRIMARY_CONTAINER, 0.98f * appear), +// hover +// ); +// Color subColor = blend( +// applyAlpha(MD3Theme.TEXT_SECONDARY, 0.88f * appear), +// applyAlpha(MD3Theme.ON_PRIMARY_CONTAINER, 0.78f * appear), +// hover * 0.8f +// ); +// +// Color badgeBase = entry.title.equals("Quit") +// ? applyAlpha(MD3Theme.TERTIARY_CONTAINER, 0.90f * appear) +// : applyAlpha(MD3Theme.SECONDARY_CONTAINER, 0.92f * appear); +// Color badgeHover = entry.title.equals("Quit") +// ? applyAlpha(MD3Theme.ERROR, 0.92f * appear) +// : applyAlpha(MD3Theme.PRIMARY, 0.95f * appear); +// Color badgeColor = blend(badgeBase, badgeHover, hover); +// Color badgeTextColor = entry.title.equals("Quit") +// ? applyAlpha(MD3Theme.ON_TERTIARY, 0.98f * appear) +// : applyAlpha(MD3Theme.ON_SECONDARY_CONTAINER, 0.98f * appear); +// +// shadowRenderer.addShadow(drawX, cardY, layout.buttonWidth, layout.buttonHeight, layout.buttonRadius, (10.0f + hover * 6.0f) * layout.scale, applyAlpha(MD3Theme.SHADOW, (0.52f + hover * 0.12f) * appear)); +// +// roundRectRenderer.addRoundRect(drawX, cardY, layout.buttonWidth, layout.buttonHeight, layout.buttonRadius, cardColor); +// +// float badgeX = drawX + 10.0f * layout.scale; +// float badgeY = cardY + (layout.buttonHeight - layout.badgeSize) / 2.0f; +// roundRectRenderer.addRoundRect(badgeX, badgeY, layout.badgeSize, layout.badgeSize, layout.badgeRadius, badgeColor); +// +// float badgeScale = 0.62f * layout.scale; +// float badgeTextX = badgeX + (layout.badgeSize - textRenderer.getWidth(entry.badge, badgeScale, StaticFontLoader.DUCKSANS)) / 2.0f; +// float badgeTextY = badgeY + (layout.badgeSize - textRenderer.getHeight(badgeScale, StaticFontLoader.DUCKSANS)) / 2.0f - 1.0f * layout.scale; +// textRenderer.addText(entry.badge, badgeTextX, badgeTextY, badgeScale, badgeTextColor, StaticFontLoader.DUCKSANS); +// +// float textX = badgeX + layout.badgeSize + 11.0f * layout.scale; +// float titleScale = 0.74f * layout.scale; +// float subtitleScale = 0.54f * layout.scale; +// float titleY = cardY + 8.0f * layout.scale; +// float subtitleY = titleY + textRenderer.getLineHeight(titleScale, StaticFontLoader.DUCKSANS) + 3.5f * layout.scale; +// +// textRenderer.addText(entry.title, textX, titleY, titleScale, titleColor, StaticFontLoader.DUCKSANS); +// textRenderer.addText(entry.subtitle, textX, subtitleY, subtitleScale, subColor); +// +// String tip = entry.title.equals("Quit") ? "Close" : "Open"; +// float tipScale = 0.50f * layout.scale; +// float tipWidth = textRenderer.getWidth(tip, tipScale); +// float tipX = drawX + layout.buttonWidth - tipWidth - 14.0f * layout.scale; +// float tipY = cardY + (layout.buttonHeight - textRenderer.getHeight(tipScale)) / 2.0f - 1.0f * layout.scale; +// textRenderer.addText(tip, tipX, tipY, tipScale, applyAlpha(MD3Theme.TEXT_MUTED, (0.86f + hover * 0.10f) * appear)); +// } +// +// private static float easeOutCubic(float value) { +// float t = Mth.clamp(value, 0.0f, 1.0f); +// float inv = 1.0f - t; +// return 1.0f - inv * inv * inv; +// } +// +// private static Color applyAlpha(Color color, float alphaFactor) { +// float factor = Mth.clamp(alphaFactor, 0.0f, 1.0f); +// return new Color(color.getRed(), color.getGreen(), color.getBlue(), Math.round(color.getAlpha() * factor)); +// } +// +// private static Color blend(Color start, Color end, float delta) { +// float t = Mth.clamp(delta, 0.0f, 1.0f); +// int r = (int) (start.getRed() + (end.getRed() - start.getRed()) * t); +// int g = (int) (start.getGreen() + (end.getGreen() - start.getGreen()) * t); +// int b = (int) (start.getBlue() + (end.getBlue() - start.getBlue()) * t); +// int a = (int) (start.getAlpha() + (end.getAlpha() - start.getAlpha()) * t); +// return new Color(r, g, b, a); +// } +// +// @Override +// public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { +// if (event.button() == 0) { +// for (MenuEntry entry : entries) { +// if (entry.isHovered(event.x(), event.y())) { +// entry.action.run(); +// return true; +// } +// } +// } +// return super.mouseClicked(event, doubleClick); +// } +// +// @Override +// public void removed() { +// super.removed(); +// if (renderTarget != null) { +// renderTarget.close(); +// renderTarget = null; +// } +// if (videoStarted) { +// videoStarted = false; +// VideoUtil.stop(); +// } +// } +// +// @Override +// public boolean shouldCloseOnEsc() { +// return false; +// } +// +// @Override +// public boolean isPauseScreen() { +// return false; +// } +// +// private static final class MenuEntry { +// private final String badge; +// private final String title; +// private final String subtitle; +// private final Runnable action; +// +// private float x; +// private float y; +// private float width; +// private float height; +// private float hoverProgress; +// +// private MenuEntry(String badge, String title, String subtitle, Runnable action) { +// this.badge = badge; +// this.title = title; +// this.subtitle = subtitle; +// this.action = action; +// } +// +// private void setBounds(float x, float y, float width, float height) { +// this.x = x; +// this.y = y; +// this.width = width; +// this.height = height; +// } +// +// private boolean isHovered(double mouseX, double mouseY) { +// return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; +// } +// } +// +// private record Layout(float scale, float panelX, float panelY, float panelWidth, float panelHeight, +// float panelRadius, float padding, float buttonsX, float buttonsY, float buttonWidth, +// float buttonHeight, float buttonGap, float buttonRadius, float badgeSize, float badgeRadius) { +// private static Layout resolve(int width, int height, int entryCount) { +// float minEdgePadding = 10.0f; +// float baseScale = Mth.clamp(Math.min(width / 480.0f, height / 320.0f), 0.92f, 1.18f); +// +// float panelWidthAtScaleOne = 252.0f; +// float panelHeightAtScaleOne = 45.0f + entryCount * 40.0f + Math.max(0, entryCount - 1) * 8.0f + 24.0f; +// float availableWidth = Math.max(1.0f, width - minEdgePadding * 2.0f); +// float availableHeight = Math.max(1.0f, height - minEdgePadding * 2.0f); +// +// float fitScale = Math.min(1.0f, Math.min(availableWidth / panelWidthAtScaleOne, availableHeight / panelHeightAtScaleOne)); +// float scale = Mth.clamp(baseScale * fitScale, 0.58f, 1.18f); +// +// float buttonWidth = 228.0f * scale; +// float buttonHeight = 40.0f * scale; +// float buttonGap = 8.0f * scale; +// float padding = 12.0f * scale; +// float headerHeight = 45.0f * scale; +// float panelWidth = buttonWidth + padding * 2.0f; +// float panelHeight = headerHeight + entryCount * buttonHeight + (entryCount - 1) * buttonGap + padding * 2.0f; +// +// float panelX = Mth.clamp(width * 0.115f, minEdgePadding, Math.max(minEdgePadding, width - panelWidth - minEdgePadding)); +// float targetY = height * 0.62f - panelHeight / 2.0f; +// float panelY = Mth.clamp(targetY, minEdgePadding, Math.max(minEdgePadding, height - panelHeight - minEdgePadding)); +// return new Layout( +// scale, +// panelX, +// panelY, +// panelWidth, +// panelHeight, +// 16.0f * scale, +// padding, +// panelX + padding, +// panelY + headerHeight, +// buttonWidth, +// buttonHeight, +// buttonGap, +// 10.0f * scale, +// 24.0f * scale, +// 7.0f * scale +// ); +// } +// } +// +//} diff --git a/common/src/main/java/com/github/epsilon/mixins/MixinChatComponent.java b/common/src/main/java/com/github/epsilon/mixins/MixinChatComponent.java index 74593633..d2b4a088 100644 --- a/common/src/main/java/com/github/epsilon/mixins/MixinChatComponent.java +++ b/common/src/main/java/com/github/epsilon/mixins/MixinChatComponent.java @@ -13,7 +13,7 @@ public class MixinChatComponent { @ModifyVariable(method = "handleMessage", at = @At("HEAD"), argsOnly = true, ordinal = 0) - private FormattedCharSequence sakura$animateClientPrefix(FormattedCharSequence message) { + private FormattedCharSequence onHandleMessage(FormattedCharSequence message) { return ChatUtils.applyAnimatedPrefix(message); } diff --git a/common/src/main/java/com/github/epsilon/mixins/MixinTitleScreen.java b/common/src/main/java/com/github/epsilon/mixins/MixinTitleScreen.java index 264d744a..8e7fafcb 100644 --- a/common/src/main/java/com/github/epsilon/mixins/MixinTitleScreen.java +++ b/common/src/main/java/com/github/epsilon/mixins/MixinTitleScreen.java @@ -1,8 +1,5 @@ package com.github.epsilon.mixins; -import com.github.epsilon.gui.screen.MainMenuScreen; -import com.github.epsilon.modules.impl.ClientSetting; -import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.TitleScreen; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -14,10 +11,10 @@ public class MixinTitleScreen { @Inject(method = "init", at = @At("HEAD"), cancellable = true) private void redirectToMainMenu(CallbackInfo ci) { - if (ClientSetting.INSTANCE.useMainMenu.getValue()) { - ci.cancel(); - Minecraft.getInstance().setScreen(MainMenuScreen.INSTANCE); - } +// if (ClientSetting.INSTANCE.useMainMenu.getValue()) { +// ci.cancel(); +// Minecraft.getInstance().setScreen(MainMenuScreen.INSTANCE); +// } } } diff --git a/common/src/main/java/com/github/epsilon/modules/impl/ClientSetting.java b/common/src/main/java/com/github/epsilon/modules/impl/ClientSetting.java index ee5d861c..43ed446d 100644 --- a/common/src/main/java/com/github/epsilon/modules/impl/ClientSetting.java +++ b/common/src/main/java/com/github/epsilon/modules/impl/ClientSetting.java @@ -58,7 +58,7 @@ public enum ThemeMode { public final BoolSetting customTitle = boolSetting("Custom Title", true, _ -> mc.updateTitle()); - public final BoolSetting useMainMenu = boolSetting("Use MainMenu", true); + //public final BoolSetting useMainMenu = boolSetting("Use MainMenu", true); public final BoolSetting soundNotify = boolSetting("Sound Notify", true); diff --git a/common/src/main/java/com/github/epsilon/utils/BytecodeInjector.java b/common/src/main/java/com/github/epsilon/utils/BytecodeInjector.java new file mode 100644 index 00000000..c19560dc --- /dev/null +++ b/common/src/main/java/com/github/epsilon/utils/BytecodeInjector.java @@ -0,0 +1,262 @@ +package com.github.epsilon.utils; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.MethodNode; + + +/** + * 免写ASM的方法头字节码注入工具。 + * + *

设计思路

+ * 不想手写 ASM 来生成字节码?只需将自定义逻辑写在一个 Dummy Class 的普通 Java 方法里, + * 让它随项目一起编译。运行时,通过 {@link #injectMethodHead} 把这个 Dummy 方法的字节码 + * 提取出来,注入到目标方法的最开头。 + * + *

为什么可行

+ * 因为 Dummy 方法的形参和 Target 方法完全一致(Fabric Mixin 天然保证),局部变量表 + * 的 slot 映射是 1:1 的,不需要任何重映射。 + * + *

用法示例

+ *
{@code
+ * // 1. 编写你的逻辑(Dummy Class 与方法)
+ * public class DummyLogic {
+ *     // 形参必须和目标方法一模一样(Mixin handler 天然满足)
+ *     public void dummyMethod(Entity entity, int x, int y, int z) {
+ *         System.out.println("Before original method!");
+ *         entity.setPos(x + 1, y, z);  // 你的任意逻辑
+ *         // 注意:不要写 return!(方法头注入不应提前返回)
+ *     }
+ * }
+ *
+ * // 2. 运行时注入
+ * byte[] targetBytes = getClassBytes("net/minecraft/world/entity/Entity");
+ * byte[] dummyBytes = getClassBytes("com/example/DummyLogic");
+ * byte[] modified = BytecodeInjector.injectMethodHead(
+ *     targetBytes, dummyBytes,
+ *     "methodName", "(Lnet/minecraft/world/entity/Entity;III)V",  // target
+ *     "dummyMethod", "(Lnet/minecraft/world/entity/Entity;III)V"   // dummy
+ * );
+ * redefineClass("net.minecraft.world.entity.Entity", modified);
+ * }
+ * + * @see ASM 9.x + * @see Sponge Mixin + */ +public final class BytecodeInjector { + + private BytecodeInjector() { + throw new UnsupportedOperationException("Utility class"); + } + + // ---- 返回值类型 ---- + + /** + * 注入结果,包含修改后的字节码和诊断信息。 + */ + public static final class InjectResult { + public final byte[] classBytes; + public final int instructionsInjected; + public final int maxStackBefore; + public final int maxStackAfter; + public final int maxLocalsBefore; + public final int maxLocalsAfter; + + InjectResult(byte[] classBytes, int instructionsInjected, + int maxStackBefore, int maxStackAfter, + int maxLocalsBefore, int maxLocalsAfter) { + this.classBytes = classBytes; + this.instructionsInjected = instructionsInjected; + this.maxStackBefore = maxStackBefore; + this.maxStackAfter = maxStackAfter; + this.maxLocalsBefore = maxLocalsBefore; + this.maxLocalsAfter = maxLocalsAfter; + } + } + + // ---- 公开 API ---- + + /** + * 将 Dummy 类中指定方法的方法体注入到目标类同名同参方法的方法头(最开头)。 + * + * @param targetClassBytes 目标类的 raw bytes + * @param dummyClassBytes Dummy 类的 raw bytes(包含要注入的逻辑) + * @param targetMethodName 目标方法名 + * @param targetMethodDesc 目标方法描述符 + * @param dummyMethodName Dummy 方法名(要提取的方法) + * @param dummyMethodDesc Dummy 方法描述符(必须与 target 一致) + * @return 注入结果,包含修改后的字节码 + * @throws IllegalArgumentException 如果方法不存在或参数签名不匹配 + */ + public static InjectResult injectMethodHead( + byte[] targetClassBytes, + byte[] dummyClassBytes, + String targetMethodName, + String targetMethodDesc, + String dummyMethodName, + String dummyMethodDesc) { + + // 1. 解析两个类 + ClassNode targetNode = parseClass(targetClassBytes); + ClassNode dummyNode = parseClass(dummyClassBytes); + + // 2. 找到各自的方法 + MethodNode targetMethod = findMethod(targetNode, targetMethodName, targetMethodDesc); + MethodNode dummyMethod = findMethod(dummyNode, dummyMethodName, dummyMethodDesc); + + if (targetMethod == null) { + throw new IllegalArgumentException( + "Target method not found: " + targetMethodName + targetMethodDesc + + " in " + targetNode.name); + } + if (dummyMethod == null) { + throw new IllegalArgumentException( + "Dummy method not found: " + dummyMethodName + dummyMethodDesc + + " in " + dummyNode.name); + } + + // 3. 验证参数签名兼容 + validateParamCompatibility(targetMethod, dummyMethod); + + // 4. 记录注入前的状态 + int maxStackBefore = targetMethod.maxStack; + int maxLocalsBefore = targetMethod.maxLocals; + + // 5. 移除 dummy 方法末尾的所有 RETURN 族指令 + // 方法头注入不应包含 return,否则原方法体永远得不到执行 + stripReturnInstructions(dummyMethod); + + // 6. 如果有 try-catch 块,迁移到目标方法 + // (标签会随 InsnList.insert() 一起自然迁移) + if (dummyMethod.tryCatchBlocks != null && !dummyMethod.tryCatchBlocks.isEmpty()) { + targetMethod.tryCatchBlocks.addAll(dummyMethod.tryCatchBlocks); + dummyMethod.tryCatchBlocks.clear(); + } + + // 7. 核心操作:将 dummy 的全部指令插入到 target 方法头部 + // InsnList.insert(InsnList) 会把整个链表插入到 firstInsn 之前 + // 并调用 removeAll(false) 清空源链表(指令被"移动"而非复制) + // 标签 LabelNode 随指令一起迁移,因此不需要 cloneLabels + int instructionsInjected = dummyMethod.instructions.size(); + targetMethod.instructions.insert(dummyMethod.instructions); + + // 8. 调整 maxStack 和 maxLocals(取较大值) + targetMethod.maxStack = Math.max(targetMethod.maxStack, dummyMethod.maxStack); + targetMethod.maxLocals = Math.max(targetMethod.maxLocals, dummyMethod.maxLocals); + + // 9. 写出字节码(COMPUTE_FRAMES 让 ASM 自动重算栈帧) + byte[] result = writeClass(targetNode); + + return new InjectResult( + result, instructionsInjected, + maxStackBefore, targetMethod.maxStack, + maxLocalsBefore, targetMethod.maxLocals + ); + } + + // ---- 内部工具方法 ---- + + /** + * 用 ASM ClassReader 将 raw bytes 解析为 ClassNode 树。 + * 使用 EXPAND_FRAMES 确保栈帧信息完整展开。 + */ + private static ClassNode parseClass(byte[] bytes) { + ClassReader reader = new ClassReader(bytes); + ClassNode node = new ClassNode(Opcodes.ASM9); + reader.accept(node, ClassReader.EXPAND_FRAMES); + return node; + } + + /** + * 在 ClassNode 中按 name + descriptor 查找方法。 + */ + private static MethodNode findMethod(ClassNode classNode, String name, String desc) { + for (MethodNode method : classNode.methods) { + if (method.name.equals(name) && method.desc.equals(desc)) { + return method; + } + } + return null; + } + + /** + * 验证目标方法和 Dummy 方法的参数兼容性。 + * 因为局部变量表要对齐,所以参数类型必须严格一致。 + */ + private static void validateParamCompatibility(MethodNode target, MethodNode dummy) { + Type[] targetArgs = Type.getArgumentTypes(target.desc); + Type[] dummyArgs = Type.getArgumentTypes(dummy.desc); + + if (targetArgs.length != dummyArgs.length) { + throw new IllegalArgumentException(String.format( + "Parameter count mismatch: target has %d, dummy has %d. " + + "Methods must have identical parameter lists for LVT alignment.", + targetArgs.length, dummyArgs.length)); + } + + for (int i = 0; i < targetArgs.length; i++) { + if (!targetArgs[i].equals(dummyArgs[i])) { + throw new IllegalArgumentException(String.format( + "Parameter type mismatch at index %d: target=%s, dummy=%s. " + + "All parameter types must be identical for LVT alignment.", + i, targetArgs[i].getClassName(), dummyArgs[i].getClassName())); + } + } + } + + /** + * 从方法末尾剥离所有 RETURN 族指令(IRETURN, LRETURN, FRETURN, DRETURN, ARETURN, RETURN)。 + * + *

方法头注入的场景下,注入的代码不应该包含 return。否则会导致原方法体 + * 永远执行不到。此方法从后往前遍历,删除所有 return 族指令,直到遇到 + * 非 return 的指令为止。 + * + * @return 被移除的指令数量 + */ + private static int stripReturnInstructions(MethodNode method) { + int removed = 0; + InsnList insns = method.instructions; + AbstractInsnNode insn = insns.getLast(); + + while (insn != null && isReturnOpcode(insn.getOpcode())) { + AbstractInsnNode prev = insn.getPrevious(); + insns.remove(insn); + removed++; + insn = prev; + } + + return removed; + } + + /** + * 判断给定 opcode 是否为 RETURN 族指令。 + * 覆盖范围:IRETURN(172) ~ RETURN(177) + */ + private static boolean isReturnOpcode(int opcode) { + return opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN; + } + + /** + * 用 ClassWriter 将 ClassNode 写回 byte[]。 + * 使用 COMPUTE_FRAMES 自动重算栈帧,这比 COMPUTE_MAXS 更准确 + * 但要求 classpath 上有所有引用到的类(Minecraft 环境下通常满足)。 + * 如果帧计算失败,回退到 COMPUTE_MAXS。 + */ + private static byte[] writeClass(ClassNode classNode) { + try { + ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_FRAMES); + classNode.accept(writer); + return writer.toByteArray(); + } catch (Exception e) { + // 帧计算失败(通常是 classpath 不完整),回退到仅计算 maxs + ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); + classNode.accept(writer); + return writer.toByteArray(); + } + } +} diff --git a/common/src/main/java/com/github/epsilon/utils/DummyLogicExample.java b/common/src/main/java/com/github/epsilon/utils/DummyLogicExample.java new file mode 100644 index 00000000..81ef07fc --- /dev/null +++ b/common/src/main/java/com/github/epsilon/utils/DummyLogicExample.java @@ -0,0 +1,275 @@ +package com.github.epsilon.utils; + +import com.github.epsilon.events.bus.EventBus; + +/** + *

Dummy 类编写指南

+ *

+ * 这是配合 {@link BytecodeInjector} 使用的 Dummy Class。你只需在其中写一个普通 Java 方法, + * 方法签名必须和目标方法完全一致(因为 Fabric Mixin handler 天然保证参数签名相同)。 + * + *

关键约束

+ *
    + *
  • 方法签名(参数类型+数量+返回值)必须与目标方法完全一致 —— 局部变量表要对齐
  • + *
  • 方法体内 不要写 return 语句 —— 你的代码运行完后应 fall-through 到原方法体
  • + *
  • 可以使用方法参数(this + 全部形参),slot 映射天然一致
  • + *
  • 可以声明局部变量、调用任何方法、访问字段
  • + *
  • ★ 修改形参 = 修改原方法看到的参数值(字节码层面 slot 共享)
  • + *
+ * + *

形参转发原理(字节码层面)

+ * JVM 方法参数存储在局部变量表的 slot 中。因为 Dummy 和 Target 签名相同, + * 它们的 slot 布局完全一致。当你写 {@code x = newValue} 时,javac 生成 + * {@code ISTORE },直接覆盖原方法的参数槽位。原方法体后续的 + * {@code ILOAD } 就会读到修改后的值。 + * + *

用法

+ * 编译后,运行时获取本类的 raw bytes,和目标的 raw bytes 一起交给 + * {@link BytecodeInjector#injectMethodHead}。 + * + *
{@code
+ * // 获取 raw bytes
+ * byte[] dummyBytes = getClassBytes("com.github.epsilon.utils.DummyLogicExample");
+ * byte[] targetBytes = getClassBytes("net/minecraft/world/entity/Entity");
+ *
+ * // 注入
+ * BytecodeInjector.InjectResult result = BytecodeInjector.injectMethodHead(
+ *     targetBytes, dummyBytes,
+ *     "tick", "()V",                // 目标方法
+ *     "injectedLogic", "()V"        // Dummy 方法
+ * );
+ * redefineClass(Entity.class, result.classBytes);
+ * }
+ * + * @see BytecodeInjector + * @see MethodInterceptEvent + */ +@SuppressWarnings("unused") +public final class DummyLogicExample { + + private DummyLogicExample() { + } + + // ============================================================ + // 示例 1:注入到无参方法头 (如 Entity.tick()V) + // ============================================================ + // 目标方法签名: public void tick() + // Dummy 签名: public void injectedLogic() + // + // 注意:注入到实例方法时,dummy 方法也必须是实例方法(非 static), + // 这样 slot 0 的 "this" 引用才会对齐。 + + /** + * 注入到 Entity.tick()V 的方法头。 + * 这里写你想在 tick() 开头执行的任意逻辑。 + */ + public void injectedLogic() { + // 示例:打印一条日志 + System.out.println("[Epsilon] Entity.tick() called!"); + + // 可以访问 this(slot 0 指向目标 Entity 实例) + // ((Entity)(Object)this).setCustomNameVisible(true); + + // 不要写 return!方法体会 fall-through 到原来的 tick() 逻辑 + } + + // ============================================================ + // 示例 2:注入到有参方法头 (如 Entity.setPos(DDD)V) + // ============================================================ + // 目标方法签名: public void setPos(double x, double y, double z) + // Dummy 签名: public void injectedSetPos(double x, double y, double z) + + /** + * 注入到 Entity.setPos(DDD)V 的方法头。 + * 参数列表必须和目标方法一致:double x, double y, double z + */ + public void injectedSetPos(double x, double y, double z) { + // 可以读取和修改参数 + System.out.printf("[Epsilon] setPos called: x=%.2f, y=%.2f, z=%.2f%n", x, y, z); + + // 可以写条件逻辑 + if (y < 0) { + System.out.println("[Epsilon] Player is below y=0!"); + } + + // 注意:修改参数的值不会影响调用者 + // (Java 是值传递,但你可以通过修改对象字段来产生影响) + // 不要写 return! + } + + // ============================================================ + // 示例 3:注入到 static 方法头(slot 0 就是第一个参数) + // ============================================================ + public static void injectedStatic(int a) { + System.out.println("[Epsilon] Static called with a=" + a); + a = a * 2; // ★ 对 static 方法同样可以直接修改形参 + // slot 0 = 参数a(因为没有 this),所以 ISTORE 0 + } + + // ============================================================ + // 示例 4:有返回值的方法(不写 return,原方法决定返回值) + // ============================================================ + @SuppressWarnings("SameReturnValue") + public boolean injectedIsAlive() { + System.out.println("[Epsilon] isAlive() check intercepted!"); + return true; // BytecodeInjector 会自动剥离 RETURN + } + + // ============================================================ + // 示例 5:★ 形参转发 + 回写(配合 MethodInterceptEvent) + // ============================================================ + // 核心演示:转发参数 → EventBus → 监听器修改 → 写回形参 → 原方法看到修改 + // + // 目标方法签名: public void tick(int ticks) + // Dummy 签名: public void interceptTick(int ticks) + // + // 关键步骤: + // 1. 用形参构造 MethodInterceptEvent(从 slot 读取参数) + // 2. 通过 EventBus 分发给所有监听器 + // 3. ★ 把 event 中可能被修改的值写回形参(写回同一 slot) + // 4. 检查 isCancelled() 决定是否跳过原方法体 + + /** + * 拦截 Entity 或某类的 tick(int) 方法,将参数转发给监听器处理。 + */ + public void interceptTick(int ticks) { + // 1. 打包参数 + MethodInterceptEvent event = new MethodInterceptEvent(ticks); + + // 2. 发送事件(监听器可以自由修改 event 中的值) + EventBus.INSTANCE.post(event); + + // 3. ★ 把修改后的值写回形参! + // 编译器生成:ILOAD , INVOKEVIRTUAL getInt, ISTORE + // 这个 ISTORE 直接写入原方法的参数 slot,原方法体随后 ILOAD 就会读到新值 + ticks = event.getInt(0); + + // 4. 如果监听器取消了事件,跳过原方法体 + // 注意:这里不能用 return,因为 BytecodeInjector 会剥离 RETURN + // 解决办法:用 if-else 包裹原方法调用 + // 更简单的:如果不取消,正常 fall-through;如果取消了,写一个标志位 + // 或者:直接在 event 中设置一个 dirty flag,让后续模块检查 + } + + // ============================================================ + // 示例 6:★ 直接修改形参(无需 EventBus,最简单) + // ============================================================ + // 如果你不需要事件系统,只想在方法执行前修改参数,直接赋值即可。 + // 因为编译后的 ISTORE 操作的就是原方法的 slot。 + // + // 目标方法签名: public void setMotion(double x, double y, double z) + // Dummy 签名: public void modifyMotion(double x, double y, double z) + + /** + * 直接修改方法参数(无需事件)。 + * 修改后原方法体看到的 x, y, z 就是新值。 + */ + public void modifyMotion(double x, double y, double z) { + // 直接修改形参 —— ISTORE 到 slot 1/2/3/4/5 + // 原方法体后续 ILOAD/DLOAD 会读到修改后的值 + if (y < 0.0) { + y = 0.0; // 把负的 y 归零 + } + x *= 1.5; // 加速 x + z *= 1.5; // 加速 z + } + + // ============================================================ + // 示例 7:★ 形参转发(多类型混合) + // ============================================================ + // 目标方法签名: public void handleInput(int key, boolean pressed, String context) + // Dummy 签名: public void interceptInput(int key, boolean pressed, String context) + + /** + * 多类型参数转发+回写。注意每个参数都要写回。 + */ + public void interceptInput(int key, boolean pressed, String context) { + // 1. 打包(注意 boolean 会被自动装箱为 Boolean) + MethodInterceptEvent event = new MethodInterceptEvent(key, pressed, context); + + // 2. 分发给监听器 + EventBus.INSTANCE.post(event); + + // 3. ★ 写回每个参数(缺一不可!) + key = event.getInt(0); + pressed = event.getBoolean(1); + context = event.getObject(2); + + // 如果监听器把 key 改成了 -1(表示拦截),后续模块可以据此判断 + } + + // ============================================================ + // 示例 8:★ Mixin 变换模板(配合 MixinRuntimeLauncher) + // ============================================================ + // 当你需要 @Inject、@Overwrite、@ModifyVariable 等 Mixin 注解能力时, + // 使用 MixinRuntimeLauncher 代替 BytecodeInjector。 + // + // 前提:你的 Dummy 类必须编译为正常的 @Mixin 类(有 @Mixin 注解, + // 方法上有 @Inject/@Overwrite 等注解)。然后用 MixinRuntimeLauncher + // 在运行时加载它的 raw bytes 并触发完整的 Mixin 变换管线。 + // + // 使用示例(在你的 Mixin 插件中): + //
{@code
+    // // 1. 获取目标类和模板 Mixin 的 raw bytes
+    // byte[] targetBytes = YourRuntimeClassProvider.getBytes("net.minecraft.world.entity.Entity");
+    // byte[] mixinBytes = YourRuntimeClassProvider.getBytes("com.github.epsilon.mixins.MixinEntity");
+    //
+    // // 2. 一步调用:启动 Mixin 管线
+    // byte[] transformed = MixinRuntimeLauncher.applyMixin(
+    //     "net.minecraft.world.entity.Entity",  // 目标类全限定名
+    //     targetBytes,                           // 目标类 raw bytes
+    //     mixinBytes                             // Mixin 类 raw bytes
+    // );
+    //
+    // // 3. 重定义类
+    // YourClassRedefiner.redefine("net.minecraft.world.entity.Entity", transformed);
+    // }
+ // + // 和 BytecodeInjector 的区别: + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + //
特性BytecodeInjectorMixinRuntimeLauncher
方法头注入✓ (InsnList.insert)✓ (通过 @Inject(at=@At("HEAD")))
@Inject 任意位置
@Overwrite
@ModifyVariable
@ModifyArg
@Redirect
描述符重映射✗ (需手动)✓ (自动)
@Shadow 字段/方法
CallbackInfo
简单直接✓✓✓
+ + // ============================================================ + // 示例 9:★ 混合模式 —— BytecodeInjector 参数转发 + MixinRuntimeLauncher 管线 + // ============================================================ + // 两种工具可以组合使用: + // 1. 先用 BytecodeInjector 注入自定义方法头(形参转发 + EventBus) + // 2. 再用 MixinRuntimeLauncher 启动完整 Mixin 管线(@Inject 等) + // + // 这让你同时拥有"免写 ASM"和"Mixin 注解全能力"。 + // + // 注意:MixinRuntimeLauncher 要求 Mixin 框架已初始化。 + // 在 Fabric/NeoForge 的 Minecraft 环境中,默认在 PREINIT 阶段完成。 + // 你可以在 INIT 或之后的阶段安全调用 applyMixin()。 +} diff --git a/common/src/main/java/com/github/epsilon/utils/MethodInterceptEvent.java b/common/src/main/java/com/github/epsilon/utils/MethodInterceptEvent.java new file mode 100644 index 00000000..c64e755f --- /dev/null +++ b/common/src/main/java/com/github/epsilon/utils/MethodInterceptEvent.java @@ -0,0 +1,124 @@ +package com.github.epsilon.utils; + +import com.github.epsilon.events.Cancellable; + +/** + * 方法拦截事件 —— 配合 {@link BytecodeInjector} 实现形参转发+回写。 + * + *

设计思路

+ * 运行时,Dummy 方法的字节码被注入到目标方法头。因为两个方法的参数签名一致, + * 它们在 JVM 局部变量表中共享完全相同的 slot。这意味着:在 Dummy 方法里对形参的 + * 重新赋值会直接修改原方法看到的参数值。 + * + *

字节码原理

+ * 假设目标方法和 Dummy 方法签名都是 {@code (IDLjava/lang/String;)V}: + *
+ * JVM 局部变量表(共享):
+ *   slot 0   →  this
+ *   slot 1   →  int x
+ *   slot 2-3 →  double y
+ *   slot 4   →  String z
+ *
+ * Dummy 方法体被注入时执行:
+ *   x = newValue;   →  ISTORE 1    (写入 slot 1)
+ *
+ * 原方法体执行时:
+ *   ILOAD 1          → 读到的是修改后的值!
+ * 
+ * + *

用法示例

+ *
{@code
+ * // Dummy 类中
+ * public void dummyMethod(int x, double y, String z) {
+ *     MethodInterceptEvent event = new MethodInterceptEvent(x, y, z);
+ *     EventBus.INSTANCE.post(event);
+ *
+ *     // ★ 关键:必须把修改后的值写回形参!
+ *     // 因为编译器生成的 ISTORE/DSTORE/ASTORE 操作的正是
+ *     // 原方法的局部变量槽位
+ *     x = event.getInt(0);
+ *     y = event.getDouble(1);
+ *     z = (String) event.getObject(2);
+ * }
+ *
+ * // 监听器中
+ * EventBus.INSTANCE.register(MethodInterceptEvent.class, event -> {
+ *     event.setInt(0, 42);   // 修改 int 参数
+ *     event.cancel();        // 可选:取消原方法执行
+ * });
+ * }
+ * + * @see BytecodeInjector + */ +public class MethodInterceptEvent extends Cancellable { + + private final Object[] args; + + /** + * @param args 方法的所有参数(按声明顺序) + */ + public MethodInterceptEvent(Object... args) { + this.args = args; + } + + // ---- 类型安全的读写 ---- + + public int getInt(int index) { + return (int) args[index]; + } + + public void setInt(int index, int value) { + args[index] = value; + } + + public long getLong(int index) { + return (long) args[index]; + } + + public void setLong(int index, long value) { + args[index] = value; + } + + public float getFloat(int index) { + return (float) args[index]; + } + + public void setFloat(int index, float value) { + args[index] = value; + } + + public double getDouble(int index) { + return (double) args[index]; + } + + public void setDouble(int index, double value) { + args[index] = value; + } + + public boolean getBoolean(int index) { + return (boolean) args[index]; + } + + public void setBoolean(int index, boolean value) { + args[index] = value; + } + + @SuppressWarnings("unchecked") + public T getObject(int index) { + return (T) args[index]; + } + + public void setObject(int index, Object value) { + args[index] = value; + } + + /** 参数个数 */ + public int getArgCount() { + return args.length; + } + + /** 直接访问内部数组(谨慎使用) */ + public Object[] getArgs() { + return args; + } +} diff --git a/common/src/main/java/com/github/epsilon/utils/MixinMappingTable.java b/common/src/main/java/com/github/epsilon/utils/MixinMappingTable.java new file mode 100644 index 00000000..56b9e963 --- /dev/null +++ b/common/src/main/java/com/github/epsilon/utils/MixinMappingTable.java @@ -0,0 +1,261 @@ +package com.github.epsilon.utils; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Mixin → Target Minecraft 类的完整映射表。 + * + *

用法

+ *
{@code
+ * for (var entry : MixinMappingTable.COMMON_MIXINS.entrySet()) {
+ *     String mixinInternalName = entry.getKey();
+ *     String targetInternalName = entry.getValue();
+ *
+ *     byte[] mixinBytes = getClassBytes(mixinInternalName);    // 你的 bytes 提供器
+ *     byte[] targetBytes = getClassBytes(targetInternalName);
+ *
+ *     byte[] transformed = MixinRuntimeLauncher.applyMixin(
+ *         targetInternalName.replace('/', '.'),
+ *         targetBytes,
+ *         mixinBytes
+ *     );
+ *     redefineClass(targetInternalName.replace('/', '.'), transformed);
+ * }
+ * }
+ * + *

说明

+ *
    + *
  • Key = Mixin 类 internal name(斜杠分隔)
  • + *
  • Value = Target Minecraft 类 internal name(斜杠分隔)
  • + *
  • 顺序与 {@code epsilon.mixins.json} 注册顺序一致
  • + *
  • Accessor 接口(I*)只提供 getter,不含业务逻辑注入,可以不热重定义
  • + *
  • Fabric/NeoForge 的 MixinMinecraft 和 MixinGuiRenderer 是 loader 专用,热重定义时用 COMMON_MIXINS 即可
  • + *
+ * + * @see MixinRuntimeLauncher + * @see BytecodeInjector + */ +public final class MixinMappingTable { + + private MixinMappingTable() { + } + + /** + * Common 模块的 Mixin → Target 完整映射(43 条)。 + * 使用 LinkedHashMap 保持注册顺序。 + */ + public static final Map COMMON_MIXINS = new LinkedHashMap<>(); + + static { + // === Accessor 接口(5 个)=== + COMMON_MIXINS.put( + "com/github/epsilon/mixins/IAbstractContainerScreen", + "net/minecraft/client/gui/screens/inventory/AbstractContainerScreen" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/IMinecraft", + "net/minecraft/client/Minecraft" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/IReloadState", + "net/minecraft/client/ResourceLoadStateTracker$ReloadState" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/IResourceLoadStateTracker", + "net/minecraft/client/ResourceLoadStateTracker" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/IServerboundMovePlayerPacket", + "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket" + ); + + // === 渲染相关 Mixin(11 个)=== + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinAvatarRenderer", + "net/minecraft/client/renderer/entity/player/AvatarRenderer" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinBlockCollisions", + "net/minecraft/world/level/BlockCollisions" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinCamera", + "net/minecraft/client/Camera" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinChatComponent", + "net/minecraft/client/gui/components/ChatComponent" // targets inner: DrawingFocusedGraphicsAccess, DrawingBackgroundGraphicsAccess + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinEntityRenderer", + "net/minecraft/client/renderer/entity/LivingEntityRenderer" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinGameRenderer", + "net/minecraft/client/renderer/GameRenderer" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinHumanoidMobRenderer", + "net/minecraft/client/renderer/entity/HumanoidMobRenderer" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinItemInHandRenderer", + "net/minecraft/client/renderer/ItemInHandRenderer" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinLevelRenderer", + "net/minecraft/client/renderer/LevelRenderer" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinLivingEntityRenderer", + "net/minecraft/client/renderer/entity/LivingEntityRenderer" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinParticleManager", + "net/minecraft/client/particle/ParticleEngine" + ); + + // === 客户端核心 Mixin(11 个)=== + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinClientLevel", + "net/minecraft/client/multiplayer/ClientLevel" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinClientPacketListener", + "net/minecraft/client/multiplayer/ClientPacketListener" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinConnection", + "net/minecraft/network/Connection" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinGlDebug", + "com/mojang/blaze3d/opengl/GlDebug" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinGui", + "net/minecraft/client/gui/Gui" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinGuiRenderer", + "net/minecraft/client/gui/render/GuiRenderer" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinLightmap", + "net/minecraft/client/renderer/Lightmap" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinMinecraft", + "net/minecraft/client/Minecraft" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinMouseHandler", + "net/minecraft/client/MouseHandler" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinScreenEffectRenderer", + "net/minecraft/client/renderer/ScreenEffectRenderer" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinWindow", + "com/mojang/blaze3d/platform/Window" + ); + + // === 实体/玩家 Mixin(9 个)=== + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinEntity", + "net/minecraft/world/entity/Entity" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinFireworkRocketEntity", + "net/minecraft/world/entity/projectile/FireworkRocketEntity" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinKeyboardInput", + "net/minecraft/client/player/KeyboardInput" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinLivingEntity", + "net/minecraft/world/entity/LivingEntity" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinLocalPlayer", + "net/minecraft/client/player/LocalPlayer" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinMultiPlayerGameMode", + "net/minecraft/client/multiplayer/MultiPlayerGameMode" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinPlayer", + "net/minecraft/world/entity/player/Player" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinProjection", + "net/minecraft/client/renderer/Projection" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinMobEffectFogEnvironment", + "net/minecraft/client/renderer/fog/environment/MobEffectFogEnvironment" + ); + + // === 输入/物品/方块等 Mixin(7 个)=== + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinKeyboardHandler", + "net/minecraft/client/KeyboardHandler" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinMain", + "net/minecraft/client/main/Main" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinTitleScreen", + "net/minecraft/client/gui/screens/TitleScreen" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinItem", + "net/minecraft/world/item/Item" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinFlowingFluid", + "net/minecraft/world/level/material/FlowingFluid" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinWebBlock", + "net/minecraft/world/level/block/WebBlock" + ); + COMMON_MIXINS.put( + "com/github/epsilon/mixins/MixinDataComponentInitializers", + "net/minecraft/core/component/DataComponentInitializers" + ); + } + + /** + * 仅含真正注入逻辑的 Mixin(排除 Accessor 接口,共 38 条)。 + * 热重定义时通常只需要这个。 + */ + public static final Map INJECTION_MIXINS = new LinkedHashMap<>(); + + static { + for (var entry : COMMON_MIXINS.entrySet()) { + String simpleName = entry.getKey().substring(entry.getKey().lastIndexOf('/') + 1); + if (!simpleName.startsWith("I")) { + INJECTION_MIXINS.put(entry.getKey(), entry.getValue()); + } + } + } + + // ==================== 便捷内部名获取 ==================== + + /** 获取 Mixin 类的 internal name */ + public static String mixinInternalName(String simpleName) { + return "com/github/epsilon/mixins/" + simpleName; + } + + /** 获取 Target MC 类的 internal name(未包含在表中的返回 null) */ + public static String targetInternalName(String mixinSimpleName) { + return COMMON_MIXINS.get(mixinInternalName(mixinSimpleName)); + } + +} diff --git a/common/src/main/java/com/github/epsilon/utils/MixinRuntimeLauncher.java b/common/src/main/java/com/github/epsilon/utils/MixinRuntimeLauncher.java new file mode 100644 index 00000000..24a54124 --- /dev/null +++ b/common/src/main/java/com/github/epsilon/utils/MixinRuntimeLauncher.java @@ -0,0 +1,338 @@ +package com.github.epsilon.utils; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.tree.ClassNode; +import org.spongepowered.asm.mixin.MixinEnvironment; +import org.spongepowered.asm.mixin.transformer.IMixinTransformer; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.*; + +/** + * 运行时启动 SpongePowered Mixin 变换管线的工具。 + * + *

解决的问题

+ * 正常 Mixin 通过 classpath 加载目标类和 Mixin 类。本工具让你可以: + *
    + *
  • 用自定义 {@code byte[]} 提供目标类的 raw bytes
  • + *
  • 用自定义 {@code byte[]} 提供Mixin 模板类的 raw bytes
  • + *
  • 触发 Mixin 完整管线:描述符重映射 + {@code @Inject} + {@code @Overwrite} + 字段合并等
  • + *
+ * + *

Mixin 变换管线概述(源码追踪)

+ *
+ * IMixinTransformer.transformClass(env, name, byte[])
+ *   → MixinTransformer.transformClass()
+ *     → MixinProcessor.applyMixins(env, name, classNode)
+ *       → 遍历 MixinConfig → hasMixinsFor(name) → getMixinsFor(name)
+ *       → TargetClassContext(env, extensions, sessionId, name, classNode, mixins)
+ *       → TargetClassContext.applyMixins()
+ *         → MixinApplicatorStandard.apply(mixins)
+ *           → 对每个 MixinInfo: mixin.createContextFor(target)
+ *             → MixinPreProcessorStandard.createContextFor()
+ *               → MixinTargetContext(mixin, classNode, target)
+ *               → transformMethod() [描述符重映射]
+ *               → prepareInjections() → applyInjections() [@Inject]
+ *           → applyFields() / applyMethods() [合并到目标类]
+ * 
+ * + *

实现原理

+ *

+ * Mixin 管线的核心瓶颈在于:Mixin 类必须通过 MixinConfig 注册,且必须能从 classpath 加载。 + * 本工具通过反射直接操作 Mixin 内部数据结构({@code MixinConfig.mixinMapping}、 + * {@code MixinInfo.state}),将自定义 ClassNode 注入到管线中,绕过 classpath 加载限制。 + *

+ * + *

提供两个入口点

+ * + * + * + * + * + * + * + * + * + * + * + * + *
入口正常 Mixin本工具
目标类 bytes{@code transformClassBytes(name, _, basicClass)} — classLoader 提供{@code transformClass(env, name, classNode)} — 直接接受 ClassNode
Mixin 类 bytes{@code MixinInfo.loadMixinClass()} → {@code getBytecodeProvider().getClassNode()} — classpath 加载反射替换 {@code MixinInfo.state} 中的 ClassNode → 自定义 bytes 解析的 ClassNode
+ * + *

前置条件

+ *
    + *
  1. Mixin 框架必须已初始化(在 Fabric/NeoForge 环境中默认满足)
  2. + *
  3. 项目中至少要有一个在 {@code epsilon.mixins.json} 中注册的正常 Mixin 类(本工具复用其 MixinConfig)
  4. + *
  5. 提供的 Mixin 类 raw bytes 必须包含合法的 {@code @Mixin} 注解
  6. + *
+ * + * @see BytecodeInjector 更轻量的字节码注入(不需要 @Mixin 注解,直接拼方法头) + * @see SpongePowered Mixin 源码 + */ +public final class MixinRuntimeLauncher { + + private MixinRuntimeLauncher() { + } + + /** + * 一步完成:用自定义 bytes 启动 Mixin 变换管线。 + * + * @param targetClassName 目标类全限定名,如 {@code "net.minecraft.client.Minecraft"} + * @param targetRawBytes 目标类的 raw byte[] + * @param mixinRawBytes Mixin 模板类的 raw byte[](必须有 {@code @Mixin} 注解) + * @return 变换后的目标类 byte[],如果目标不是任何 Mixin 的目标则返回原始 bytes + */ + public static byte[] applyMixin(String targetClassName, byte[] targetRawBytes, byte[] mixinRawBytes) { + try { + // 1. 获取 Mixin 基础设施 + MixinEnvironment env = MixinEnvironment.getCurrentEnvironment(); + Object activeTransformer = env.getActiveTransformer(); + if (!(activeTransformer instanceof IMixinTransformer transformer)) { + throw new IllegalStateException( + "No active IMixinTransformer. Mixin may not be initialized yet." + ); + } + + // 2. 将两个 raw bytes 都解析为 ClassNode + ClassNode mixinNode = toClassNode(mixinRawBytes); + ClassNode targetNode = toClassNode(targetRawBytes); + String targetInternalName = targetClassName.replace('.', '/'); + + // 3. 核心:把自定义 Mixin ClassNode 注入到 Mixin 内部管线 + registerMixinWithCustomClassNode(transformer, targetClassName, mixinNode); + + // 4. 触发 Mixin 变换(修改 targetNode 本身,返回 true 表示有变换发生) + boolean applied = transformer.transformClass(env, targetInternalName, targetNode); + + if (applied) { + return toByteArray(targetNode); + } + // 未匹配到任何 Mixin,返回原始 bytes + return targetRawBytes; + } catch (Exception e) { + throw new RuntimeException( + "MixinRuntimeLauncher: Failed to apply mixin to " + targetClassName, e + ); + } + } + + /** + * 注册一个自定义 Mixin 类到 Mixin 管线中。 + *

+ * 核心步骤(通过反射操作 Mixin 内部状态): + *

    + *
  1. 获取 {@code MixinTransformer → MixinProcessor → configs} 列表
  2. + *
  3. 取第一个可用 MixinConfig 作为"宿主"
  4. + *
  5. 取该 Config 中任意一个已有 MixinInfo 作为"模板"(继承其 parent/config/service 引用)
  6. + *
  7. 创建新的 MixinInfo.State(包装我们的自定义 ClassNode)
  8. + *
  9. 将 (targetClassName → 新 MixinInfo) 注入到 Config 的 mapping 中
  10. + *
+ *

+ */ + private static void registerMixinWithCustomClassNode( + IMixinTransformer transformer, + String targetClassName, + ClassNode mixinClassNode + ) throws Exception { + + // ---------- 第 1 步:通过反射获取 MixinProcessor ---------- + // MixinTransformer 是 package-private final class,IMixinTransformer 是其公共接口 + Field processorField = findField(transformer.getClass(), "processor"); + Object mixinProcessor = processorField.get(transformer); + + // ---------- 第 2 步:获取 configs 列表 ---------- + @SuppressWarnings("unchecked") + List configs = (List) findField(mixinProcessor.getClass(), "configs").get(mixinProcessor); + + if (configs.isEmpty()) { + throw new IllegalStateException("No MixinConfig registered. At least one mixin must exist in epsilon.mixins.json."); + } + + // 取第一个 Config 作为宿主(任意 Config 都可以,它们共享同一个 Extensions/SessionId) + Object hostConfig = configs.get(0); + + // ---------- 第 3 步:获取 Config 的 mixinMapping ---------- + @SuppressWarnings("unchecked") + Map> mixinMapping = (Map>) + findField(hostConfig.getClass(), "mixinMapping").get(hostConfig); + + // 如果目标类已经注册过了,跳过 + if (mixinMapping.containsKey(targetClassName)) { + return; + } + + // ---------- 第 4 步:从 hostConfig 获取已有的 MixinInfo 作为模板 ---------- + // mixinMapping 中任意一个已有条目都可以提供 parent/service/extensions 引用 + Object templateMixinInfo = null; + for (List mixins : mixinMapping.values()) { + if (!mixins.isEmpty()) { + templateMixinInfo = mixins.get(0); + break; + } + } + + if (templateMixinInfo == null) { + throw new IllegalStateException("No existing MixinInfo found to use as template."); + } + + // ---------- 第 5 步:创建新的 MixinInfo(克隆模板的关键引用)---------- + Object newMixinInfo = createMixinInfoClone(templateMixinInfo, mixinClassNode, targetClassName, hostConfig); + + // ---------- 第 6 步:注入到 mapping 中 ---------- + mixinMapping.computeIfAbsent(targetClassName, k -> new ArrayList<>()).add(newMixinInfo); + + // 同时注入到 unhandledTargets(可选,用于审计) + try { + @SuppressWarnings("unchecked") + Set unhandledTargets = (Set) + findField(hostConfig.getClass(), "unhandledTargets").get(hostConfig); + unhandledTargets.add(targetClassName); + } catch (Exception ignored) { + // unhandledTargets 仅用于审计,不影响功能 + } + } + + /** + * 克隆一个 MixinInfo 并用自定义 ClassNode 替换其 state。 + *

+ * MixinInfo 是 package-private 的,无法直接 new。我们利用 + * {@code Unsafe.allocateInstance()} 或反射构造器创建实例,然后注入字段。 + *

+ *

+ * 关键点: + *

    + *
  • {@code MixinInfo.state} 包装了 ClassNode,在 {@code createContextFor()} 时克隆使用
  • + *
  • 必须设置 {@code parent}, {@code className}, {@code name}, {@code priority} 等字段
  • + *
  • {@code MixinInfo.State} 是非静态内部类,需要 outer MixinInfo 引用
  • + *
+ *

+ */ + private static Object createMixinInfoClone( + Object template, + ClassNode customClassNode, + String targetClassName, + Object hostConfig + ) throws Exception { + + Class mixinInfoClass = template.getClass(); + + // 使用 sun.misc.Unsafe 创建未初始化的实例(绕过构造器中的 loadMixinClass 调用) + Object newInfo = allocateInstance(mixinInfoClass); + + // 复制模板的所有字段 + for (Field field : mixinInfoClass.getDeclaredFields()) { + field.setAccessible(true); + try { + Object value = field.get(template); + field.set(newInfo, value); + } catch (IllegalAccessException ignored) { + } + } + + // 覆盖关键字段 + setField(mixinInfoClass, newInfo, "parent", hostConfig); + setField(mixinInfoClass, newInfo, "name", customClassNode.name.substring( + customClassNode.name.lastIndexOf('/') + 1)); + setField(mixinInfoClass, newInfo, "className", customClassNode.name.replace('/', '.')); + + // 创建新的 MixinInfo.State(内嵌我们的 ClassNode) + // State 是非静态内部类: State(MixinInfo outer, ClassNode classNode, ClassInfo classInfo) + Class stateClass = findInnerClass(mixinInfoClass, "State"); + Constructor stateCtor = stateClass.getDeclaredConstructor( + mixinInfoClass, ClassNode.class, Class.forName("org.spongepowered.asm.mixin.transformer.ClassInfo") + ); + stateCtor.setAccessible(true); + Object newState = stateCtor.newInstance(newInfo, customClassNode, null); + + // 覆盖 state 字段(注意:可能叫 state 或 pendingState) + setField(mixinInfoClass, newInfo, "state", newState); + try { + setField(mixinInfoClass, newInfo, "pendingState", null); + } catch (Exception ignored) { + } + + // 设置 declaredTargets(从 @Mixin 注解解析出来的目标类列表) + // 这些目标在 prepareMixins() 阶段已经解析好了,我们只需确保它们包含我们的 target + try { + @SuppressWarnings("unchecked") + List oldTargetClasses = (List) + findField(mixinInfoClass, "targetClasses").get(newInfo); + List oldTargetClassNames = new ArrayList<>(); + for (Object t : oldTargetClasses) { + oldTargetClassNames.add(t.toString()); + } + @SuppressWarnings("unchecked") + List targetClassNames = (List) + findField(mixinInfoClass, "targetClassNames").get(newInfo); + if (!targetClassNames.contains(targetClassName)) { + targetClassNames.add(targetClassName); + } + } catch (Exception ignored) { + } + + return newInfo; + } + + // ==================== 辅助方法 ==================== + + private static ClassNode toClassNode(byte[] bytes) { + ClassNode node = new ClassNode(); + new ClassReader(bytes).accept(node, ClassReader.EXPAND_FRAMES); + return node; + } + + private static byte[] toByteArray(ClassNode classNode) { + ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_FRAMES); + classNode.accept(writer); + return writer.toByteArray(); + } + + private static Field findField(Class clazz, String name) throws NoSuchFieldException { + Class current = clazz; + while (current != null) { + try { + Field field = current.getDeclaredField(name); + field.setAccessible(true); + return field; + } catch (NoSuchFieldException e) { + current = current.getSuperclass(); + } + } + throw new NoSuchFieldException(name + " not found in " + clazz.getName() + " hierarchy"); + } + + private static void setField(Class clazz, Object instance, String name, Object value) throws Exception { + Field field = findField(clazz, name); + field.set(instance, value); + } + + private static Class findInnerClass(Class outerClass, String simpleName) throws ClassNotFoundException { + for (Class inner : outerClass.getDeclaredClasses()) { + if (inner.getSimpleName().equals(simpleName)) { + return inner; + } + } + // 也可能定义在父类中 + if (outerClass.getSuperclass() != null) { + return findInnerClass(outerClass.getSuperclass(), simpleName); + } + throw new ClassNotFoundException(simpleName + " not found in " + outerClass.getName()); + } + + private static Object allocateInstance(Class clazz) throws Exception { + try { + // Java 9+ 首选 + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field theUnsafe = unsafeClass.getDeclaredField("theUnsafe"); + theUnsafe.setAccessible(true); + Object unsafe = theUnsafe.get(null); + return unsafeClass.getMethod("allocateInstance", Class.class).invoke(unsafe, clazz); + } catch (Exception e) { + // 备选:反射调用默认构造器 + Constructor ctor = clazz.getDeclaredConstructor(); + ctor.setAccessible(true); + return ctor.newInstance(); + } + } +} diff --git a/fabric/build.gradle.kts b/fabric/build.gradle.kts index 68b35181..b9fdf526 100644 --- a/fabric/build.gradle.kts +++ b/fabric/build.gradle.kts @@ -14,11 +14,11 @@ dependencies { implementation("net.fabricmc.fabric-api:fabric-api:${fabricVersion}") compileOnly(group = "com.google.code.findbugs", name = "jsr305", version = "3.0.2") - implementation(include("org.bytedeco:javacpp:1.5.10")!!) - implementation(include("org.bytedeco:javacv:1.5.10")!!) - implementation(include("org.bytedeco:ffmpeg:6.1.1-1.5.10")!!) - runtimeOnly(include("org.bytedeco:javacpp:1.5.10:windows-x86_64")!!) - runtimeOnly(include("org.bytedeco:ffmpeg:6.1.1-1.5.10:windows-x86_64")!!) +// implementation(include("org.bytedeco:javacpp:1.5.10")!!) +// implementation(include("org.bytedeco:javacv:1.5.10")!!) +// implementation(include("org.bytedeco:ffmpeg:6.1.1-1.5.10")!!) +// runtimeOnly(include("org.bytedeco:javacpp:1.5.10:windows-x86_64")!!) +// runtimeOnly(include("org.bytedeco:ffmpeg:6.1.1-1.5.10:windows-x86_64")!!) } loom { @@ -59,3 +59,14 @@ sourceSets.configureEach { } } } + +/* +tasks.register("extractRuntimeClasspath") { + from(configurations.runtimeClasspath) + into("$projectDir/build/runtimeClasspath") + + doFirst { + file("$projectDir/build/runtimeClasspath").mkdirs() + } +} +*/ diff --git a/gradle.properties b/gradle.properties index fe4b8771..c0cda17b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ org.gradle.caching=true org.gradle.configuration-cache=false # Project -version=2026.3.0 +version=2026.3.1 group=com.github.epsilon java_version=25 diff --git a/neoforge/build.gradle.kts b/neoforge/build.gradle.kts index d4979b72..06e7388e 100644 --- a/neoforge/build.gradle.kts +++ b/neoforge/build.gradle.kts @@ -7,11 +7,11 @@ val neoforgeVersion = project.property("neoforge_version").toString() val modId = project.property("mod_id").toString() dependencies { - implementation(jarJar("org.bytedeco:javacpp:1.5.10")!!) - implementation(jarJar("org.bytedeco:javacv:1.5.10")!!) - implementation(jarJar("org.bytedeco:ffmpeg:6.1.1-1.5.10")!!) - runtimeOnly(jarJar("org.bytedeco:javacpp:1.5.10:windows-x86_64")!!) - runtimeOnly(jarJar("org.bytedeco:ffmpeg:6.1.1-1.5.10:windows-x86_64")!!) +// implementation(jarJar("org.bytedeco:javacpp:1.5.10")!!) +// implementation(jarJar("org.bytedeco:javacv:1.5.10")!!) +// implementation(jarJar("org.bytedeco:ffmpeg:6.1.1-1.5.10")!!) +// runtimeOnly(jarJar("org.bytedeco:javacpp:1.5.10:windows-x86_64")!!) +// runtimeOnly(jarJar("org.bytedeco:ffmpeg:6.1.1-1.5.10:windows-x86_64")!!) } neoForge {