Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
1b67ab8
chore(deps): 升级 Prism 0.2.2 / Cadence 0.1.2 并完成宿主适配
gaoyu06 Oct 16, 2026
ef9d85b
fix(api): 请求带 Accept-Language,失败响应保留后端错误码
gaoyu06 Oct 16, 2026
7ae6b0a
fix(cosmetics): 远端饰品解析改为负缓存+限频批量+指数退避
gaoyu06 Oct 16, 2026
210b6e3
feat(config): 配置原子写入、滚动备份与一键回滚,导入同名配置先问覆盖/另存为
gaoyu06 Oct 16, 2026
ada13de
fix(account): accounts.json 移出游戏目录并原子写入
gaoyu06 Oct 16, 2026
7c1b393
fix(auth): 启动 token 改读 FPSMASTER_LAUNCH_TOKEN,日志统一脱敏
gaoyu06 Oct 16, 2026
04cffa1
fix(music): 播放会话隔离、列表四态与错误提示、封面失败负缓存
gaoyu06 Oct 16, 2026
2f3a6b2
fix(lifecycle): 关闭时先存配置、不再被网络卡住;初始化步骤互不连带
gaoyu06 Oct 16, 2026
de0c552
fix(i18n): 逗号小数点地区 Reach 显示失效;i18n key 与系统判断改用 Locale.ROOT
gaoyu06 Oct 16, 2026
eedc14c
fix(minimap): 颜色缓存按 IBlockState 取键并发安全,纹理缓冲加锁
gaoyu06 Oct 16, 2026
5e43f70
perf(blur): Kawase 模糊链按级降采样
gaoyu06 Oct 16, 2026
e124158
fix(macos): 缓解 GL-on-Metal resize 崩溃与运行期 AWT 初始化
gaoyu06 Oct 16, 2026
ae3af90
fix(music): 去掉与 Prism 0.2.2 账号标签重叠的「打开文件夹」按钮
gaoyu06 Oct 16, 2026
7db0634
fix(account): 迁移旧 accounts.json 时合并进已有 edge_accounts.json,写入成功后才删除
gaoyu06 Oct 16, 2026
8c0bae5
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
20 changes: 19 additions & 1 deletion .github/workflows/ci-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ 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: Install Prism UI
Expand All @@ -162,6 +163,23 @@ jobs:
chmod +x ./gradlew
./gradlew publishToMavenLocal

# Cadence (top.fpsmaster:music-api) 0.1.2 没有发布到 JitPack,和 Prism 一样从源码装进 mavenLocal。
- name: Check out Cadence
if: steps.decision.outputs.should_build == 'true'
uses: actions/checkout@v4
with:
repository: FPSMasterTeam/Cadence
# Cadence v0.1.2。
ref: 9e60bab7834a73ed4ceead49ecec2d07af708b80
path: cadence

- name: Install Cadence
if: steps.decision.outputs.should_build == 'true'
working-directory: cadence
run: |
chmod +x ./gradlew
./gradlew publishToMavenLocal

- name: Inject SMTC native libraries
if: steps.decision.outputs.should_build == 'true'
uses: actions/download-artifact@v4
Expand Down
7 changes: 3 additions & 4 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,12 @@ dependencies {
isTransitive = false
}

shadowImpl("top.fpsmaster:prism:0.2.1")
shadowImpl("top.fpsmaster:prism:0.2.2")

// 音乐能力:Cadence 数据客户端(网易云/QQ 搜索/直链/歌词/歌单/登录)。
// 由 JitPack 托管 (FPSMasterTeam/Cadence),坐标即 com.github.<owner>:<repo>:<tag>;
// 源码里的包名仍是 top.fpsmaster.music.*。
// 0.1.2 还没有发到 JitPack(没有 tag),先从 mavenLocal 取(Cadence 仓库 publishToMavenLocal)。
// gson 由 MC classpath 提供,故不传递依赖;Kotlin 运行时单独引入。
shadowImpl("com.github.FPSMasterTeam:Cadence:v0.1.1") {
shadowImpl("top.fpsmaster:music-api:0.1.2") {
isTransitive = false
}
shadowImpl("org.jetbrains.kotlin:kotlin-stdlib:2.4.0") {
Expand Down
80 changes: 51 additions & 29 deletions src/main/java/top/fpsmaster/FPSMaster.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,27 @@ public class FPSMaster {
public static Language i18n = new Language();
public static ClientThreadPool async = new ClientThreadPool(Math.max(2, Runtime.getRuntime().availableProcessors() / 2));
public static EdgeTelemetryReporter telemetryReporter = new EdgeTelemetryReporter();
public static boolean development = false;
/**
* Probed once: the main menu asks every frame, and in a release build each probe threw and caught
* a ClassNotFoundException (hundreds of stack-trace allocations a second).
*/
public static final boolean development = detectDevelopment();
public static boolean defaultConfigExistedBeforeLoad = false;

private static void checkDevelopment() {
private static boolean detectDevelopment() {
try {
Class.forName("net.fabricmc.devlaunchinjector.Main");
development = true;
} catch (Throwable ignored) {
return true;
} catch (ClassNotFoundException | LinkageError notDev) {
return false;
}
}

public static boolean isDevelopment() {
checkDevelopment();
return development;
}

public static String getClientTitle() {
checkDevelopment();
return CLIENT_NAME + " " + EDITION + " (" + GitInfo.getBranch() + " - " + GitInfo.getCommitIdAbbrev() + ")" + (development ? " - dev" : "");
}

Expand Down Expand Up @@ -172,46 +175,65 @@ public void initialize() {
initializeFonts();
initializeModules();
initializeComponents();
initializeConfigures();
CosmeticManager.getInstance().initialize();
initializeCommands();
initializeLang();
checkOptifine();
} catch (Exception e) {
// Everything after this needs the data directory and the module list.
ExceptionHandler.handle(e);
return;
}
// Independent steps, each in its own try: one bad config entry used to throw out of the
// shared try and skip cosmetics, commands and i18n for the whole session.
initStep("config", this::initializeConfigures);
initStep("cosmetics", () -> CosmeticManager.getInstance().initialize());
initStep("commands", this::initializeCommands);
initStep("i18n", this::initializeLang);
checkOptifine();
}

private interface InitStep {
void run() throws Exception;
}

private static void initStep(String name, InitStep step) {
try {
step.run();
} catch (Exception e) {
ExceptionHandler.handle(e, "Initialization step '" + name + "' failed");
}
}


public void shutdown() {
// Before anything else: an unfinished stream is an unreadable file.
top.fpsmaster.replay.ReplayRecorder.instance().stop();
// Then the config: everything below may block on the network, and a player who gives up
// waiting and kills the process must not lose this session's settings.
try {
ClientLogger.info("Saving configs");
configManager.saveConfig(ConfigProfileUtils.getActiveProfileName());
} catch (FileException e) {
ExceptionHandler.handleFileException(e, "Failed to save default config during shutdown");
}
PlayTimeStatistics.flush();
// A debounced scale change is still pending if the game is closed straight after dragging
// the slider; give it a bounded moment rather than losing it.
top.fpsmaster.cosmetic.RemoteCosmeticService.getInstance().flushBlocking(1500L);
// Release SMTC native session before tearing down async/audio
try {
top.fpsmaster.modules.music.MusicManager.get().shutdownSmtc();
} catch (Throwable t) {
ClientLogger.warn("SMTC shutdown skipped: " + t.getMessage());
}
// The playback thread holds a javax.sound line and a temp file, and stop() joins it. It has
// to happen before async.close(), which is where the rest of the client's workers go.
try {
top.fpsmaster.modules.music.MusicManager.get().engine().stop();
} catch (Throwable t) {
ClientLogger.warn("Audio engine stop skipped: " + t.getMessage());
// Music was never opened: nothing to stop, and get() would create the SMTC session and
// load credentials just to tear them down again.
if (top.fpsmaster.modules.music.MusicManager.isInitialized()) {
try {
top.fpsmaster.modules.music.MusicManager.get().shutdownSmtc();
} catch (Throwable t) {
ClientLogger.warn("SMTC shutdown skipped: " + t.getMessage());
}
try {
top.fpsmaster.modules.music.MusicManager.get().engine().stop();
} catch (Throwable t) {
ClientLogger.warn("Audio engine stop skipped: " + t.getMessage());
}
}
releaseGraphics();
telemetryReporter.shutdown();
async.close();
try {
ClientLogger.info("Saving configs");
configManager.saveConfig(ConfigProfileUtils.getActiveProfileName());
} catch (FileException e) {
ExceptionHandler.handleFileException(e, "Failed to save default config during shutdown");
}
}

/**
Expand Down
24 changes: 21 additions & 3 deletions src/main/java/top/fpsmaster/cosmetic/CosmeticManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ private static boolean isRenderableCategory(String category) {
private final ElytraRenderer elytraRenderer = new ElytraRenderer();
private final Map<String, ResourceLocation> textures = new HashMap<>();
private final Set<String> loading = new HashSet<>();
/**
* Option id -> when its texture last failed to load. The renderers ask for a texture every frame,
* so without this a 404 or a malformed atlas was downloaded again on the next frame, forever.
* Guarded by {@link #textures}.
*/
private final Map<String, Long> failedAt = new HashMap<>();
private static final long TEXTURE_RETRY_MS = 5L * 60_000L;
/** Other players' items, resolved on demand. Deliberately outside {@link #allOptions()}: someone
* else's cosmetic is something to draw, never something in this account's wardrobe. */
private final Map<String, CosmeticOption> remoteOptions = new ConcurrentHashMap<>();
Expand Down Expand Up @@ -108,7 +115,11 @@ public void reloadCustom() {
// The reloaded file may hold a different image under the same id, so the entry has to go
// — and with it the GL texture, which dropping the map entry alone would strand.
synchronized (textures) {
for (CosmeticOption option : customOptions) deleteTexture(textures.remove(option.id));
for (CosmeticOption option : customOptions) {
deleteTexture(textures.remove(option.id));
// The user may just have fixed the file: give it a fresh attempt.
failedAt.remove(option.id);
}
}
customOptions = Collections.unmodifiableList(options);
validateSelections();
Expand Down Expand Up @@ -413,6 +424,7 @@ public void releaseTextures() {
for (ResourceLocation location : textures.values()) deleteTexture(location);
textures.clear();
loading.clear();
failedAt.clear();
}
}

Expand All @@ -427,7 +439,10 @@ private static void deleteTexture(ResourceLocation location) {
private void loadTexture(CosmeticOption option) {
if (option == null || option.assetKey == null) return;
synchronized (textures) {
if (textures.containsKey(option.id) || !loading.add(option.id)) return;
if (textures.containsKey(option.id)) return;
Long failed = failedAt.get(option.id);
if (failed != null && System.currentTimeMillis() - failed < TEXTURE_RETRY_MS) return;
if (!loading.add(option.id)) return;
}
FPSMaster.async.runnable(() -> {
try {
Expand All @@ -443,6 +458,7 @@ private void loadTexture(CosmeticOption option) {
synchronized (textures) {
textures.put(option.id, location);
loading.remove(option.id);
failedAt.remove(option.id);
}
} catch (Exception exception) {
fail(option, exception);
Expand Down Expand Up @@ -502,8 +518,10 @@ private void validateDimensions(BufferedImage image, String category) {
private void fail(CosmeticOption option, Exception exception) {
synchronized (textures) {
loading.remove(option.id);
failedAt.put(option.id, System.currentTimeMillis());
}
ClientLogger.warn("Failed to load cosmetic " + option.id + ": " + exception.getMessage());
ClientLogger.warn("Failed to load cosmetic " + option.id + " (next attempt in "
+ (TEXTURE_RETRY_MS / 60_000L) + " min): " + exception.getMessage());
}

private CosmeticOption option(CosmeticItem item, String category) {
Expand Down
Loading
Loading