2 Commits
main ... main

Author SHA1 Message Date
ccb921f4bd perf: 优化实例启动性能并修复过渡动画对齐
依赖校验戳缓存(18.6s→0.1s)、并发化库/资产校验、启动计时埋点、过渡动画200ms、窗口客户区精确对齐、遮罩即时显示、debug构建写文件日志。
2026-09-16 13:10:37 +08:00
776f621436 feat: add Minecraft launch transition animation (cover/logo phases) 2026-09-15 22:33:18 +08:00
12 changed files with 1319 additions and 502 deletions

131
README.md
View File

@ -1,75 +1,98 @@
# Starlight Launcher — 实例启动性能优化记录
<div align="center">
<img src="./apps/app/icons/128x128.png" width="128" height="128" alt="Starlight Launcher Logo" />
<h1>Starlight Launcher</h1>
<p><strong>次世代 Minecraft 桌面客户端,全能、美观、全平台覆盖。</strong></p>
> 内部文档,非上线说明。本分支聚焦「实例启动慢」问题的定位与修复。
<p>
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/actions">
<img src="https://img.shields.io/badge/Repo-git.starlight.cool-blue?style=for-the-badge&logo=git" alt="Repository" />
</a>
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/releases">
<img src="https://img.shields.io/badge/Releases-Download-green?style=for-the-badge&logo=github" alt="Releases" />
</a>
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/stargazers">
<img src="https://img.shields.io/badge/Stars-★-ffb800?style=for-the-badge&logo=github" alt="Stars" />
</a>
<a href="COPYING.md">
<img src="https://img.shields.io/badge/License-GPL_3.0-blue.svg?style=for-the-badge" alt="License" />
</a>
</p>
## 背景
<p>
<a href="https://skin.starlight.cool/">官方网站</a>
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/releases/latest">下载最新版</a>
<a href="CONTRIBUTING.md">参与贡献</a>
<a href="CODE_OF_CONDUCT.md">行为准则</a>
</p>
</div>
用户反馈:点击实例启动后,要等很久才看到游戏窗口。
<details open>
<summary><strong>关于 The Land of StarLight (TLSL)</strong></summary>
经实测定位,问题由多个独立环节叠加造成,本分支逐项修复。
**The Land Of StarLight**(中文全称**星光领域**,英文简称 **TLSL**)成立于 2014 年,是一个主营 Minecraft 相关内容的综合性平台。平台主要负责人为 **Disy**(因名称常被抢注,在多数平台以 **Disy920** 出现)。
## 改动清单
TLSL 的前身可追溯至 2012 年初建立的凋灵服务器TWS。经过多次重组与模式调整于 2014 年 10 月定名为**星光服务器StarLight-Server简称 SLS**。2020 年初随着运营模式改变与业务扩展TLSL 正式成立SLS 成为其主营子项目。
### 1. 依赖校验戳缓存(`packages/app-lib/src/launcher/direct_ensure.rs`
如今 TLSL 旗下拥有 Minecraft 服务器 **StarLight-ServerSLS**、面向 Fabric 端的领地模组 **Enclosure** 等项目,致力于打造一个完善的 Minecraft 交流与开发平台
**问题**PCL/HMCL 直连实例每次启动都要对全部库文件与资产对象重算 SHA1
实测某整合包118 个库 + 3911 个资产对象,合计约 670 MB机械硬盘顺序读仅
27.8 MB/s串行校验耗时约 **17.7 秒**,占整个启动准备阶段的 97%。
**Starlight Launcher** 即是面向 **SLS星光服务器** 打造的 Minecraft 桌面启动器。
**方案**新增持久化「已验证」戳缓存key 为文件绝对路径value 为
`(size, mtime)``file_is_current` 先做 stat命中戳则跳过 SHA1 读取;
文件被替换或修改size/mtime 变化)时自动回退到完整 SHA1 校验,**不弱化
损坏检测**。
</details>
- 缓存文件:`<caches>/linked-verify-stamps.json`(存 Axolotl 自己的缓存目录,
不污染直连安装目录)
- 首次启动建立缓存仍走完整校验;后续启动(含跨进程重启)直接命中
---
**效果**`asset_scan` 15788ms → 62ms`ensure_deps` 整体 18.6s → 0.1s。
**Starlight Launcher星光启动器** 是一款免费、开源、跨平台的 Minecraft Java 版第三方启动器,支持在一个客户端中搜索、安装和更新来自 Modrinth 与 CurseForge 的模组、整合包、资源包和光影,并提供实例管理、多种账户认证与个性化外观。
### 2. 库/资产校验并发化(`packages/app-lib/src/launcher/direct_ensure.rs`
本项目基于 [Modrinth App](https://github.com/modrinth/code) 构建,并在其基础上由 [Axolotl 启动器](https://github.com/Mystic-Stars/Axolotl) 修改而来,移除了不适用于本项目的商业化模块,专注于提供纯净、无广告的桌面启动体验。
在戳缓存基础上,把库扫描与资产扫描从串行 `for` 循环改为
`try_for_each_concurrent`(并发度与下载器一致,`task_concurrency_limit * 2`)。
首次建缓存时也能吃到并发收益。
_(注:本项目是调用 Modrinth 公开 API 的独立客户端,与 Rinth, Inc. 无任何关联。)_
> 并发对机械硬盘的随机小文件读取提升有限IOPS 瓶颈),真正的
> 数量级提升来自上面的戳缓存。
## 核心优势
### 3. 启动阶段计时埋点(`packages/app-lib/src/api/instance/run.rs`、`packages/app-lib/src/launcher/mod.rs`
- **真跨平台体验**:告别繁琐的环境配置,原生支持 Windows、macOS完美兼容 Intel 与 Apple Silicon及各类主流 Linux 发行版。
- **现代化内容生态**:集成 Modrinth 和 CurseForge可在启动器中一键浏览。游戏实例、整合包、模组、资源包及光影均可一键安装与升级彻底告别手动管理依赖的痛苦。
- **高度定制化**:无论是主题色调、背景图片,还是离线皮肤,核心功能与视觉展现均由你自由支配。
- **All in one 全新体验**:启动器内置 “实验室” 功能,囊括种子地图、投影工坊等海量使用工具,带来全新原生轮椅体验。
在启动链路插入 `[launch-timing]` 前缀的计时日志,覆盖:
`hosted_prepare_launch` / `hosted_java_arguments` / `resolve_version_info` /
`resolve_java` / `resolve_gc` / `assemble_client` / `ensure_deps`(细分
`dep_lib_scan` / `asset_scan` / `dep_assets` / `dep_log_config`/
`remove_old_natives` / `extract_linked_natives` / `process_spawn`
## 下载与安装
用于定位瓶颈grep `[launch-timing]` 即可。
请前往 [Releases](https://git.starlight.cool/AxTps/Starlight_Lancher/releases/latest) 下载适合你操作系统的最新安装包。
已安装的用户每次均可通过内置的 Tauri 签名校验机制,自动在后台完成更新,无需手动下载安装更新。
### 4. 启动过渡动画优化(`apps/app/src/mc_transition.rs`
| 系统平台 | 推荐下载文件 |
| ----------------------- | ----------------------------------------- |
| **Windows** (10/11 x64) | 下载 `.exe` (NSIS) 安装程序 |
| **macOS** | 下载 `通用 .dmg` 镜像文件 |
| **Linux** (x64) | 提供 `.AppImage``.deb``.rpm` 多种格式 |
**a. 动画时长**400ms → 200ms帧数 50 → 25。缓解卡顿。
## 参与项目开发
**b. 窗口对齐修复**:全屏放大与缩回游戏窗口时,左侧留缝、整体偏右。
根因是无边框窗口的不可见 resize border实测窗口比目标大 16×9px
可见内容从 (8, 4.5) 才开始)。改用 `set_client_rect`,通过
`GetWindowRect` / `GetClientRect` 计算边框并补偿,让**可见客户区**精确
落在目标矩形。
Starlight Launcher 的进步离不开社区的反馈与贡献。
如果遇到 Bug 或有新的功能点子,欢迎提交 Issue。如需搭建本地开发环境或查阅打包发布规范请阅读详细的 [贡献指南 (CONTRIBUTING.md)](CONTRIBUTING.md)
参与社区和贡献代码前,也请先阅读[行为准则 (CODE_OF_CONDUCT.md)](CODE_OF_CONDUCT.md)。
**c. 单次 SetWindowPos**:每帧从两次调用(`set_position` + `set_size`
改为单次 `SetWindowPos`,减少重绘
---
**d. 恢复可见**:动画前先 `show()` + `unminimize()`,避免轻量模式遗留
导致遮罩建了却不可见。
### 5. 遮罩即时显示(`apps/app/src/lightweight_mode.rs`
**问题**:点击启动后遮罩要等约 10 秒才出现。
**根因**:收到 `launched` 事件后,代码先 `await maximize_minecraft_window`
(最多轮询 5 秒等游戏窗口),跑完才启动遮罩动画。
**方案**:把最大化游戏窗口丢到独立任务,遮罩动画立即启动,两者并行。
### 6. debug 构建写文件日志(`packages/app-lib/src/logger.rs`
**问题**debug 构建的 `start_logger` 只输出到控制台、不落盘GUI 进程
关闭后无法回溯日志,导致性能计时无法采集。
**方案**debug 版 logger 增加一层文件输出,写入与 release 相同的
`launcher_logs` 目录,文件名带 `session_debug_` 前缀。
## 结论与边界
- **启动器侧**:准备阶段已从 18.6s 优化到约 0.1s,拉起进程约 0.6s,已到极限。
- **游戏侧**:窗口出现前约 11s、完整加载约 45.8s230 mod 的 NeoForge 整合包),
属整合包固有成本,任何启动器无法缩短。
- 曾尝试开启 NeoForge 早期窗口(`fml.toml``earlyWindowControl`
该整合包下与 mod 冲突导致更慢11s → 30s**已回滚**。
## 构建
```bash
# debug含计时埋点、文件日志
cargo build -p theseus_gui
# release
cargo build --release -p theseus_gui
```

View File

@ -0,0 +1 @@
<svg height="388" width="2500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375.103901073386 57.671"><path d="M57.355.316H16.519C7.396.316 0 7.713 0 16.834V57.671h40.836c9.124 0 16.519-7.396 16.519-16.518V7.38z" fill="#db1f29"/><path d="M13.772 48.104c-4.892 0-4.892-4.892-4.892-4.892V18.746c0-4.893 4.892-4.893 4.892-4.893h9.786c4.894 0 9.786 4.893 9.786 4.893s4.894 0 0 0c0-14.251 4.501 5.285 9.786 0 3.46-3.459 4.893 17.125 4.893 17.125l-4.893-2.444s0-4.894-7.339-9.786c-9.104-6.071-19.572-2.071-19.572 7.338 0 19.196 31.804 12.233 31.804 12.233s0 4.892-4.893 4.892H13.772z" fill="#fff"/><path d="M38.238 13.854c0-1.35 1.095-7.34 2.445-7.34 1.352 0 2.446 5.99 2.446 7.34a2.445 2.445 0 1 1-4.891 0z" fill="#fff"/><path d="M105.714 56.675V21.379L93.342 39.77H87.85L75.479 21.379v35.296h-9.85V.996h7.899l17.109 25.186L107.747.996h7.745v55.679zM149.727 57.671c-7.744 0-14.943-3.021-20.271-8.507-5.375-5.545-8.333-12.763-8.333-20.325 0-7.57 2.959-14.788 8.333-20.325C134.783 3.101 142.169 0 149.727 0c7.636 0 14.858 3.02 20.337 8.502 5.384 5.549 8.343 12.767 8.343 20.336 0 7.562-2.958 14.781-8.332 20.325-5.412 5.489-12.636 8.508-20.348 8.508zm0-47.666c-4.919 0-9.567 1.979-13.088 5.574-3.56 3.484-5.512 8.188-5.512 13.26 0 5.069 1.952 9.77 5.496 13.237 3.531 3.61 8.179 5.59 13.104 5.59 4.947 0 9.774-2.031 13.242-5.573 3.506-3.51 5.433-8.215 5.433-13.254 0-5.042-1.927-9.75-5.426-13.252-3.48-3.55-8.307-5.582-13.249-5.582zM180.215 56.675v-9.396h1.062c16.254 0 19.52-5.682 19.52-14.848V10.236h-20.582V1.071h30.432v31.36c0 16.993-8.783 24.243-29.369 24.243h-1.063zM253.239 56.675l-5.128-13.543h-20.26l-5.204 13.543h-10.633L234.354.312h7.106l22.34 56.363zm-8.762-22.931l-6.491-17.066-6.491 17.066zM301.564 56.675l-24.237-35.068v35.068h-9.853V.996h7.588l24.467 35.376V.996h9.776v55.679zM343.234 57.671c-7.744 0-14.943-3.021-20.271-8.506-5.375-5.546-8.334-12.764-8.334-20.326 0-7.57 2.959-14.788 8.332-20.325C328.29 3.101 335.677 0 343.234 0c7.634 0 14.857 3.02 20.337 8.502a29.986 29.986 0 0 1 4.891 6.671l.842 1.566h-11.782l-.501-.563a12.058 12.058 0 0 0-.539-.591c-3.479-3.549-8.305-5.58-13.247-5.58-4.919 0-9.567 1.979-13.089 5.574-3.559 3.484-5.511 8.188-5.511 13.26 0 5.069 1.952 9.77 5.496 13.237 3.53 3.61 8.179 5.59 13.104 5.59 4.947 0 9.773-2.031 13.24-5.573a18.397 18.397 0 0 0 4.85-8.598h-20.991V23.49h31.095l.14.899c.229 1.469.347 2.965.347 4.45 0 7.562-2.959 14.78-8.331 20.325-5.415 5.488-12.639 8.507-20.351 8.507z"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -373,6 +373,12 @@ let allowWindowClose = false
let unlistenCloseRequested: (() => void) | undefined
let unlistenLightweightModeError: (() => void) | undefined
let unlistenSystemAccentColor: (() => void) | undefined
let unlistenMcTransitionFadeout: (() => void) | undefined
let unlistenMcTransitionFocus: (() => void) | undefined
let unlistenMcTransitionCover: (() => void) | undefined
let unlistenMcTransitionLogoIn: (() => void) | undefined
let unlistenMcTransitionLogoOut: (() => void) | undefined
let unlistenMcTransitionReset: (() => void) | undefined
let maximizedStateTimer: ReturnType<typeof setTimeout> | undefined
let unlistenWindowResize: (() => void) | undefined
const minecraftCrashModal = ref()
@ -491,6 +497,104 @@ onMounted(async () => {
checkUpdates()
void warnIfRunningElevated()
startDirectLinkSync()
unlistenMcTransitionFadeout = await listen('mc-transition-fadeout', async () => {
// 游戏窗口已就位、启动器已缩放到同尺寸。这里做 0.5s CSS 淡出,
// 完成后回调 Rust由后端执行聚焦游戏 + 最小化启动器。
const root = document.getElementById('app')
if (root) {
root.style.transition = 'opacity 500ms ease-out'
root.style.opacity = '0'
}
window.setTimeout(() => {
void invoke('mc_transition_fade_done').catch(() => {})
}, 510)
})
// 窗口恢复可见时,清掉残留的淡出透明度,避免再次打开时全透明。
const resetLauncherOpacity = () => {
const root = document.getElementById('app')
if (root) {
root.style.transition = 'none'
root.style.opacity = ''
void root.offsetHeight
}
}
window.addEventListener('focus', resetLauncherOpacity)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') resetLauncherOpacity()
})
unlistenMcTransitionFocus = await getCurrentWindow().onFocusChanged(({ payload: focused }) => {
if (focused) resetLauncherOpacity()
})
// ============ 启动过渡遮罩(三阶段) ============
// 阶段1纯色遮罩主题色无图—— 盖住放大抖动
// 阶段2全屏后渐显 SVG logo
// 阶段3缩小前背景变 Mojang 红 + 渐隐 logo
let mcCoverEl = null
let mcCoverImg = null
unlistenMcTransitionCover = await listen('mc-transition-cover-show', () => {
const app = document.getElementById('app')
if (!app) return
if (!mcCoverEl) {
mcCoverEl = document.createElement('div')
mcCoverEl.id = 'mc-transition-cover'
mcCoverEl.style.cssText = [
'position: fixed',
'inset: 0',
'z-index: 999999',
'display: flex',
'align-items: center',
'justify-content: center',
'background: var(--color-brand, #db1f29)',
'transition: background-color 500ms ease, opacity 300ms ease-out',
'opacity: 0',
'pointer-events: none',
].join(';')
mcCoverImg = document.createElement('img')
mcCoverImg.src = '/mojang-logo.svg'
mcCoverImg.alt = ''
mcCoverImg.style.cssText =
'width: 42%; max-width: 640px; height: auto; opacity: 0; transition: opacity 500ms ease-out;'
mcCoverEl.appendChild(mcCoverImg)
app.appendChild(mcCoverEl)
}
requestAnimationFrame(() => {
mcCoverEl.style.opacity = '1'
})
window.setTimeout(() => {
void invoke('mc_transition_cover_ready').catch(() => {})
}, 320)
})
unlistenMcTransitionLogoIn = await listen('mc-transition-logo-in', () => {
if (mcCoverImg) mcCoverImg.style.opacity = '1'
window.setTimeout(() => {
void invoke('mc_transition_logo_in_done').catch(() => {})
}, 520)
})
unlistenMcTransitionLogoOut = await listen('mc-transition-logo-out', () => {
if (mcCoverEl) mcCoverEl.style.backgroundColor = '#db1f29'
if (mcCoverImg) mcCoverImg.style.opacity = '0'
window.setTimeout(() => {
void invoke('mc_transition_logo_out_done').catch(() => {})
}, 520)
})
unlistenMcTransitionReset = await listen('mc-transition-reset-opacity', () => {
const app = document.getElementById('app')
if (app) {
app.style.transition = 'none'
app.style.opacity = ''
}
if (mcCoverEl) {
mcCoverEl.remove()
mcCoverEl = null
mcCoverImg = null
}
})
})
let directLinkSync: (() => Promise<void>) | undefined
@ -567,6 +671,12 @@ onUnmounted(async () => {
window.removeEventListener('keydown', handleGlobalKeydown, true)
unlistenCloseRequested?.()
unlistenLightweightModeError?.()
unlistenMcTransitionFadeout?.()
unlistenMcTransitionFocus?.()
unlistenMcTransitionCover?.()
unlistenMcTransitionLogoIn?.()
unlistenMcTransitionLogoOut?.()
unlistenMcTransitionReset?.()
unlistenSystemAccentColor?.()
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)

View File

@ -92,6 +92,7 @@ features = [
"Foundation",
"UI_ViewManagement",
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
"Win32_System_Com",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",

View File

@ -249,33 +249,32 @@ impl LightweightMode {
}
None if payload.event == "launched" => {
let app = app.clone();
// 最大化游戏窗口会轮询等待 MC 窗口出现(最多数秒)。它必须
// 与过渡动画并行,否则遮罩要等它跑完才出现,用户会看到一段
// 毫无反馈的空白期。
if payload.maximize_window {
let pid = payload.pid;
tauri::async_runtime::spawn(async move {
maximize_minecraft_window(pid).await;
});
}
tauri::async_runtime::spawn(async move {
if payload.maximize_window {
maximize_minecraft_window(payload.pid).await;
}
let settings = match theseus::settings::get().await {
Ok(settings) => settings,
Err(error) => {
tracing::warn!(
"Failed to read lightweight mode setting: {error}"
);
return;
}
};
if settings.enter_lightweight_mode_on_game_launch {
let state = app.state::<LightweightMode>();
if let Err(error) = state.enter(&app) {
tracing::warn!(
"Failed to enter lightweight mode: {error}"
);
}
} else if settings.hide_on_process_start
&& let Some(window) =
app.get_webview_window(MAIN_WINDOW_LABEL)
&& let Err(error) = window.minimize()
{
// 窗口过渡:把启动器全屏遮罩 → 缩放 → 淡出 → 最小化
crate::mc_transition::run(
&app,
payload.pid,
payload.maximize_window,
)
.await;
// 过渡动画结束后,统一进入轻量模式(隐藏到托盘)。
// 由过渡流程接管,不再各自判断用户的「轻量模式 /
// 启动后隐藏」设置,避免与动画收尾冲突,也顺带修掉
// 「重开时窗口全透明」的问题(轻量模式恢复时是重建
// 全新窗口,不残留旧的 opacity
let state = app.state::<LightweightMode>();
if let Err(error) = state.enter(&app) {
tracing::warn!(
"Failed to minimize launcher after Minecraft started: {error}"
"Failed to enter lightweight mode after transition: {error}"
);
}
});

View File

@ -22,6 +22,7 @@ use theseus::prelude::*;
mod api;
mod error;
mod lightweight_mode;
mod mc_transition;
mod mod_translation;
mod portable;
mod seed_map;
@ -867,6 +868,10 @@ fn main() {
lightweight_mode::lightweight_mode_frontend_ready,
lightweight_mode::lightweight_mode_set_route,
lightweight_mode::lightweight_mode_enter,
mc_transition::mc_transition_fade_done,
mc_transition::mc_transition_cover_ready,
mc_transition::mc_transition_logo_in_done,
mc_transition::mc_transition_logo_out_done,
]);
tracing::info!("Initializing app...");

View File

@ -0,0 +1,453 @@
//! Minecraft 启动时的窗口过渡动画(仅 Windows 生效)。
//!
//! 流程(四阶段):
//! 阶段A启动器放大到全屏0.2s 动画),全程置顶 + 聚焦;
//! 阶段B等待 MC 窗口出现(最多 60s期间启动器盖住游戏
//! 阶段C获取 MC 窗口位置/大小1s 动画缩放到相同大小;
//! 阶段D大小一致后0.5s 淡出(不与缩放同步);
//! 阶段E聚焦游戏窗口 → 启动器最小化。
//!
//! 过渡结束后返回,由调用方继续执行用户的轻量模式 / 隐藏设置。
//!
//! 注意HWND 是裸指针,非 Send不能跨 await 持有,因此一律用 `usize`
//! 保存窗口句柄,只在同步的 win32 调用里临时转回 HWND。
#![cfg_attr(not(target_os = "windows"), allow(dead_code, unused_imports))]
use tauri::{AppHandle, Emitter, Manager, WebviewWindow};
/// 主窗口 label与 lightweight_mode.rs 保持一致。
pub const MAIN_WINDOW_LABEL: &str = "main";
/// 等待 MC 窗口出现的轮询次数与间隔120 × 500ms = 60s
const FIND_RETRIES: u32 = 120;
const FIND_INTERVAL_MS: u64 = 500;
/// 缩放动画总时长与帧数200ms / 25 帧 = 8ms 一帧)。
const ANIM_DURATION_MS: u64 = 200;
const ANIM_STEPS: u32 = 25;
/// 等待前端完成 CSS 淡出的超时兜底(毫秒)。淡出 500ms留足余量。
const FADE_TIMEOUT_MS: u64 = 700;
#[cfg(target_os = "windows")]
static FADE_DONE: std::sync::Mutex<
Option<tokio::sync::oneshot::Sender<()>>,
> = std::sync::Mutex::new(None);
#[cfg(target_os = "windows")]
static COVER_DONE: std::sync::Mutex<
Option<tokio::sync::oneshot::Sender<()>>,
> = std::sync::Mutex::new(None);
#[cfg(target_os = "windows")]
static LOGO_IN_DONE: std::sync::Mutex<
Option<tokio::sync::oneshot::Sender<()>>,
> = std::sync::Mutex::new(None);
#[cfg(target_os = "windows")]
static LOGO_OUT_DONE: std::sync::Mutex<
Option<tokio::sync::oneshot::Sender<()>>,
> = std::sync::Mutex::new(None);
/// 前端完成淡出动画后的回调命令。
#[tauri::command]
pub fn mc_transition_fade_done() {
#[cfg(target_os = "windows")]
{
if let Ok(mut guard) = FADE_DONE.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
}
/// 前端覆盖层渐显完成后的回调命令。
#[tauri::command]
pub fn mc_transition_cover_ready() {
#[cfg(target_os = "windows")]
{
if let Ok(mut guard) = COVER_DONE.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
}
/// 前端 SVG logo 渐显完成后的回调命令。
#[tauri::command]
pub fn mc_transition_logo_in_done() {
#[cfg(target_os = "windows")]
{
if let Ok(mut guard) = LOGO_IN_DONE.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
}
/// 前端 SVG logo 渐隐 + 背景变红完成后的回调命令。
#[tauri::command]
pub fn mc_transition_logo_out_done() {
#[cfg(target_os = "windows")]
{
if let Ok(mut guard) = LOGO_OUT_DONE.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
}
/// 缓入缓出曲线,比纯 ease-out 更柔和自然。
#[cfg(target_os = "windows")]
fn ease_in_out(t: f64) -> f64 {
if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
}
}
/// 分帧移动 + 缩放启动器窗口。`from`/`to` 为 (x, y, w, h)。
/// 若起止相同则直接返回;结束时补一帧精确坐标,避免累积误差。
#[cfg(target_os = "windows")]
async fn animate_resize(
_window: &WebviewWindow,
hwnd: usize,
from: (i32, i32, u32, u32),
to: (i32, i32, u32, u32),
duration_ms: u64,
) {
if from == to {
return;
}
let step_ms = (duration_ms / ANIM_STEPS as u64).max(1);
for step in 1..=ANIM_STEPS {
let t = step as f64 / ANIM_STEPS as f64;
let e = ease_in_out(t);
let x = from.0 as f64 + (to.0 - from.0) as f64 * e;
let y = from.1 as f64 + (to.1 - from.1) as f64 * e;
let w = from.2 as f64 + (to.2 as i64 - from.2 as i64) as f64 * e;
let h = from.3 as f64 + (to.3 as i64 - from.3 as i64) as f64 * e;
// 单次 SetWindowPos移动+缩放一把过)比重绘两次的
// set_position/set_size 更顺滑,也避免无边框窗口的
// 不可见 resize border 造成的偏移。
win_impl::set_client_rect(
hwnd,
x as i32,
y as i32,
w.max(1.0) as i32,
h.max(1.0) as i32,
);
tokio::time::sleep(std::time::Duration::from_millis(step_ms)).await;
}
// 补最后一帧精确值(同样只设可见客户区,避免无边框边框造成偏移)
win_impl::set_client_rect(
hwnd,
to.0,
to.1,
to.2.max(1) as i32,
to.3.max(1) as i32,
);
}
pub async fn run(app: &AppHandle, pid: u32, _maximize: bool) {
if pid == 0 {
return;
}
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
return;
};
#[cfg(target_os = "windows")]
let hwnd_raw = window.hwnd().map(|h| h.0 as usize).unwrap_or(0);
#[cfg(not(target_os = "windows"))]
let hwnd_raw = 0usize;
// 记录启动器当前「可见客户区」作为起点(与最终对齐用的是同一套
// 坐标系,避免无边框窗口边框带来的偏移累积)。
let start = {
#[cfg(target_os = "windows")]
{
win_impl::get_client_rect(hwnd_raw).unwrap_or_else(|| {
let p = window
.outer_position()
.map(|p| (p.x, p.y))
.unwrap_or((0, 0));
let s = window
.outer_size()
.map(|s| (s.width, s.height))
.unwrap_or((1280, 800));
(p.0, p.1, s.0, s.1)
})
}
#[cfg(not(target_os = "windows"))]
{
let p = window
.outer_position()
.map(|p| (p.x, p.y))
.unwrap_or((0, 0));
let s = window
.outer_size()
.map(|s| (s.width, s.height))
.unwrap_or((1280, 800));
(p.0, p.1, s.0, s.1)
}
};
// 全屏目标 = 启动器当前所在显示器的完整矩形
let fullscreen = match window
.current_monitor()
.ok()
.flatten()
.or_else(|| window.primary_monitor().ok().flatten())
{
Some(m) => {
(m.position().x, m.position().y, m.size().width, m.size().height)
}
None => {
return;
}
};
// 窗口可能处于最小化/隐藏(轻量模式遗留),先恢复可见再动画,
// 否则遮罩虽然在 DOM 里建好,用户却看不到。
let _ = window.show();
let _ = window.unminimize();
// 全程置顶 + 聚焦
let _ = window.set_always_on_top(true);
let _ = window.set_focus();
// ---------- 第①步:显示纯色遮罩(无图,主题色) ----------
let (cover_tx, cover_rx) = tokio::sync::oneshot::channel::<()>();
if let Ok(mut guard) = COVER_DONE.lock() {
*guard = Some(cover_tx);
}
let _ = app.emit("mc-transition-cover-show", ());
let _ = tokio::time::timeout(
std::time::Duration::from_millis(1000),
cover_rx,
)
.await;
// ---------- 第②步放大到全屏0.2s ----------
animate_resize(&window, hwnd_raw, start, fullscreen, ANIM_DURATION_MS)
.await;
// ---------- 第③步:全屏后,渐显 SVG logo ----------
let (lin_tx, lin_rx) = tokio::sync::oneshot::channel::<()>();
if let Ok(mut guard) = LOGO_IN_DONE.lock() {
*guard = Some(lin_tx);
}
let _ = app.emit("mc-transition-logo-in", ());
let _ = tokio::time::timeout(
std::time::Duration::from_millis(1500),
lin_rx,
)
.await;
// ---------- 等待 MC 窗口出现(最多 60s ----------
let mut mc_raw: usize = 0;
for _ in 0..FIND_RETRIES {
if let Some(raw) = win_impl::find_window(pid) {
mc_raw = raw;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(FIND_INTERVAL_MS))
.await;
}
if mc_raw == 0 {
let _ = window.set_always_on_top(false);
animate_resize(&window, hwnd_raw, fullscreen, start, 300).await;
let _ = app.emit("mc-transition-reset-opacity", ());
return;
}
// ---------- 第④步:缩小前,背景变 Mojang 红 + 渐隐 logo ----------
let (lout_tx, lout_rx) = tokio::sync::oneshot::channel::<()>();
if let Ok(mut guard) = LOGO_OUT_DONE.lock() {
*guard = Some(lout_tx);
}
let _ = app.emit("mc-transition-logo-out", ());
let _ = tokio::time::timeout(
std::time::Duration::from_millis(1500),
lout_rx,
)
.await;
// ---------- 第⑤步:缩小到 MC 窗口矩形1s ----------
let mc_rect = win_impl::get_rect(mc_raw).unwrap_or((
fullscreen.0,
fullscreen.1,
fullscreen.2 as i32,
fullscreen.3 as i32,
));
let target = (
mc_rect.0,
mc_rect.1,
mc_rect.2.max(1) as u32,
mc_rect.3.max(1) as u32,
);
let _ = window.set_always_on_top(true);
let _ = window.set_focus();
animate_resize(&window, hwnd_raw, fullscreen, target, ANIM_DURATION_MS)
.await;
// ---------- 第⑥步:等待 1s游戏稳定遮罩仍在 ----------
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
// ---------- 第⑦步淡出0.5s,含遮罩一起) ----------
let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
if let Ok(mut guard) = FADE_DONE.lock() {
*guard = Some(done_tx);
}
let _ = app.emit("mc-transition-fadeout", ());
let _ = tokio::time::timeout(
std::time::Duration::from_millis(FADE_TIMEOUT_MS),
done_rx,
)
.await;
// ---------- 第⑧步:聚焦游戏 + 最小化启动器 ----------
let _ = window.set_always_on_top(false);
win_impl::focus(mc_raw);
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
let _ = window.minimize();
let _ = app.emit("mc-transition-reset-opacity", ());
}
#[cfg(not(target_os = "windows"))]
pub async fn run(_app: &AppHandle, _pid: u32, _maximize: bool) {}
#[cfg(target_os = "windows")]
mod win_impl {
use std::sync::{
Mutex,
atomic::{AtomicU32, AtomicUsize, Ordering},
};
use windows::Win32::Foundation::{HWND, LPARAM, POINT, RECT};
use windows::Win32::Graphics::Gdi::ClientToScreen;
use windows::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetClientRect, GetWindowRect, GetWindowThreadProcessId,
IsWindowVisible, SetForegroundWindow,
};
use windows::core::BOOL;
/// 目标进程 pid枚举回调通过静态变量传递
static TARGET_PID: AtomicU32 = AtomicU32::new(0);
/// 命中的窗口句柄,以 usize 存储HWND 非 Send/Sync
static FOUND_HWND: AtomicUsize = AtomicUsize::new(0);
/// 串行化枚举,避免并发查找互相覆盖静态变量。
static ENUM_LOCK: Mutex<()> = Mutex::new(());
/// usize → HWND。调用方保证数值来自真实句柄。
fn to_hwnd(raw: usize) -> HWND {
HWND(raw as *mut core::ffi::c_void)
}
unsafe extern "system" fn find_cb(hwnd: HWND, _: LPARAM) -> BOOL {
let mut pid = 0u32;
unsafe { GetWindowThreadProcessId(hwnd, Some(&mut pid)) };
if pid != TARGET_PID.load(Ordering::Relaxed)
|| !unsafe { IsWindowVisible(hwnd).as_bool() }
{
return BOOL(1);
}
let mut rect = RECT::default();
if unsafe { GetWindowRect(hwnd, &mut rect) }.is_ok() {
let w = rect.right - rect.left;
let h = rect.bottom - rect.top;
// 过滤掉工具窗口 / 0 尺寸窗口,只认真正的游戏主窗口
if w > 50 && h > 50 {
FOUND_HWND.store(hwnd.0 as usize, Ordering::Relaxed);
return BOOL(0); // 找到即停止枚举
}
}
BOOL(1)
}
/// 查找属于 `pid` 的可见顶层窗口返回句柄原始值0 表示未找到)。
pub fn find_window(pid: u32) -> Option<usize> {
let _guard = ENUM_LOCK.lock().ok()?;
TARGET_PID.store(pid, Ordering::Relaxed);
FOUND_HWND.store(0, Ordering::Relaxed);
unsafe {
let _ = EnumWindows(Some(find_cb), LPARAM(0));
}
let raw = FOUND_HWND.load(Ordering::Relaxed);
(raw != 0).then_some(raw)
}
/// 取窗口矩形,返回 (x, y, width, height)。
pub fn get_rect(raw: usize) -> Option<(i32, i32, i32, i32)> {
let mut r = RECT::default();
unsafe { GetWindowRect(to_hwnd(raw), &mut r).ok()? };
Some((r.left, r.top, r.right - r.left, r.bottom - r.top))
}
/// 取窗口「可见客户区」在屏幕坐标下的矩形,返回 (x, y, w, h)。
/// 无边框窗口仍带不可见的 resize border客户区比窗口矩形内缩
/// 这正是全屏后左侧留缝、整体偏右的根源。
pub fn get_client_rect(raw: usize) -> Option<(i32, i32, u32, u32)> {
if raw == 0 {
return None;
}
let hwnd = to_hwnd(raw);
let mut cr = RECT::default();
if unsafe { GetClientRect(hwnd, &mut cr) }.is_err() {
return None;
}
let mut origin = POINT { x: 0, y: 0 };
if !unsafe { ClientToScreen(hwnd, &mut origin) }.as_bool() {
return None;
}
Some((
origin.x,
origin.y,
(cr.right - cr.left) as u32,
(cr.bottom - cr.top) as u32,
))
}
/// 让「可见客户区」精确落在屏幕坐标 (x, y, w, h),自动补偿无边框
/// 窗口的不可见边框。动画与对齐统一用它,避免左/上缝隙。
pub fn set_client_rect(raw: usize, x: i32, y: i32, w: i32, h: i32) {
use windows::Win32::UI::WindowsAndMessaging::{
SWP_NOACTIVATE, SWP_NOZORDER, SetWindowPos,
};
if raw == 0 {
return;
}
let hwnd = to_hwnd(raw);
let mut wr = RECT::default();
let mut cr = RECT::default();
let mut origin = POINT { x: 0, y: 0 };
unsafe {
let _ = GetWindowRect(hwnd, &mut wr);
let _ = GetClientRect(hwnd, &mut cr);
let _ = ClientToScreen(hwnd, &mut origin);
}
let border_w = (wr.right - wr.left) - (cr.right - cr.left);
let border_h = (wr.bottom - wr.top) - (cr.bottom - cr.top);
let inset_x = origin.x - wr.left;
let inset_y = origin.y - wr.top;
unsafe {
let _ = SetWindowPos(
hwnd,
None,
x - inset_x,
y - inset_y,
w + border_w,
h + border_h,
SWP_NOZORDER | SWP_NOACTIVATE,
);
}
}
/// 把前台焦点交给游戏窗口。
pub fn focus(raw: usize) {
unsafe {
let _ = SetForegroundWindow(to_hwnd(raw));
}
}
}

View File

@ -75,8 +75,10 @@ async fn run_with_extra_launch_args_inner(
extra_launch_args: Option<Vec<String>>,
gc_intent: Option<GcLaunchIntent>,
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
let __t_hosted = std::time::Instant::now();
let _hosted_guard =
crate::pack::hosted::prepare_launch(instance_id, offline_mode).await?;
tracing::info!("[launch-timing] hosted_prepare_launch: {}ms", __t_hosted.elapsed().as_millis());
let state = State::get().await?;
let launch_preparation_timeout =
crate::state::instances::commands::get_instance_launch_context(

View File

@ -293,30 +293,130 @@ pub(crate) fn linked_native_plan(
}))
}
/// Persistent cache of "this file was already hash-verified" stamps, keyed by
/// absolute path and validated against the file's size and mtime. Asset
/// objects and libraries number in the thousands on modern packs; re-hashing
/// every byte on each launch is pure disk churn (and dominates cold-start on
/// mechanical drives). Content-addressed files never change in place, so a
/// matching (size, mtime) stamp lets us trust a previous verification.
///
/// The cache lives in Axolotl's own caches directory, never in the linked
/// installation. It only ever *skips* a re-hash when the stamp matches;
/// a modified or replaced file falls back to a full SHA1 check.
static VERIFY_STAMPS: std::sync::OnceLock<
std::sync::Mutex<HashMap<String, (u64, u128)>>,
> = std::sync::OnceLock::new();
static VERIFY_STAMPS_PATH: std::sync::OnceLock<PathBuf> =
std::sync::OnceLock::new();
static VERIFY_STAMPS_LOADED: std::sync::OnceLock<()> =
std::sync::OnceLock::new();
static VERIFY_STAMPS_DIRTY: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn verify_stamps() -> &'static std::sync::Mutex<HashMap<String, (u64, u128)>>
{
VERIFY_STAMPS.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}
/// Loads the stamp cache once per process. Safe to call from every entry
/// point; only the first call does work.
async fn load_verify_stamps(st: &State) {
if VERIFY_STAMPS_LOADED.set(()).is_err() {
return;
}
let path = st
.directories
.caches_dir()
.join("linked-verify-stamps.json");
if let Ok(bytes) = tokio::fs::read(&path).await
&& let Ok(map) =
serde_json::from_slice::<HashMap<String, (u64, u128)>>(&bytes)
{
if let Ok(mut guard) = verify_stamps().lock() {
*guard = map;
}
}
let _ = VERIFY_STAMPS_PATH.set(path);
}
/// Writes the stamp cache back if anything changed this session. Best-effort:
/// a failure only costs us the next session's re-hash.
async fn flush_verify_stamps() {
if !VERIFY_STAMPS_DIRTY.swap(false, std::sync::atomic::Ordering::SeqCst) {
return;
}
let Some(path) = VERIFY_STAMPS_PATH.get() else {
return;
};
let snapshot = match verify_stamps().lock() {
Ok(guard) => guard.clone(),
Err(_) => return,
};
let Ok(json) = serde_json::to_vec(&snapshot) else {
return;
};
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let _ = tokio::fs::write(path, json).await;
}
fn file_mtime_nanos(metadata: &std::fs::Metadata) -> Option<u128> {
metadata
.modified()
.ok()
.and_then(|time| {
time.duration_since(std::time::UNIX_EPOCH).ok()
})
.map(|duration| duration.as_nanos())
}
/// Whether the file exists and satisfies the metadata available for it.
/// SHA1 is authoritative when declared; otherwise a declared size still
/// protects against accepting a partial or truncated file. An unreadable file
/// counts as not current so it gets replaced.
/// counts as not current so it gets replaced. A previously recorded
/// (size, mtime) stamp short-circuits the SHA1 read for unchanged files.
async fn file_is_current(
path: &std::path::Path,
expected_sha1: Option<&str>,
expected_size: Option<u64>,
) -> bool {
if !path.is_file() {
return false;
}
let metadata = match std::fs::metadata(path) {
Ok(metadata) if metadata.is_file() => metadata,
_ => return false,
};
if let Some(expected_size) = expected_size
&& std::fs::metadata(path)
.map_or(true, |metadata| metadata.len() != expected_size)
&& metadata.len() != expected_size
{
return false;
}
match expected_sha1 {
Some(expected) => match fetch::sha1_file_async(path).await {
Ok((_, actual)) => actual.eq_ignore_ascii_case(expected),
Err(_) => false,
},
None => true,
let Some(expected) = expected_sha1 else {
return true;
};
let mtime = file_mtime_nanos(&metadata);
let key = path.to_string_lossy().into_owned();
if let Some(mtime) = mtime
&& let Ok(guard) = verify_stamps().lock()
&& let Some(&(size, stamp_mtime)) = guard.get(&key)
&& size == metadata.len()
&& stamp_mtime == mtime
{
return true;
}
match fetch::sha1_file_async(path).await {
Ok((_, actual)) if actual.eq_ignore_ascii_case(expected) => {
if let Some(mtime) = mtime
&& let Ok(mut guard) = verify_stamps().lock()
{
guard.insert(key, (metadata.len(), mtime));
VERIFY_STAMPS_DIRTY
.store(true, std::sync::atomic::Ordering::SeqCst);
}
true
}
_ => false,
}
}
@ -475,18 +575,48 @@ pub(crate) async fn ensure_linked_assets_from(
};
let objects_dir = direct.assets_dir().join("objects");
let mut missing = Vec::new();
for asset in index.objects.values() {
let hash = &asset.hash;
if hash.len() < 2 {
continue;
}
let destination = objects_dir.join(&hash[..2]).join(hash);
let size = u64::from(asset.size);
if !file_is_current(&destination, Some(hash), Some(size)).await {
missing.push((hash.clone(), size, destination));
}
}
// Assets number in the thousands on modern versions; hashing each object
// serially accounted for the bulk of launch latency. Fan the checks out
// across a bounded pool (same width the downloader uses).
let __t_asset_scan = std::time::Instant::now();
let asset_limit = download_util::task_concurrency_limit(st)
.map(|limit| limit.saturating_mul(2))
.unwrap_or(FALLBACK_CONCURRENCY);
let missing_slot = std::sync::Arc::new(std::sync::Mutex::new(
Vec::<(String, u64, PathBuf)>::new(),
));
let asset_entries: Vec<(String, u64)> = index
.objects
.values()
.filter(|asset| asset.hash.len() >= 2)
.map(|asset| (asset.hash.clone(), u64::from(asset.size)))
.collect();
stream::iter(asset_entries)
.map(Ok::<_, crate::Error>)
.try_for_each_concurrent(asset_limit, |(hash, size)| {
let missing_slot = std::sync::Arc::clone(&missing_slot);
let objects_dir = objects_dir.clone();
async move {
let destination = objects_dir.join(&hash[..2]).join(&hash);
if !file_is_current(
&destination,
Some(&hash),
Some(size),
)
.await
{
missing_slot.lock().unwrap().push((hash, size, destination));
}
Ok(())
}
})
.await?;
let missing: Vec<(String, u64, PathBuf)> = std::sync::Arc::try_unwrap(
missing_slot,
)
.map(|mutex| mutex.into_inner().unwrap())
.unwrap_or_default();
tracing::info!("[launch-timing] asset_scan: {}ms (checked {}, missing {})", __t_asset_scan.elapsed().as_millis(), index.objects.len(), missing.len());
if !missing.is_empty() {
tracing::info!(
count = missing.len(),
@ -620,6 +750,7 @@ pub(crate) async fn ensure_direct_launch_dependencies(
java_arch: &str,
minecraft_updated: bool,
) -> crate::Result<()> {
load_verify_stamps(st).await;
let mut plans = Vec::new();
if let Some(plan) = linked_client_plan(direct, version_info) {
plans.push(plan);
@ -648,14 +779,41 @@ pub(crate) async fn ensure_direct_launch_dependencies(
// Only fetch what is actually missing so a healthy installation performs
// zero network requests.
let mut pending = Vec::new();
for plan in plans {
if !file_is_current(&plan.destination, plan.sha1.as_deref(), plan.size)
.await
{
pending.push(plan);
}
}
let __t_libscan = std::time::Instant::now();
let plans_len_dbg = plans.len();
// Verify every planned file concurrently. Hashing a few hundred jars
// serially dominated cold-start time; the SHA1 reads are independent so
// they fan out across the same bounded pool the downloader uses.
let lib_limit = download_util::task_concurrency_limit(st)
.map(|limit| limit.saturating_mul(2))
.unwrap_or(FALLBACK_CONCURRENCY);
let pending_slot = std::sync::Arc::new(std::sync::Mutex::new(
Vec::<LinkedFilePlan>::new(),
));
stream::iter(plans)
.map(Ok::<_, crate::Error>)
.try_for_each_concurrent(lib_limit, |plan| {
let pending_slot = std::sync::Arc::clone(&pending_slot);
async move {
if !file_is_current(
&plan.destination,
plan.sha1.as_deref(),
plan.size,
)
.await
{
pending_slot.lock().unwrap().push(plan);
}
Ok(())
}
})
.await?;
let pending: Vec<LinkedFilePlan> = std::sync::Arc::try_unwrap(
pending_slot,
)
.map(|mutex| mutex.into_inner().unwrap())
.unwrap_or_default();
tracing::info!("[launch-timing] dep_lib_scan: {}ms (checked {}, missing {})", __t_libscan.elapsed().as_millis(), plans_len_dbg, pending.len());
if !pending.is_empty() {
tracing::info!(
count = pending.len(),
@ -672,6 +830,7 @@ pub(crate) async fn ensure_direct_launch_dependencies(
.await?;
}
let __t_assets = std::time::Instant::now();
ensure_linked_assets(
st,
direct,
@ -679,7 +838,11 @@ pub(crate) async fn ensure_direct_launch_dependencies(
version_info.assets == "legacy",
)
.await?;
tracing::info!("[launch-timing] dep_assets: {}ms", __t_assets.elapsed().as_millis());
let __t_logcfg = std::time::Instant::now();
ensure_linked_log_config(st, direct, version_info.logging.as_ref()).await?;
tracing::info!("[launch-timing] dep_log_config: {}ms", __t_logcfg.elapsed().as_millis());
flush_verify_stamps().await;
Ok(())
}

View File

@ -1525,8 +1525,11 @@ pub async fn launch_minecraft(
) -> crate::Result<ProcessMetadata> {
let instance = &context.instance;
let content_set = &context.applied_content_set;
let mut __lt = std::time::Instant::now();
let mut combined_java_args =
crate::pack::hosted::java_arguments(&instance.id).await?;
tracing::info!("[launch-timing] hosted_java_arguments: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
combined_java_args.extend_from_slice(java_args);
let java_args = combined_java_args.as_slice();
@ -1832,6 +1835,8 @@ pub async fn launch_minecraft(
.await?;
}
}
tracing::info!("[launch-timing] resolve_version_info: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
let java_version = if let Some(java) =
context.launch_overrides.java_path.as_ref()
@ -1886,6 +1891,8 @@ pub async fn launch_minecraft(
let java_version =
crate::api::jre::check_jre(java_version.path.clone().into()).await?;
validate_loader_java_version(&version_info, &java_version)?;
tracing::info!("[launch-timing] resolve_java: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
// Runtime-verify and fall back for GC arguments against the *actual* JVM
// that will run Minecraft. The frontend supplies an ordered candidate
@ -1915,6 +1922,8 @@ pub async fn launch_minecraft(
}
*gc_report = Some(report);
}
tracing::info!("[launch-timing] resolve_gc: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
// Apply the mappable linked-launcher private settings for directly
// associated instances on top of the resolved launch configuration.
@ -2020,6 +2029,8 @@ pub async fn launch_minecraft(
None => vanilla_client_path,
}
};
tracing::info!("[launch-timing] assemble_client: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
let args = version_info.arguments.clone().unwrap_or_default();
let mut command = match wrapper {
@ -2065,6 +2076,7 @@ pub async fn launch_minecraft(
&& let (Some(direct), Some(libraries)) =
(&direct_launch, linked_libraries.as_deref())
{
let __t_ensure = std::time::Instant::now();
direct_ensure::ensure_direct_launch_dependencies(
&state,
direct,
@ -2074,6 +2086,7 @@ pub async fn launch_minecraft(
minecraft_updated,
)
.await?;
tracing::info!("[launch-timing] ensure_deps(libs+assets+log): {}ms", __t_ensure.elapsed().as_millis());
}
let natives_dir = if let Some(direct) = &direct_launch {
@ -2085,9 +2098,11 @@ pub async fn launch_minecraft(
};
// Linked native archives can change outside Axolotl, so rebuild only the
// Axolotl-owned linked cache on every launch. Never mutate linked folders.
let __t_rm = std::time::Instant::now();
if direct_launch.is_some() && natives_dir.exists() {
io::remove_dir_all(&natives_dir).await?;
}
tracing::info!("[launch-timing] remove_old_natives: {}ms", __t_rm.elapsed().as_millis());
if !natives_dir.exists() {
io::create_dir_all(&natives_dir).await?;
}
@ -2098,6 +2113,7 @@ pub async fn launch_minecraft(
// launch; the managed restore path below must never touch them.
let target = natives_dir.clone();
let java_arch = java_version.architecture.clone();
let __t_extract = std::time::Instant::now();
tokio::task::spawn_blocking(move || {
extract_linked_natives(
&direct,
@ -2108,6 +2124,7 @@ pub async fn launch_minecraft(
)
})
.await??;
tracing::info!("[launch-timing] extract_linked_natives: {}ms", __t_extract.elapsed().as_millis());
} else if direct_launch.is_none() {
if offline_mode {
natives::prepare_native_libraries(
@ -2137,6 +2154,8 @@ pub async fn launch_minecraft(
}
}
tracing::info!("[launch-timing] ensure_libraries_and_natives: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
tracing::debug!(
"Found QuickPlayVersion for {}: {quick_play_version:?}",
content_set.game_version
@ -2399,7 +2418,7 @@ pub async fn launch_minecraft(
let logs_folder = state.directories.game_logs_dir(&instance_path);
// Create Minecraft child by inserting it into the state
// This also spawns the process and prepares the subsequent processes
state
let __launch_process = state
.process_manager
.insert_new_process(
&instance.id,
@ -2443,7 +2462,9 @@ pub async fn launch_minecraft(
Ok(())
},
)
.await
.await;
tracing::info!("[launch-timing] process_spawn: {}ms", __lt.elapsed().as_millis());
__launch_process
}
#[cfg(test)]

View File

@ -630,7 +630,10 @@ impl std::io::Write for TruncatedConsoleWriter {
// Handling for the live development logging
// This will log to the console, and will not log to a file
#[cfg(debug_assertions)]
pub fn start_logger(_app_identifier: &str) -> Option<()> {
pub fn start_logger(app_identifier: &str) -> Option<()> {
use crate::prelude::DirectoryInfo;
use chrono::Local;
use tracing_subscriber::fmt::time::ChronoLocal;
use tracing_subscriber::prelude::*;
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| {
@ -640,12 +643,39 @@ pub fn start_logger(_app_identifier: &str) -> Option<()> {
.add_directive("hyper=info".parse().ok()?)
.add_directive("hyper_util=info".parse().ok()?)
.add_directive("sqlx=warn".parse().ok()?);
let console_layer = tracing_subscriber::fmt::layer().with_writer(|| {
TruncatedConsoleWriter {
stdout: std::io::stdout(),
}
});
// Debug builds historically only logged to the console, so a launched
// GUI process left no trace once its window closed. Tee into the same
// launcher_logs directory the release build uses so debug launches can
// be inspected after the fact.
let file_layer = DirectoryInfo::launcher_logs_dir_path(app_identifier)
.and_then(|dir| {
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join(format!(
"session_debug_{}.log",
Local::now().format("%Y%m%d_%H%M%S")
));
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.ok()
})
.map(|file| {
tracing_subscriber::fmt::layer()
.with_writer(std::sync::Mutex::new(file))
.with_ansi(false)
.with_timer(ChronoLocal::rfc_3339())
});
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer().with_writer(|| {
TruncatedConsoleWriter {
stdout: std::io::stdout(),
}
}))
.with(console_layer)
.with(file_layer)
.with(filter)
.with(tracing_error::ErrorLayer::default())
.init();

775
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff