Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8f4143b
fix(tick): 所有版本都派发 TickEvent,ClickGUI 键解绑后不再每帧报 GLFW 错误
gaoyu06 Oct 16, 2026
cc72d25
feat(ui): 升级 Prism 0.2.2 / Cadence 0.1.2,宿主适配,链接与文件选择不再依赖 AWT
gaoyu06 Oct 16, 2026
31b9525
fix(music): 播放会话带代际号,切歌不再在渲染线程 join,也不会两首叠放
gaoyu06 Oct 16, 2026
b7c072d
fix(multiplayer): 上下移动服务器时把显示下标换算回 servers.dat 下标
gaoyu06 Oct 16, 2026
6fdd778
fix(hud): 计分板按 vanilla 排序并套用队伍前后缀;HUD 只测量要显示的组件
gaoyu06 Oct 16, 2026
09c2d11
fix(cosmetics): 解析失败指数退避、贴图失败记忆、换号重新绑定;API 按 code 处理错误
gaoyu06 Oct 16, 2026
d91a955
feat(replay): 进服自动录制与“保存最近 N 分钟”;录像总能打开;写盘不阻塞
gaoyu06 Oct 16, 2026
880f0cd
feat(config): 原子写、滚动备份、损坏自动恢复与一键回滚;写盘移出渲染线程
gaoyu06 Oct 16, 2026
1d12ddc
fix(auth): 平台 token 先读 FPSMASTER_LAUNCH_TOKEN,读到后清掉 -D 系统属性
gaoyu06 Oct 16, 2026
84842a6
perf(telemetry): 先判断再构造快照,失败指数退避,退出最多等 1.5 秒
gaoyu06 Oct 16, 2026
460d0f7
perf(chat): 聊天头像的发送者按行对象缓存,玩家查找走名字索引
gaoyu06 Oct 16, 2026
0320e12
fix(blur): 面板模糊用缓存的 sampler,最后一个 Prism 界面关闭后释放离屏目标
gaoyu06 Oct 16, 2026
27ff628
fix(settings): 不再展示无效的 WebView 缩放和硬件加速开关
gaoyu06 Oct 16, 2026
6100f2c
chore(smoke): TickEvent 派发与疾跑生效写入 -Dfpsmaster.smoke 证据
gaoyu06 Oct 16, 2026
4941cbb
fix(auth): 切换账号后丢弃上一个账号迟到的绑定结果
gaoyu06 Oct 16, 2026
3f56acf
fix(replay): 退出时也等待先前异步停止、仍在写盘的录像
gaoyu06 Oct 16, 2026
6e5324f
ci: pin Prism 0.2.2 and Cadence v0.1.2 to their merged commits
gaoyu06 Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/ci-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,18 @@ jobs:
uses: actions/checkout@v4
with:
repository: FPSMasterTeam/fpsmaster-prism
ref: 1a3b61b2d75b9be6bed583ad08b52fb0016e8b83
# Prism 0.2.2(fix/prism-review 已合入 main 的提交)。
ref: e3b6e244f912828bd11d777683c47efd94c79121
path: fpsmaster-prism

- name: Check out Cadence
uses: actions/checkout@v4
with:
repository: FPSMasterTeam/Cadence
# Cadence v0.1.2。
ref: 9e60bab7834a73ed4ceead49ecec2d07af708b80
path: cadence

- name: Set up JDK ${{ matrix.jdk }}
uses: actions/setup-java@v4
with:
Expand All @@ -157,6 +166,12 @@ jobs:
chmod +x ./gradlew
./gradlew publishToMavenLocal

- name: Install Cadence
working-directory: cadence
run: |
chmod +x ./gradlew
./gradlew publishToMavenLocal

- name: Build with Gradle
shell: bash
env:
Expand Down
11 changes: 6 additions & 5 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -303,11 +303,12 @@ dependencies {
}

implementation("com.google.code.gson:gson:2.14.0")
// Cadence:网易云/QQ 音乐数据客户端(原 top.fpsmaster.web.music 抽出,JitPack 托管)。
// Cadence:网易云/QQ 音乐数据客户端(原 top.fpsmaster.web.music 抽出)。0.1.2 还没有 tag/JitPack 版本,
// 先从 mavenLocal 取(在 music-api 仓库 ./gradlew publishToMavenLocal)。
// 纯 JDK + gson 实现,不含 net.minecraft,故无需 Loom remap。
implementation("com.github.FPSMasterTeam:Cadence:v0.1.1")
implementation("top.fpsmaster:music-api:0.1.2")
implementation("com.googlecode.soundlibs:mp3spi:1.9.5.4")
implementation("top.fpsmaster:prism:0.2.1")
implementation("top.fpsmaster:prism:0.2.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.11.0")
compileOnly("org.projectlombok:lombok:1.18.46")
Expand All @@ -322,9 +323,9 @@ dependencies {
bundledRuntime("io.github.vlouboos:standaloneevent-common:1.6")
// 只打 Cadence 自己的类:它的两个传递依赖 kotlin-stdlib(上面已 bundle)与 gson(MC 自带)
// 都已在运行时就位,transitive 打进来只会重复/覆盖。
bundledRuntime("com.github.FPSMasterTeam:Cadence:v0.1.1") { isTransitive = false }
bundledRuntime("top.fpsmaster:music-api:0.1.2") { isTransitive = false }
bundledRuntime("com.googlecode.soundlibs:mp3spi:1.9.5.4")
bundledRuntime("top.fpsmaster:prism:0.2.1")
bundledRuntime("top.fpsmaster:prism:0.2.2")
testImplementation(kotlin("test"))
}

Expand Down
13 changes: 9 additions & 4 deletions src/main/java/top/fpsmaster/mixin/impl/MixinMinecraft.java
Original file line number Diff line number Diff line change
Expand Up @@ -81,18 +81,23 @@ public abstract class MixinMinecraft implements IMinecraftSession {
//?}
}

// Two distinct cadences: Client.tick runs once per frame (runTick HEAD, also used to drain deferred
// screen switches), TickEvent fires once per 20 Hz game tick. Minecraft.tick()V has the same
// signature on every Stonecutter target, so the game-tick hook must not be version-gated: modules
// like Sprint/ToggleSneak/Optimization/CosmeticLoadoutCache only ever see TickEvent.
@Inject(method = "runTick", at = @At("HEAD"))
private void fpsmaster$tick(boolean renderLevel, CallbackInfo callback) {
private void fpsmaster$frame(boolean renderLevel, CallbackInfo callback) {
Client.tick();
}

//? if >=1.21.5 {
@Inject(method = "tick", at = @At("HEAD"))
private void fpsmaster$tick(CallbackInfo callback) {
private void fpsmaster$gameTick(CallbackInfo callback) {
if (top.fpsmaster.diagnostics.Smoke.ENABLED) {
top.fpsmaster.diagnostics.Smoke.feature("tick-event");
}
StandaloneEventAPI.getApi().call(new TickEvent());
}

//?}
@Inject(method = "startAttack", at = @At("HEAD"))
private void fpsmaster$startAttack(CallbackInfoReturnable<Boolean> cir) {
StandaloneEventAPI.getApi().call(new MouseEvent(0));
Expand Down
38 changes: 38 additions & 0 deletions src/main/java/top/fpsmaster/mixin/impl/MixinOnlineServerEntry.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package top.fpsmaster.mixin.impl;

import net.minecraft.client.multiplayer.ServerData;
import net.minecraft.client.multiplayer.ServerList;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import top.fpsmaster.multiplayer.ServerBrowser;

import java.util.ArrayList;
import java.util.List;

/**
* Vanilla's move arrows / Shift+Up/Down compute positions with {@code children().indexOf(this)},
* i.e. in display order, and hand them straight to {@code ServerList.swap}. Once
* {@link MixinServerSelectionList} inserts promoted rows and lifts pinned ones, display positions
* no longer match servers.dat: moving the last row up threw IndexOutOfBoundsException, other moves
* reordered the wrong servers. Translate both positions back to servers.dat and skip any swap that
* involves a promoted row, which is not in servers.dat at all.
*/
@Mixin(targets = "net.minecraft.client.gui.screens.multiplayer.ServerSelectionList$OnlineServerEntry")
public class MixinOnlineServerEntry {
@Redirect(
method = "swap(II)V",
at = @At(value = "INVOKE", target = "Lnet/minecraft/client/multiplayer/ServerList;swap(II)V")
)
private void fpsmaster$swapStoredPositions(ServerList list, int displayFrom, int displayTo) {
List<ServerData> stored = new ArrayList<>(list.size());
for (int i = 0; i < list.size(); i++) {
stored.add(list.get(i));
}
int from = ServerBrowser.INSTANCE.storedIndex(stored, displayFrom);
int to = ServerBrowser.INSTANCE.storedIndex(stored, displayTo);
if (from >= 0 && to >= 0) {
list.swap(from, to);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
* Drives the parts of a replay that belong on the client thread: sampling the recording player's own
* position and equipment, which no clientbound packet carries, and advancing playback.
*
* Hooked on {@code Minecraft.tick} rather than through the event bus because the bus only dispatches
* a client tick from 1.21.5 onwards, and a recording has to sample on every version.
* Hooked on {@code Minecraft.tick} directly rather than through the event bus so that a failing
* recording is isolated here and never reaches the other TickEvent subscribers.
*/
@Mixin(Minecraft.class)
public class MixinReplayClientTick {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public class MixinReplayPacketCapture {
top.fpsmaster.diagnostics.Smoke.mixin("replay-packet");
top.fpsmaster.diagnostics.Smoke.feature("replay");
}
Replay.onClientboundPacket(packet);
Replay.onClientboundPacket(packet, ((Connection) (Object) this).getPacketListener());
} catch (Throwable failure) {
// A recording is never worth dropping a connection over.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public class MixinServerSelectionList {
userServers,
data -> data.ip,
MixinServerSelectionList::fpsmaster$promotedServerData);
ServerBrowser.INSTANCE.rememberDisplayed(fpsmaster$arranged);
}

@Redirect(
Expand Down
89 changes: 50 additions & 39 deletions src/main/java/top/fpsmaster/musicui/AudioEngine.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.concurrent.atomic.AtomicInteger;

/**
* 纯 Java 音频播放引擎,基于 {@code javax.sound.sampled} + mp3spi(JLayer)。
Expand All @@ -26,6 +27,11 @@
*
* <p>解码与播放在后台线程进行,绝不阻塞渲染线程;曲目自然播完时通过
* {@link Minecraft#execute(Runnable)} 在主线程回调 {@code onEnded}。
*
* <p>每次 play/stop 都推进 {@link #generation},播放线程只认自己那一代:interrupt 打断不了
* HttpURLConnection 的阻塞读和 SourceDataLine.write,旧线程可能在新曲目开始后才醒来,
* 若还看共享的 stopped 标志(已被新曲目置回 false)就会和新曲目叠着放、互相改进度。
* 所以 stop 不再在调用方线程 join 旧线程,旧线程醒来发现过期就自己收尾退出。
*/
public class AudioEngine {

Expand All @@ -36,6 +42,7 @@ public class AudioEngine {
+ "Chrome/123.0.0.0 Safari/537.36";

private volatile Thread playThread;
private final AtomicInteger generation = new AtomicInteger();
private volatile boolean stopped = true;
private volatile boolean paused = false;

Expand Down Expand Up @@ -82,24 +89,20 @@ public float getVolume() {
* @param onEnded 曲目自然播完时在主线程回调;可为 null。
*/
public void play(final String url, final String referer, long knownDurationMs, final Runnable onEnded) {
stop();
synchronized (lock) {
stopped = false;
paused = false;
positionMs = 0;
durationMs = knownDurationMs;
pendingSeekMs = -1;
}
final int gen = begin(knownDurationMs);
final Thread t = new Thread(new Runnable() {
@Override
public void run() {
File temp = null;
try {
temp = downloadToTemp(url, referer);
temp = downloadToTemp(url, referer, gen);
if (!isCurrent(gen)) {
return;
}
synchronized (lock) {
currentTemp = temp;
}
playLoop(temp, onEnded);
playLoop(temp, onEnded, gen);
} catch (Throwable e) {
LOGGER.error("Music playback failed: " + e.getMessage());
} finally {
Expand All @@ -118,19 +121,12 @@ public void run() {
}

public void playFile(final File file, long knownDurationMs, final Runnable onEnded) {
stop();
synchronized (lock) {
stopped = false;
paused = false;
positionMs = 0;
durationMs = knownDurationMs;
pendingSeekMs = -1;
}
final int gen = begin(knownDurationMs);
final Thread t = new Thread(new Runnable() {
@Override
public void run() {
try {
playLoop(file, onEnded);
playLoop(file, onEnded, gen);
} catch (Throwable e) {
LOGGER.error("Local music playback failed: " + e.getMessage());
}
Expand All @@ -143,19 +139,36 @@ public void run() {
t.start();
}

private void playLoop(File file, Runnable onEnded) throws Exception {
/** Stops whatever is playing and starts a new session; returns its generation. */
private int begin(long knownDurationMs) {
stop();
synchronized (lock) {
stopped = false;
paused = false;
positionMs = 0;
durationMs = knownDurationMs;
pendingSeekMs = -1;
return generation.get();
}
}

private boolean isCurrent(int gen) {
return !stopped && generation.get() == gen;
}

private void playLoop(File file, Runnable onEnded, int gen) throws Exception {
long startMs = 0;
boolean endedNaturally = false;

while (!stopped) {
while (isCurrent(gen)) {
AudioInputStream fileIn = null;
AudioInputStream din = null;
SourceDataLine line = null;
boolean seekBreak = false;
try {
fileIn = AudioSystem.getAudioInputStream(new BufferedInputStream(new FileInputStream(file)));
AudioFormat base = fileIn.getFormat();
if (durationMs <= 0 && fileIn.getFrameLength() > 0 && base.getFrameRate() > 0) {
if (isCurrent(gen) && durationMs <= 0 && fileIn.getFrameLength() > 0 && base.getFrameRate() > 0) {
durationMs = (long) (fileIn.getFrameLength() * 1000.0 / base.getFrameRate());
}
AudioFormat decoded = new AudioFormat(
Expand All @@ -182,14 +195,14 @@ private void playLoop(File file, Runnable onEnded) throws Exception {
// not others, and why seeking again "fixed" it.
long skipBytes = (long) (startMs * bytesPerMs);
skipBytes -= skipBytes % frameSize;
skipDecoded(din, skipBytes);
skipDecoded(din, skipBytes, gen);
}
long posBytes = 0;
byte[] buf = new byte[4096];
// Bytes left over from the previous read that do not yet complete a frame.
int carry = 0;

while (!stopped) {
while (isCurrent(gen)) {
long seek = pendingSeekMs;
if (seek >= 0) {
pendingSeekMs = -1;
Expand Down Expand Up @@ -220,7 +233,9 @@ private void playLoop(File file, Runnable onEnded) throws Exception {
if (writable > 0) {
line.write(buf, 0, writable);
posBytes += writable;
positionMs = startMs + (long) (posBytes / bytesPerMs);
if (isCurrent(gen)) {
positionMs = startMs + (long) (posBytes / bytesPerMs);
}
}
carry = available - writable;
if (carry > 0) {
Expand All @@ -231,7 +246,7 @@ private void playLoop(File file, Runnable onEnded) throws Exception {
closeQuietly(line, din, fileIn);
}

if (stopped) {
if (!isCurrent(gen)) {
return;
}
if (seekBreak) {
Expand All @@ -242,7 +257,7 @@ private void playLoop(File file, Runnable onEnded) throws Exception {
}
}

if (endedNaturally && !stopped && onEnded != null) {
if (endedNaturally && isCurrent(gen) && onEnded != null) {
stopped = true;
Minecraft mc = Minecraft.getInstance();
if (mc != null) {
Expand All @@ -252,10 +267,10 @@ private void playLoop(File file, Runnable onEnded) throws Exception {
}

/** 逐块读丢弃到目标字节数(mp3spi 的 skip 不可靠,故用读丢弃)。 */
private void skipDecoded(AudioInputStream din, long bytes) throws Exception {
private void skipDecoded(AudioInputStream din, long bytes, int gen) throws Exception {
byte[] junk = new byte[4096];
long remaining = bytes;
while (remaining > 0 && !stopped) {
while (remaining > 0 && isCurrent(gen)) {
int toRead = (int) Math.min(junk.length, remaining);
int n = din.read(junk, 0, toRead);
if (n < 0) break;
Expand Down Expand Up @@ -326,28 +341,24 @@ public void seek(long ms) {
paused = false;
}

/** 停止并释放当前曲目。 */
/** 停止当前曲目。不等待旧线程:它醒来发现代际已过期会自己关掉 line 和临时文件。 */
public void stop() {
Thread t;
synchronized (lock) {
stopped = true;
paused = false;
generation.incrementAndGet();
t = playThread;
playThread = null;
positionMs = 0;
gainControl = null;
}
if (t != null) {
t.interrupt();
try {
t.join(500);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
positionMs = 0;
gainControl = null;
}

private File downloadToTemp(String url, String referer) throws Exception {
private File downloadToTemp(String url, String referer, int gen) throws Exception {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setInstanceFollowRedirects(true);
conn.setConnectTimeout(10_000);
Expand All @@ -366,7 +377,7 @@ private File downloadToTemp(String url, String referer) throws Exception {
byte[] buf = new byte[16384];
int n;
while ((n = in.read(buf)) != -1) {
if (stopped) break;
if (!isCurrent(gen)) break;
out.write(buf, 0, n);
}
} finally {
Expand Down
11 changes: 4 additions & 7 deletions src/main/java/top/fpsmaster/ui/kit/NovaBlurTexture.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,10 @@ public final class NovaBlurTexture extends AbstractTexture {
public void attach(RenderTarget target) {
this.texture = target.getColorTexture();
this.textureView = target.getColorTextureView();
this.sampler = com.mojang.blaze3d.systems.RenderSystem.getDevice().createSampler(
com.mojang.blaze3d.textures.AddressMode.CLAMP_TO_EDGE,
com.mojang.blaze3d.textures.AddressMode.CLAMP_TO_EDGE,
com.mojang.blaze3d.textures.FilterMode.LINEAR,
com.mojang.blaze3d.textures.FilterMode.LINEAR,
0,
java.util.OptionalDouble.empty());
// The shared cached sampler: attach() runs on every framebuffer resize (every frame while
// dragging a window edge), and a sampler created here was never closed.
this.sampler = com.mojang.blaze3d.systems.RenderSystem.getSamplerCache()
.getClampToEdge(com.mojang.blaze3d.textures.FilterMode.LINEAR);
}

@Override
Expand Down
Loading
Loading