Compare commits

...
Author SHA1 Message Date
AxTps a12c27edf7 fix: 补齐盔甲预览的中文翻译
Sync source to CNB / Sync Git ref (push) Skipped
皮肤编辑器盔甲面板的 46 条文案此前只有英文,补上 packages/ui 的中英对照。
2026-10-10 21:25:39 +08:00
AxTps 544611738d docs: 1.0.2-beta4 公告并入盔甲预览
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
beta4 此前未打 tag,本次发布将其与皮肤编辑器盔甲预览
移植(PR #8)合并发布,故在 beta4 公告条目补充:
- 皮肤预览新增盔甲与盔甲纹饰渲染
- 盔甲预览选择的本地持久化
2026-10-10 19:25:39 +08:00
AxTps 403a883e0b fix: CI 钉住 cargo-shear 版本为 1.13.4
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
check-rust.yml 的 shear 任务在 Rust 1.95 工具链下装最新
cargo-shear(1.14.0) 失败:其依赖 ra_ap_syntax 0.0.352 要求
rustc >= 1.98。且 binstall 走 GitHub release 下载时遭遇
api.github.com 403,回退源码编译后同样因版本过高而失败。

钉到 cargo-shear@1.13.4(依赖 ra_ap_syntax 0.0.347,要求
rustc 1.95),与 CI 的 stable 工具链兼容。
2026-10-09 22:20:48 +08:00
AxTps 53c749aaea fix: 补齐 skin-rendering barrel 的盔甲导出
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
PR #8 合并后 CI 打包报 MISSING_EXPORT:ArmorTrimPanel.vue 与
SkinPreviewRenderer.vue 从 #ui/composables/skin-rendering 导入
ARMOR_SLOTS / ARMOR_TRIM_MATERIALS / ARMOR_TRIM_PATTERNS /
armorMaterialsForSlot / createDefaultArmorPreviewConfig /
useArmorPreview,但该 barrel 未导出这些符号。

补上 armor-preview-types 与 use-armor-preview 的导出。
本地 pnpm --filter @modrinth/app-frontend build 已通过。
2026-10-09 22:12:41 +08:00
Ap_Tx addc31c7c1 Merge pull request #8 from ApTx2012/feat/axolotl-port
feat: 移植 Axolotl 皮肤盔甲预览(Armor Preview)
2026-10-09 21:50:24 +08:00
AxTps 25978bcd8f Merge remote-tracking branch 'origin2/main'
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
2026-10-09 21:46:12 +08:00
AxTps 117eaad9ff style: 对分组修复涉及的 Rust 文件补 cargo fmt 2026-10-09 21:44:22 +08:00
Disy 91e2c369f5 fix: 优化下载并发调度,发布 1.0.2-beta4
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
2026-10-08 12:10:54 +08:00
20 changed files with 724 additions and 125 deletions

No files matched your search

+1 -1
View File
@@ -22,5 +22,5 @@ jobs:
node-version-file: .nvmrc node-version-file: .nvmrc
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1 - uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
- run: cargo binstall --no-confirm cargo-shear - run: cargo binstall --no-confirm cargo-shear@1.13.4
- run: cargo shear - run: cargo shear
Generated
+2 -2
View File
@@ -6841,7 +6841,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus" name = "theseus"
version = "1.0.0-beta15" version = "1.0.2-beta4"
dependencies = [ dependencies = [
"ariadne", "ariadne",
"async-minecraft-ping", "async-minecraft-ping",
@@ -6945,7 +6945,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus_gui" name = "theseus_gui"
version = "1.0.0-beta15" version = "1.0.2-beta4"
dependencies = [ dependencies = [
"ashpd", "ashpd",
"async_zip", "async_zip",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "1.0.0-beta15", "version": "1.0.2-beta4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -32,6 +32,43 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
] ]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
id: 'launcher-1.0.2-beta4',
version: '1.0.2-beta4',
publishedAt: '2026-10-08',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta4',
'zh-CN': 'Starlight Launcher 1.0.2-beta4',
},
changes: {
added: [
{
'en-US': 'Added armor and armor-trim rendering to the skin preview, letting you try on armor pieces and trim patterns directly in the skin editor.',
'zh-CN': '皮肤预览新增盔甲与盔甲纹饰渲染,可在皮肤编辑器内直接试穿各部位盔甲并调整纹饰图案。',
},
{
'en-US': 'Armor preview selections in the skin editor are now saved locally, so they persist across sessions.',
'zh-CN': '皮肤编辑器中的盔甲预览选择现在会保存在本地,跨会话保留。',
},
],
changed: [
{
'en-US': 'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.',
'zh-CN': '优化下载任务调度:任务因容量不足排队时会自动降低并发,提升大型 Minecraft 版本在受限网络环境下的安装稳定性。',
},
],
fixed: [
{
'en-US': 'Fixed downloads timing out while waiting for internal connection capacity, which could cause Minecraft installations to fail even when the network was available.',
'zh-CN': '修复下载任务等待内部连接容量时过早超时,导致网络正常的情况下 Minecraft 安装仍可能失败的问题。',
},
{
'en-US': 'Fixed instance group changes not saving for some instances.',
'zh-CN': '修复部分实例的分组修改无法保存的问题。',
},
],
},
},
{ {
id: 'launcher-1.0.2-beta3', id: 'launcher-1.0.2-beta3',
version: '1.0.2-beta3', version: '1.0.2-beta3',
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "theseus_gui" name = "theseus_gui"
# The actual version is set by the theseus-build workflow on tagging # The actual version is set by the theseus-build workflow on tagging
version = "1.0.0-beta15" version = "1.0.2-beta4"
edition.workspace = true edition.workspace = true
description = "Axolotl Launcher is a cross-platform Minecraft launcher" description = "Axolotl Launcher is a cross-platform Minecraft launcher"
homepage = "https://www.ghs.red" homepage = "https://www.ghs.red"
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "theseus" name = "theseus"
# The actual version is set by the theseus-build workflow on tagging # The actual version is set by the theseus-build workflow on tagging
version = "1.0.0-beta15" version = "1.0.2-beta4"
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
+3 -3
View File
@@ -1947,8 +1947,8 @@ pub async fn download_assets(
// failures. Runs concurrently (same budget as the original scheduler) so // failures. Runs concurrently (same budget as the original scheduler) so
// import flows are not serialised. // import flows are not serialised.
if !fallback_assets.is_empty() { if !fallback_assets.is_empty() {
let limit = crate::util::download::task_concurrency_limit(st) let limit =
.map(|limit| limit.saturating_mul(2)) crate::util::download::single_authority_task_concurrency_limit(st)
.unwrap_or(ASSET_BATCH_CONCURRENCY); .unwrap_or(ASSET_BATCH_CONCURRENCY);
futures::stream::iter(fallback_assets) futures::stream::iter(fallback_assets)
.map(Ok::<FallbackAsset, crate::Error>) .map(Ok::<FallbackAsset, crate::Error>)
@@ -2123,7 +2123,7 @@ pub async fn download_libraries(
let num_files = tasks.len(); let num_files = tasks.len();
loading_try_for_each_concurrent( loading_try_for_each_concurrent(
stream::iter(tasks).map(Ok::<LibraryDownloadTask<'_>, crate::Error>), stream::iter(tasks).map(Ok::<LibraryDownloadTask<'_>, crate::Error>),
crate::util::download::task_concurrency_limit(&st).map(|limit| limit.saturating_mul(2)), crate::util::download::task_concurrency_limit(&st),
loading_bar, loading_bar,
loading_amount, loading_amount,
num_files, num_files,
@@ -1,6 +1,5 @@
use crate::api::pack::import::{ use crate::api::pack::import::{
ImportLauncherType, ImportLauncherType, direct_link::resolve_direct_link,
direct_link::resolve_direct_link,
}; };
use crate::launcher::ExternalGameDirMode; use crate::launcher::ExternalGameDirMode;
use crate::state::instances::{ use crate::state::instances::{
@@ -1,7 +1,7 @@
use super::create_direct_link_instance::create_direct_link_instance; use super::create_direct_link_instance::create_direct_link_instance;
use crate::api::pack::import::direct_link::{ use crate::api::pack::import::direct_link::{
detect_direct_link_source, detect_direct_link_source, has_minecraft_version_manifest,
has_minecraft_version_manifest, resolve_direct_link, resolve_direct_link,
}; };
use crate::event::{InstancePayloadType, emit::emit_instance}; use crate::event::{InstancePayloadType, emit::emit_instance};
use crate::launcher::ExternalGameDirMode; use crate::launcher::ExternalGameDirMode;
+39
View File
@@ -118,6 +118,7 @@ pub struct State {
download_sample_requests: AtomicU64, download_sample_requests: AtomicU64,
download_sample_errors: AtomicU64, download_sample_errors: AtomicU64,
download_sample_throttles: AtomicU64, download_sample_throttles: AtomicU64,
download_sample_queue_waits: AtomicU64,
pub(crate) install_job_semaphore: Semaphore, pub(crate) install_job_semaphore: Semaphore,
pub(crate) install_db_semaphore: Semaphore, pub(crate) install_db_semaphore: Semaphore,
pub(crate) install_job_cancellations: DashMap<Uuid, CancellationToken>, pub(crate) install_job_cancellations: DashMap<Uuid, CancellationToken>,
@@ -196,6 +197,7 @@ struct AutoConcurrencySample {
requests: u64, requests: u64,
errors: u64, errors: u64,
throttles: u64, throttles: u64,
queue_waits: u64,
} }
/// Per-instance lock registry with task-local reentrancy. /// Per-instance lock registry with task-local reentrancy.
@@ -399,6 +401,15 @@ impl AutoConcurrencyController {
} else { } else {
sample.errors as f64 / sample.requests as f64 sample.errors as f64 / sample.requests as f64
}; };
if sample.queue_waits > 0 {
self.high_utilization_windows = 0;
self.pressure_windows = 0;
self.probe = None;
self.cooldown_until = Some(now + AUTO_DOWNLOAD_PROBE_COOLDOWN);
return current
.saturating_sub(current.div_ceil(4))
.max(AUTO_DOWNLOAD_CONCURRENCY_MIN);
}
// A single throttled window is handled by the per-host limiter; the // A single throttled window is handled by the per-host limiter; the
// global concurrency only backs off when pressure persists across // global concurrency only backs off when pressure persists across
// several samples, and then in small steps instead of a big cut. // several samples, and then in small steps instead of a big cut.
@@ -677,6 +688,11 @@ impl State {
self.download_sample_errors.fetch_add(1, Ordering::AcqRel); self.download_sample_errors.fetch_add(1, Ordering::AcqRel);
} }
pub(crate) fn record_download_queue_wait(&self) {
self.download_sample_queue_waits
.fetch_add(1, Ordering::AcqRel);
}
pub(crate) fn update_download_settings( pub(crate) fn update_download_settings(
self: &Arc<Self>, self: &Arc<Self>,
settings: &Settings, settings: &Settings,
@@ -735,6 +751,7 @@ impl State {
self.download_sample_requests.swap(0, Ordering::AcqRel); self.download_sample_requests.swap(0, Ordering::AcqRel);
self.download_sample_errors.swap(0, Ordering::AcqRel); self.download_sample_errors.swap(0, Ordering::AcqRel);
self.download_sample_throttles.swap(0, Ordering::AcqRel); self.download_sample_throttles.swap(0, Ordering::AcqRel);
self.download_sample_queue_waits.swap(0, Ordering::AcqRel);
continue; continue;
} }
let sample = AutoConcurrencySample { let sample = AutoConcurrencySample {
@@ -749,6 +766,9 @@ impl State {
throttles: self throttles: self
.download_sample_throttles .download_sample_throttles
.swap(0, Ordering::AcqRel), .swap(0, Ordering::AcqRel),
queue_waits: self
.download_sample_queue_waits
.swap(0, Ordering::AcqRel),
}; };
let current = self.download_concurrency(); let current = self.download_concurrency();
let ceiling = let ceiling =
@@ -771,6 +791,7 @@ impl State {
requests = sample.requests, requests = sample.requests,
errors = sample.errors, errors = sample.errors,
throttles = sample.throttles, throttles = sample.throttles,
queue_waits = sample.queue_waits,
"Adjusted automatic download concurrency" "Adjusted automatic download concurrency"
); );
self.resize_download_concurrency(target); self.resize_download_concurrency(target);
@@ -935,6 +956,7 @@ impl State {
download_sample_requests: AtomicU64::new(0), download_sample_requests: AtomicU64::new(0),
download_sample_errors: AtomicU64::new(0), download_sample_errors: AtomicU64::new(0),
download_sample_throttles: AtomicU64::new(0), download_sample_throttles: AtomicU64::new(0),
download_sample_queue_waits: AtomicU64::new(0),
install_job_semaphore: Semaphore::new(MAX_CONCURRENT_INSTALL_JOBS), install_job_semaphore: Semaphore::new(MAX_CONCURRENT_INSTALL_JOBS),
install_db_semaphore: Semaphore::new(1), install_db_semaphore: Semaphore::new(1),
install_job_cancellations: DashMap::new(), install_job_cancellations: DashMap::new(),
@@ -1024,6 +1046,7 @@ pub(crate) async fn test_state(
download_sample_requests: AtomicU64::new(0), download_sample_requests: AtomicU64::new(0),
download_sample_errors: AtomicU64::new(0), download_sample_errors: AtomicU64::new(0),
download_sample_throttles: AtomicU64::new(0), download_sample_throttles: AtomicU64::new(0),
download_sample_queue_waits: AtomicU64::new(0),
install_job_semaphore: Semaphore::new(1), install_job_semaphore: Semaphore::new(1),
install_db_semaphore: Semaphore::new(1), install_db_semaphore: Semaphore::new(1),
install_job_cancellations: DashMap::new(), install_job_cancellations: DashMap::new(),
@@ -1053,9 +1076,24 @@ mod auto_concurrency_tests {
requests: 100, requests: 100,
errors: 0, errors: 0,
throttles: 0, throttles: 0,
queue_waits: 0,
} }
} }
#[test]
fn queue_pressure_backs_off_immediately_and_repeatedly() {
let mut controller = AutoConcurrencyController::default();
let now = Instant::now();
let congested = AutoConcurrencySample {
queue_waits: 1,
..healthy(8, 100)
};
assert_eq!(controller.next_target(now, 128, 128, congested), 96);
assert_eq!(controller.next_target(now, 96, 128, congested), 72);
assert_eq!(controller.next_target(now, 16, 128, congested), 16);
}
#[test] #[test]
fn probes_up_after_two_saturated_windows_and_respects_ceiling() { fn probes_up_after_two_saturated_windows_and_respects_ceiling() {
let mut controller = AutoConcurrencyController::default(); let mut controller = AutoConcurrencyController::default();
@@ -1130,6 +1168,7 @@ mod auto_concurrency_tests {
requests: 100, requests: 100,
errors: 5, errors: 5,
throttles: 0, throttles: 0,
queue_waits: 0,
}; };
assert_eq!(controller.next_target(Instant::now(), 16, 128, sample), 16); assert_eq!(controller.next_target(Instant::now(), 16, 128, sample), 16);
} }
@@ -62,6 +62,7 @@ pub(crate) enum H2DownloadOutcome {
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum H2DownloadFailure { pub(crate) enum H2DownloadFailure {
Ineligible(&'static str), Ineligible(&'static str),
Capacity,
Connect, Connect,
Tls, Tls,
Protocol, Protocol,
@@ -76,6 +77,7 @@ impl H2DownloadFailure {
pub(crate) const fn as_str(self) -> &'static str { pub(crate) const fn as_str(self) -> &'static str {
match self { match self {
Self::Ineligible(reason) => reason, Self::Ineligible(reason) => reason,
Self::Capacity => "HTTP/2 connection capacity is unavailable",
Self::Connect => "HTTP/2 TCP connection failed", Self::Connect => "HTTP/2 TCP connection failed",
Self::Tls => "HTTP/2 TLS connection failed", Self::Tls => "HTTP/2 TLS connection failed",
Self::Protocol => "HTTP/2 protocol failed", Self::Protocol => "HTTP/2 protocol failed",
@@ -104,6 +106,7 @@ impl H2DownloadFailure {
pub(crate) async fn try_download_via_h2( pub(crate) async fn try_download_via_h2(
request: &DownloadRequest, request: &DownloadRequest,
route: &DownloadRoute, route: &DownloadRoute,
semaphore: &fetch::FetchSemaphore,
destination: &Path, destination: &Path,
part_path: &Path, part_path: &Path,
policy: super::native::NativeH2Policy, policy: super::native::NativeH2Policy,
@@ -122,13 +125,19 @@ pub(crate) async fn try_download_via_h2(
preserve_partial: false, preserve_partial: false,
}; };
} }
let connection = match connect_authority( let connection_wait =
route, connect_authority(route, true, policy.allow_cold_connection);
true, let connection_result = if let Some(cancellation) =
policy.allow_cold_connection, request.cancellation.as_ref()
)
.await
{ {
tokio::select! {
_ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
result = connection_wait => result,
}
} else {
connection_wait.await
};
let connection = match connection_result {
Ok(connection) => connection, Ok(connection) => connection,
Err(failure) => { Err(failure) => {
return H2DownloadOutcome::Fallback { return H2DownloadOutcome::Fallback {
@@ -137,6 +146,30 @@ pub(crate) async fn try_download_via_h2(
}; };
} }
}; };
// The shared connection reserves native capacity when it is created.
// Acquire the per-download permit afterwards so every native path uses
// the same native-then-fetch order and cannot form a circular wait.
let fetch_permit_wait =
fetch::acquire_fetch_permit_with_pressure(semaphore);
let fetch_permit_result = if let Some(cancellation) =
request.cancellation.as_ref()
{
tokio::select! {
_ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
result = fetch_permit_wait => result,
}
} else {
fetch_permit_wait.await
};
let _fetch_permit = match fetch_permit_result {
Ok(permit) => permit,
Err(_) => {
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Connect,
preserve_partial: false,
};
}
};
let Ok(uri) = route.url.parse::<Uri>() else { let Ok(uri) = route.url.parse::<Uri>() else {
return H2DownloadOutcome::Fallback { return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Http, failure: H2DownloadFailure::Http,
@@ -156,14 +189,20 @@ pub(crate) async fn try_download_via_h2(
let total_size = if let Some(size) = expected_size { let total_size = if let Some(size) = expected_size {
size size
} else { } else {
let _probe_stream_permit = match tokio::time::timeout( let probe_wait = super::h2_stream_budget::acquire_with_pressure(route);
ASSET_RESOURCE_WAIT_TIMEOUT, let probe_result = if let Some(cancellation) =
super::h2_stream_budget::acquire(route), request.cancellation.as_ref()
)
.await
{ {
Ok(Ok(permit)) => permit, tokio::select! {
Ok(Err(_)) | Err(_) => { _ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
result = probe_wait => result,
}
} else {
probe_wait.await
};
let _probe_stream_permit = match probe_result {
Ok(permit) => permit,
Err(_) => {
return H2DownloadOutcome::Fallback { return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Connect, failure: H2DownloadFailure::Connect,
preserve_partial: false, preserve_partial: false,
@@ -239,10 +278,7 @@ pub(crate) async fn try_download_via_h2(
crate::install::DownloadItemStatus::WaitingForResource, crate::install::DownloadItemStatus::WaitingForResource,
) )
.await; .await;
let stream_wait = tokio::time::timeout( let stream_wait = super::h2_stream_budget::acquire_with_pressure(route);
ASSET_RESOURCE_WAIT_TIMEOUT,
super::h2_stream_budget::acquire(route),
);
let stream_wait_started = Instant::now(); let stream_wait_started = Instant::now();
let stream_result = if let Some(cancellation) = let stream_result = if let Some(cancellation) =
request.cancellation.as_ref() request.cancellation.as_ref()
@@ -255,8 +291,8 @@ pub(crate) async fn try_download_via_h2(
stream_wait.await stream_wait.await
}; };
let _stream_permit = match stream_result { let _stream_permit = match stream_result {
Ok(Ok(permit)) => permit, Ok(permit) => permit,
Ok(Err(_)) | Err(_) => { Err(_) => {
return H2DownloadOutcome::Fallback { return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Connect, failure: H2DownloadFailure::Connect,
preserve_partial: false, preserve_partial: false,
@@ -327,6 +363,7 @@ async fn connect_authority(
"Failed to establish shared HTTP/2 connection" "Failed to establish shared HTTP/2 connection"
); );
Err(match error.kind { Err(match error.kind {
H2ConnectFailureKind::Capacity => H2DownloadFailure::Capacity,
H2ConnectFailureKind::Tcp => H2DownloadFailure::Connect, H2ConnectFailureKind::Tcp => H2DownloadFailure::Connect,
H2ConnectFailureKind::Tls => H2DownloadFailure::Tls, H2ConnectFailureKind::Tls => H2DownloadFailure::Tls,
H2ConnectFailureKind::Protocol => H2DownloadFailure::Protocol, H2ConnectFailureKind::Protocol => H2DownloadFailure::Protocol,
@@ -771,6 +808,12 @@ mod tests {
use super::*; use super::*;
use bytes::Bytes; use bytes::Bytes;
#[test]
fn capacity_wait_is_not_classified_as_a_transport_failure() {
assert!(!H2DownloadFailure::Capacity.is_transfer_failure());
assert!(!H2DownloadFailure::Capacity.should_cooldown_authority());
}
#[tokio::test] #[tokio::test]
async fn single_stream_reports_bytes_before_the_response_finishes() { async fn single_stream_reports_bytes_before_the_response_finishes() {
let size = 512 * 1024; let size = 512 * 1024;
@@ -1183,28 +1226,6 @@ async fn download_asset_item(
"timed out waiting for asset destination lock".to_string(), "timed out waiting for asset destination lock".to_string(),
) )
})?; })?;
let fetch_permit = if apply_native_policy {
let Some(semaphore) = native_semaphore else {
return Err(crate::ErrorKind::OtherError(
"native asset batch is missing fetch budget".to_string(),
)
.into());
};
Some(
tokio::time::timeout(
ASSET_RESOURCE_WAIT_TIMEOUT,
semaphore.0.acquire(),
)
.await
.map_err(|_| {
crate::ErrorKind::NetworkError(
"timed out waiting for asset fetch permit".to_string(),
)
})??,
)
} else {
None
};
// A different downloader may have committed the object while this item // A different downloader may have committed the object while this item
// waited for the destination lock. Reuse it instead of opening another // waited for the destination lock. Reuse it instead of opening another
// stream, which also prevents cross-engine `.part`/rename races. // stream, which also prevents cross-engine `.part`/rename races.
@@ -1243,24 +1264,26 @@ async fn download_asset_item(
return Ok(AssetBatchItemOutcome::LocalObjectFailed { error }); return Ok(AssetBatchItemOutcome::LocalObjectFailed { error });
} }
}; };
let _stream_permit = if apply_native_policy { // Resolve the primary or lazily expanded sibling before taking the fetch
Some( // permit. Shared connections reserve native capacity, so this preserves
tokio::time::timeout( // the native-then-fetch order used by every other native path.
ASSET_RESOURCE_WAIT_TIMEOUT, let connection = connections.connection(rescue).await;
super::h2_stream_budget::acquire_asset(route), let fetch_permit = if apply_native_policy {
) let Some(semaphore) = native_semaphore else {
.await return Err(crate::ErrorKind::OtherError(
.map_err(|_| { "native asset batch is missing fetch budget".to_string(),
crate::ErrorKind::NetworkError(
"timed out waiting for asset HTTP/2 stream permit"
.to_string(),
)
})??,
) )
.into());
};
Some(fetch::acquire_fetch_permit_with_pressure(semaphore).await?)
} else {
None
};
let _stream_permit = if apply_native_policy {
Some(super::h2_stream_budget::acquire_asset_with_pressure(route).await?)
} else { } else {
None None
}; };
let connection = connections.connection(rescue).await;
let _connection_stream = connection.track_stream(); let _connection_stream = connection.track_stream();
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert( headers.insert(
+58 -27
View File
@@ -34,6 +34,7 @@ const CONNECTION_WAIT_TIMEOUT: Duration = Duration::from_secs(45);
#[derive(Debug, Clone, Copy, Eq, PartialEq)] #[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) enum H2ConnectFailureKind { pub(crate) enum H2ConnectFailureKind {
Capacity,
Tcp, Tcp,
Tls, Tls,
Protocol, Protocol,
@@ -357,9 +358,41 @@ async fn connect_tcp(host: &str, port: u16) -> std::io::Result<TcpStream> {
} }
/// Connects a new shared HTTP/2 connection to `authority` (host[:port]). /// Connects a new shared HTTP/2 connection to `authority` (host[:port]).
async fn establish( async fn reserve_physical_budget(
route: &DownloadRoute, route: &DownloadRoute,
reserve_native_budget: bool, reserve_native_budget: bool,
) -> Result<Option<super::native_budget::NativeBudgetPermit>, H2ConnectError> {
Ok(if reserve_native_budget {
Some(
crate::util::fetch::wait_for_download_resource(
super::native_budget::acquire(route),
)
.await
.map_err(|_| {
H2ConnectError::new(
H2ConnectFailureKind::Capacity,
"HTTP/2 connection capacity remained stalled for 5 minutes"
.to_string(),
)
})?
.map_err(|error| {
H2ConnectError::new(
H2ConnectFailureKind::Capacity,
format!(
"failed to reserve HTTP/2 connection capacity: {error}"
),
)
})?,
)
} else {
None
})
}
/// Connects a new shared HTTP/2 connection to `authority` (host[:port]).
async fn establish(
route: &DownloadRoute,
physical_budget: Option<super::native_budget::NativeBudgetPermit>,
) -> Result<Arc<SharedH2Connection>, H2ConnectError> { ) -> Result<Arc<SharedH2Connection>, H2ConnectError> {
let authority = let authority =
crate::util::fetch::url_authority(&route.url).ok_or_else(|| { crate::util::fetch::url_authority(&route.url).ok_or_else(|| {
@@ -368,16 +401,6 @@ async fn establish(
"HTTP/2 route has no authority".to_string(), "HTTP/2 route has no authority".to_string(),
) )
})?; })?;
let physical_budget = if reserve_native_budget {
Some(super::native_budget::acquire(route).await.map_err(|error| {
H2ConnectError::new(
H2ConnectFailureKind::Tcp,
format!("failed to reserve HTTP/2 connection capacity: {error}"),
)
})?)
} else {
None
};
let (host, port) = authority let (host, port) = authority
.rsplit_once(':') .rsplit_once(':')
.map(|(host, port)| (host, port.parse::<u16>().unwrap_or(443))) .map(|(host, port)| (host, port.parse::<u16>().unwrap_or(443)))
@@ -487,14 +510,14 @@ pub(crate) async fn shared_connection(
) )
})?; })?;
let slot = connection_slot(&authority).await; let slot = connection_slot(&authority).await;
let mut cached = tokio::time::timeout(CONNECTION_WAIT_TIMEOUT, slot.lock()) let mut cached = crate::util::fetch::wait_for_download_resource(
slot.lock(),
)
.await .await
.map_err(|_| { .map_err(|_| {
H2ConnectError::new( H2ConnectError::new(
H2ConnectFailureKind::Protocol, H2ConnectFailureKind::Capacity,
format!( format!("HTTP/2 connection slot remained stalled for {authority}"),
"timed out waiting for HTTP/2 connection slot {authority}"
),
) )
})?; })?;
if let Some(connection) = cached.as_ref().filter(|connection| { if let Some(connection) = cached.as_ref().filter(|connection| {
@@ -525,9 +548,11 @@ pub(crate) async fn shared_connection(
)); ));
} }
tracing::debug!(authority, "Establishing cold shared HTTP/2 connection"); tracing::debug!(authority, "Establishing cold shared HTTP/2 connection");
let physical_budget =
reserve_physical_budget(route, reserve_native_budget).await?;
let connection = tokio::time::timeout( let connection = tokio::time::timeout(
CONNECTION_WAIT_TIMEOUT, CONNECTION_WAIT_TIMEOUT,
establish(route, reserve_native_budget), establish(route, physical_budget),
) )
.await .await
.map_err(|_| { .map_err(|_| {
@@ -555,15 +580,16 @@ pub(crate) async fn shared_batch_connection(
) )
})?; })?;
let slot = batch_connection_slot(&authority).await; let slot = batch_connection_slot(&authority).await;
let mut cached = tokio::time::timeout( let mut cached = crate::util::fetch::wait_for_download_resource(
CONNECTION_WAIT_TIMEOUT,
slot.lock(), slot.lock(),
) )
.await .await
.map_err(|_| { .map_err(|_| {
H2ConnectError::new( H2ConnectError::new(
H2ConnectFailureKind::Protocol, H2ConnectFailureKind::Capacity,
format!("timed out waiting for asset HTTP/2 connection slot {authority}"), format!(
"asset HTTP/2 connection slot remained stalled for {authority}"
),
) )
})?; })?;
if let Some(connection) = cached.as_ref().filter(|connection| { if let Some(connection) = cached.as_ref().filter(|connection| {
@@ -591,9 +617,11 @@ pub(crate) async fn shared_batch_connection(
*cached = None; *cached = None;
} }
tracing::debug!(authority, "Establishing sibling HTTP/2 asset connection"); tracing::debug!(authority, "Establishing sibling HTTP/2 asset connection");
let physical_budget =
reserve_physical_budget(route, reserve_native_budget).await?;
let connection = tokio::time::timeout( let connection = tokio::time::timeout(
CONNECTION_WAIT_TIMEOUT, CONNECTION_WAIT_TIMEOUT,
establish(route, reserve_native_budget), establish(route, physical_budget),
) )
.await .await
.map_err(|_| { .map_err(|_| {
@@ -614,10 +642,13 @@ pub(crate) async fn has_live_connection(authority: &str) -> bool {
return false; return false;
}; };
drop(connections); drop(connections);
let live = slot let Ok(cached) = slot.try_lock() else {
.lock() // This is only an eligibility probe. A busy slot means another task
.await // is establishing or refreshing the connection; do not queue this
// caller behind that work before its cancellation-aware download path.
return false;
};
cached
.as_ref() .as_ref()
.is_some_and(|connection| !connection.is_dead()); .is_some_and(|connection| !connection.is_dead())
live
} }
@@ -218,12 +218,8 @@ async fn download_range(
crate::install::DownloadItemStatus::WaitingForResource, crate::install::DownloadItemStatus::WaitingForResource,
) )
.await; .await;
let _permit = tokio::time::timeout( let _permit = super::h2_stream_budget::acquire_with_pressure(route)
Duration::from_secs(45),
super::h2_stream_budget::acquire(route),
)
.await .await
.map_err(|_| H2DownloadFailure::Connect)?
.map_err(|_| H2DownloadFailure::Connect)?; .map_err(|_| H2DownloadFailure::Connect)?;
super::h2_download::record_install_stage( super::h2_download::record_install_stage(
request, request,
@@ -104,6 +104,20 @@ pub(crate) async fn acquire(
} }
} }
pub(crate) async fn acquire_with_pressure(
route: &DownloadRoute,
) -> crate::Result<H2StreamPermit> {
crate::util::fetch::wait_for_download_resource(acquire(route))
.await
.map_err(|_| {
crate::Error::from(crate::ErrorKind::NetworkError(
"HTTP/2 stream permit remained stalled for 5 minutes"
.to_string(),
))
})?
.map_err(Into::into)
}
/// Acquires a stream for the Minecraft asset batch. Assets retain a large /// Acquires a stream for the Minecraft asset batch. Assets retain a large
/// logical worker window, but use a separate physical stream pool so they /// logical worker window, but use a separate physical stream pool so they
/// cannot consume the ordinary native content share. /// cannot consume the ordinary native content share.
@@ -134,10 +148,25 @@ pub(crate) async fn acquire_asset(
} }
} }
pub(crate) async fn acquire_asset_with_pressure(
route: &DownloadRoute,
) -> crate::Result<H2StreamPermit> {
crate::util::fetch::wait_for_download_resource(acquire_asset(route))
.await
.map_err(|_| {
crate::Error::from(crate::ErrorKind::NetworkError(
"asset HTTP/2 stream permit remained stalled for 5 minutes"
.to_string(),
))
})?
.map_err(Into::into)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::util::fetch::DownloadRouteSource; use crate::util::fetch::DownloadRouteSource;
use std::sync::atomic::{AtomicUsize, Ordering};
fn route() -> DownloadRoute { fn route() -> DownloadRoute {
DownloadRoute { DownloadRoute {
@@ -190,4 +219,40 @@ mod tests {
MAX_H2_STREAMS_PER_AUTHORITY MAX_H2_STREAMS_PER_AUTHORITY
); );
} }
#[tokio::test]
async fn contended_stream_wait_reports_pressure_and_resumes() {
let route = DownloadRoute {
url: "https://h2-pressure.example/file".to_string(),
source: DownloadRouteSource::Official,
is_mirror: false,
allow_sensitive_headers: true,
supports_range: true,
proxy: ProxyPolicy::Direct,
};
let mut held = Vec::new();
for _ in 0..MAX_H2_STREAMS_PER_AUTHORITY {
held.push(acquire(&route).await.unwrap());
}
let pressure = Arc::new(AtomicUsize::new(0));
let observed = Arc::clone(&pressure);
let release = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(35)).await;
drop(held);
});
let permit = crate::util::fetch::wait_for_resource_with_pressure(
acquire(&route),
Duration::from_millis(5),
Duration::from_millis(100),
move || {
observed.fetch_add(1, Ordering::Relaxed);
},
)
.await;
release.await.unwrap();
assert!(permit.is_ok());
assert!(pressure.load(Ordering::Relaxed) >= 1);
}
} }
+14 -1
View File
@@ -73,6 +73,19 @@ pub(crate) fn task_concurrency_limit(state: &crate::State) -> Option<usize> {
if active_engine() == DownloadEngine::XmclCompat { if active_engine() == DownloadEngine::XmclCompat {
None None
} else { } else {
Some(state.download_concurrency()) Some(native_budget::task_limit(
state.download_concurrency(),
usize::MAX,
))
}
}
pub(crate) fn single_authority_task_concurrency_limit(
state: &crate::State,
) -> Option<usize> {
if active_engine() == DownloadEngine::XmclCompat {
None
} else {
Some(native_budget::task_limit(state.download_concurrency(), 1))
} }
} }
@@ -144,6 +144,15 @@ pub(crate) fn available(route: &DownloadRoute) -> usize {
.unwrap_or(MAX_CONNECTIONS_PER_AUTHORITY) .unwrap_or(MAX_CONNECTIONS_PER_AUTHORITY)
} }
pub(crate) fn task_limit(
configured_limit: usize,
authority_count: usize,
) -> usize {
configured_limit.max(1).min(MAX_NATIVE_CONNECTIONS).min(
MAX_CONNECTIONS_PER_AUTHORITY.saturating_mul(authority_count.max(1)),
)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -174,4 +183,11 @@ mod tests {
drop(permits); drop(permits);
assert!(try_acquire(&route).is_ok()); assert!(try_acquire(&route).is_ok());
} }
#[test]
fn task_limit_matches_native_connection_capacity() {
assert_eq!(task_limit(128, 1), 8);
assert_eq!(task_limit(128, 4), 32);
assert_eq!(task_limit(4, 1), 4);
}
} }
+126 -24
View File
@@ -85,6 +85,8 @@ const MAX_REDIRECT_LOCATION_BYTES: usize = 8 * 1024;
const FILE_TRANSFER_CONNECT_TIMEOUT: time::Duration = const FILE_TRANSFER_CONNECT_TIMEOUT: time::Duration =
time::Duration::from_secs(20); time::Duration::from_secs(20);
const RESOURCE_WAIT_TIMEOUT: time::Duration = time::Duration::from_secs(45); const RESOURCE_WAIT_TIMEOUT: time::Duration = time::Duration::from_secs(45);
const RESOURCE_PRESSURE_INTERVAL: time::Duration = time::Duration::from_secs(3);
const RESOURCE_STALL_TIMEOUT: time::Duration = time::Duration::from_secs(300);
#[cfg(not(test))] #[cfg(not(test))]
const FILE_TRANSFER_READ_TIMEOUT: time::Duration = const FILE_TRANSFER_READ_TIMEOUT: time::Duration =
time::Duration::from_secs(60); time::Duration::from_secs(60);
@@ -3894,6 +3896,73 @@ struct NativeConnectionPermit<'a> {
_native: crate::util::download::native_budget::NativeBudgetPermit, _native: crate::util::download::native_budget::NativeBudgetPermit,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ResourceWaitError {
TimedOut,
}
pub(crate) async fn wait_for_resource_with_pressure<F, T, P>(
future: F,
pressure_interval: time::Duration,
stall_timeout: time::Duration,
mut on_pressure: P,
) -> Result<T, ResourceWaitError>
where
F: Future<Output = T>,
P: FnMut(),
{
let pressure_interval =
pressure_interval.max(time::Duration::from_millis(1));
let pressure_timer = tokio::time::sleep(pressure_interval);
let stall_timer = tokio::time::sleep(stall_timeout);
tokio::pin!(future, pressure_timer, stall_timer);
loop {
tokio::select! {
result = &mut future => return Ok(result),
_ = &mut stall_timer => return Err(ResourceWaitError::TimedOut),
_ = &mut pressure_timer => {
on_pressure();
pressure_timer.as_mut().reset(
tokio::time::Instant::now() + pressure_interval,
);
}
}
}
}
pub(crate) async fn wait_for_download_resource<F, T>(
future: F,
) -> Result<T, ResourceWaitError>
where
F: Future<Output = T>,
{
wait_for_resource_with_pressure(
future,
RESOURCE_PRESSURE_INTERVAL,
RESOURCE_STALL_TIMEOUT,
|| {
if let Some(state) = crate::State::get_if_initialized() {
state.record_download_queue_wait();
}
},
)
.await
}
pub(crate) async fn acquire_fetch_permit_with_pressure(
semaphore: &FetchSemaphore,
) -> crate::Result<SemaphorePermit<'_>> {
wait_for_download_resource(semaphore.0.acquire())
.await
.map_err(|_| {
crate::Error::from(ErrorKind::NetworkError(
"download permit remained stalled for 5 minutes".to_string(),
))
})?
.map_err(Into::into)
}
async fn acquire_native_connection<'a>( async fn acquire_native_connection<'a>(
route: &DownloadRoute, route: &DownloadRoute,
semaphore: &'a FetchSemaphore, semaphore: &'a FetchSemaphore,
@@ -3906,6 +3975,20 @@ async fn acquire_native_connection<'a>(
}) })
} }
async fn acquire_native_connection_with_pressure<'a>(
route: &DownloadRoute,
semaphore: &'a FetchSemaphore,
) -> crate::Result<NativeConnectionPermit<'a>> {
wait_for_download_resource(acquire_native_connection(route, semaphore))
.await
.map_err(|_| {
crate::Error::from(ErrorKind::NetworkError(
"native download resources remained stalled for 5 minutes"
.to_string(),
))
})?
}
fn try_acquire_native_connection<'a>( fn try_acquire_native_connection<'a>(
route: &DownloadRoute, route: &DownloadRoute,
semaphore: &'a FetchSemaphore, semaphore: &'a FetchSemaphore,
@@ -5703,19 +5786,11 @@ async fn download_to_path_inner(
None None
}; };
if let Some((h2_route, h2_policy)) = h2_selection { if let Some((h2_route, h2_policy)) = h2_selection {
let h2_permit =
tokio::time::timeout(RESOURCE_WAIT_TIMEOUT, semaphore.0.acquire())
.await
.map_err(|_| {
ErrorKind::NetworkError(
"timed out waiting for HTTP/2 download permit"
.to_string(),
)
})??;
let h2_started = Instant::now(); let h2_started = Instant::now();
match crate::util::download::h2_download::try_download_via_h2( match crate::util::download::h2_download::try_download_via_h2(
&request, &request,
&h2_route, &h2_route,
semaphore,
destination, destination,
&part_path, &part_path,
h2_policy, h2_policy,
@@ -5808,7 +5883,6 @@ async fn download_to_path_inner(
.await?; .await?;
} }
} }
drop(h2_permit);
} }
let mut official_integrity_retry = h2_failed_nonofficial.is_some(); let mut official_integrity_retry = h2_failed_nonofficial.is_some();
@@ -6147,8 +6221,6 @@ async fn download_to_path_inner(
} else { } else {
0 0
}; };
let mut activity = crate::State::get_if_initialized()
.map(|state| state.begin_download_connection());
record_install_download_started( record_install_download_started(
&request, &request,
route, route,
@@ -6161,25 +6233,21 @@ async fn download_to_path_inner(
DownloadItemStatus::WaitingForResource, DownloadItemStatus::WaitingForResource,
) )
.await; .await;
let permit_wait = tokio::time::timeout( let permit_wait =
RESOURCE_WAIT_TIMEOUT, acquire_native_connection_with_pressure(route, semaphore);
acquire_native_connection(route, semaphore),
);
let resource_wait_started = Instant::now(); let resource_wait_started = Instant::now();
let permit = if let Some(cancellation) = request.cancellation.as_ref() { let permit = if let Some(cancellation) =
request.cancellation.as_ref()
{
tokio::select! { tokio::select! {
_ = cancellation.cancelled() => return Err(ErrorKind::OtherError("download canceled while waiting for native resources".to_string()).into()), _ = cancellation.cancelled() => return Err(ErrorKind::OtherError("download canceled while waiting for native resources".to_string()).into()),
result = permit_wait => result, result = permit_wait => result,
} }
} else { } else {
permit_wait.await permit_wait.await
} }?;
.map_err(|_| { let mut activity = crate::State::get_if_initialized()
ErrorKind::NetworkError( .map(|state| state.begin_download_connection());
"timed out waiting for native download resources"
.to_string(),
)
})??;
tracing::debug!( tracing::debug!(
route = %sanitize_url_for_log(&route.url), route = %sanitize_url_for_log(&route.url),
resource = "native_connection_and_fetch", resource = "native_connection_and_fetch",
@@ -7135,6 +7203,40 @@ mod tests {
static H2_FALLBACK_TEST_LOCK: LazyLock<std::sync::Mutex<()>> = static H2_FALLBACK_TEST_LOCK: LazyLock<std::sync::Mutex<()>> =
LazyLock::new(|| std::sync::Mutex::new(())); LazyLock::new(|| std::sync::Mutex::new(()));
#[tokio::test]
async fn resource_wait_reports_pressure_without_losing_queue_position() {
let pressure_events = Arc::new(AtomicUsize::new(0));
let observed = Arc::clone(&pressure_events);
let result = wait_for_resource_with_pressure(
async {
tokio::time::sleep(Duration::from_millis(35)).await;
7
},
Duration::from_millis(5),
Duration::from_millis(100),
move || {
observed.fetch_add(1, Ordering::Relaxed);
},
)
.await;
assert_eq!(result, Ok(7));
assert!(pressure_events.load(Ordering::Relaxed) >= 1);
}
#[tokio::test]
async fn resource_wait_keeps_a_final_stall_guard() {
let result = wait_for_resource_with_pressure(
std::future::pending::<()>(),
Duration::from_millis(5),
Duration::from_millis(25),
|| {},
)
.await;
assert_eq!(result, Err(ResourceWaitError::TimedOut));
}
async fn spawn_range_server( async fn spawn_range_server(
data: Arc<Vec<u8>>, data: Arc<Vec<u8>>,
wrong_content_range: bool, wrong_content_range: bool,
@@ -1,4 +1,6 @@
export * from './armor-preview-types'
export * from './types' export * from './types'
export * from './use-armor-preview'
export * from './use-skin-preview-animation' export * from './use-skin-preview-animation'
export * from './use-skin-preview-controls' export * from './use-skin-preview-controls'
export * from './use-skin-preview-fit' export * from './use-skin-preview-fit'
+138
View File
@@ -4723,5 +4723,143 @@
}, },
"version.supplementary-resources.type": { "version.supplementary-resources.type": {
"defaultMessage": "Type" "defaultMessage": "Type"
},
"skin.preview.armor.equip-material": {
"defaultMessage": "{material} {slot}"
},
"skin.preview.armor.material": {
"defaultMessage": "Armor material"
},
"skin.preview.armor.material.chainmail": {
"defaultMessage": "Chainmail"
},
"skin.preview.armor.material.copper": {
"defaultMessage": "Copper"
},
"skin.preview.armor.material.diamond": {
"defaultMessage": "Diamond"
},
"skin.preview.armor.material.gold": {
"defaultMessage": "Gold"
},
"skin.preview.armor.material.iron": {
"defaultMessage": "Iron"
},
"skin.preview.armor.material.leather": {
"defaultMessage": "Leather"
},
"skin.preview.armor.material.netherite": {
"defaultMessage": "Netherite"
},
"skin.preview.armor.material.turtle": {
"defaultMessage": "Turtle Shell"
},
"skin.preview.armor.open": {
"defaultMessage": "Armor and trims"
},
"skin.preview.armor.piece": {
"defaultMessage": "Armor piece"
},
"skin.preview.armor.remove-piece": {
"defaultMessage": "Remove {slot}"
},
"skin.preview.armor.remove-trim": {
"defaultMessage": "No armor trim"
},
"skin.preview.armor.slot-state": {
"defaultMessage": "{slot}: {state}"
},
"skin.preview.armor.slot.boots": {
"defaultMessage": "Boots"
},
"skin.preview.armor.slot.chestplate": {
"defaultMessage": "Chestplate"
},
"skin.preview.armor.slot.helmet": {
"defaultMessage": "Helmet"
},
"skin.preview.armor.slot.leggings": {
"defaultMessage": "Leggings"
},
"skin.preview.armor.trim-material": {
"defaultMessage": "Trim material"
},
"skin.preview.armor.trim-material.amethyst": {
"defaultMessage": "Amethyst"
},
"skin.preview.armor.trim-material.emerald": {
"defaultMessage": "Emerald"
},
"skin.preview.armor.trim-material.lapis": {
"defaultMessage": "Lapis Lazuli"
},
"skin.preview.armor.trim-material.quartz": {
"defaultMessage": "Quartz"
},
"skin.preview.armor.trim-material.redstone": {
"defaultMessage": "Redstone"
},
"skin.preview.armor.trim-material.resin": {
"defaultMessage": "Resin Brick"
},
"skin.preview.armor.trim-pattern": {
"defaultMessage": "Trim pattern"
},
"skin.preview.armor.trim.bolt": {
"defaultMessage": "Bolt Armor Trim"
},
"skin.preview.armor.trim.coast": {
"defaultMessage": "Coast Armor Trim"
},
"skin.preview.armor.trim.dune": {
"defaultMessage": "Dune Armor Trim"
},
"skin.preview.armor.trim.eye": {
"defaultMessage": "Eye Armor Trim"
},
"skin.preview.armor.trim.flow": {
"defaultMessage": "Flow Armor Trim"
},
"skin.preview.armor.trim.host": {
"defaultMessage": "Host Armor Trim"
},
"skin.preview.armor.trim.raiser": {
"defaultMessage": "Raiser Armor Trim"
},
"skin.preview.armor.trim.rib": {
"defaultMessage": "Rib Armor Trim"
},
"skin.preview.armor.trim.sentry": {
"defaultMessage": "Sentry Armor Trim"
},
"skin.preview.armor.trim.shaper": {
"defaultMessage": "Shaper Armor Trim"
},
"skin.preview.armor.trim.silence": {
"defaultMessage": "Silence Armor Trim"
},
"skin.preview.armor.trim.snout": {
"defaultMessage": "Snout Armor Trim"
},
"skin.preview.armor.trim.spire": {
"defaultMessage": "Spire Armor Trim"
},
"skin.preview.armor.trim.tide": {
"defaultMessage": "Tide Armor Trim"
},
"skin.preview.armor.trim.vex": {
"defaultMessage": "Vex Armor Trim"
},
"skin.preview.armor.trim.ward": {
"defaultMessage": "Ward Armor Trim"
},
"skin.preview.armor.trim.wayfinder": {
"defaultMessage": "Wayfinder Armor Trim"
},
"skin.preview.armor.trim.wild": {
"defaultMessage": "Wild Armor Trim"
},
"skin.preview.armor.unequipped": {
"defaultMessage": "Unequipped"
} }
} }
+138
View File
@@ -4744,5 +4744,143 @@
}, },
"image-viewer.previous": { "image-viewer.previous": {
"defaultMessage": "上一张图像" "defaultMessage": "上一张图像"
},
"skin.preview.armor.equip-material": {
"message": "为{slot}装备{material}"
},
"skin.preview.armor.material": {
"message": "盔甲材质"
},
"skin.preview.armor.material.chainmail": {
"message": "锁链"
},
"skin.preview.armor.material.copper": {
"message": "铜"
},
"skin.preview.armor.material.diamond": {
"message": "钻石"
},
"skin.preview.armor.material.gold": {
"message": "金"
},
"skin.preview.armor.material.iron": {
"message": "铁"
},
"skin.preview.armor.material.leather": {
"message": "皮革"
},
"skin.preview.armor.material.netherite": {
"message": "下界合金"
},
"skin.preview.armor.material.turtle": {
"message": "海龟壳"
},
"skin.preview.armor.open": {
"message": "盔甲与纹饰"
},
"skin.preview.armor.piece": {
"message": "盔甲部位"
},
"skin.preview.armor.remove-piece": {
"message": "脱下{slot}"
},
"skin.preview.armor.remove-trim": {
"message": "无盔甲纹饰"
},
"skin.preview.armor.slot-state": {
"message": "{slot}:{state}"
},
"skin.preview.armor.slot.boots": {
"message": "靴子"
},
"skin.preview.armor.slot.chestplate": {
"message": "胸甲"
},
"skin.preview.armor.slot.helmet": {
"message": "头盔"
},
"skin.preview.armor.slot.leggings": {
"message": "护腿"
},
"skin.preview.armor.trim-material": {
"message": "纹饰材料"
},
"skin.preview.armor.trim-material.amethyst": {
"message": "紫水晶"
},
"skin.preview.armor.trim-material.emerald": {
"message": "绿宝石"
},
"skin.preview.armor.trim-material.lapis": {
"message": "青金石"
},
"skin.preview.armor.trim-material.quartz": {
"message": "下界石英"
},
"skin.preview.armor.trim-material.redstone": {
"message": "红石"
},
"skin.preview.armor.trim-material.resin": {
"message": "树脂砖"
},
"skin.preview.armor.trim-pattern": {
"message": "纹饰图案"
},
"skin.preview.armor.trim.bolt": {
"message": "镶铆盔甲纹饰"
},
"skin.preview.armor.trim.coast": {
"message": "海岸盔甲纹饰"
},
"skin.preview.armor.trim.dune": {
"message": "沙丘盔甲纹饰"
},
"skin.preview.armor.trim.eye": {
"message": "眼眸盔甲纹饰"
},
"skin.preview.armor.trim.flow": {
"message": "涡流盔甲纹饰"
},
"skin.preview.armor.trim.host": {
"message": "雇主盔甲纹饰"
},
"skin.preview.armor.trim.raiser": {
"message": "牧民盔甲纹饰"
},
"skin.preview.armor.trim.rib": {
"message": "肋骨盔甲纹饰"
},
"skin.preview.armor.trim.sentry": {
"message": "哨兵盔甲纹饰"
},
"skin.preview.armor.trim.shaper": {
"message": "塑造盔甲纹饰"
},
"skin.preview.armor.trim.silence": {
"message": "幽静盔甲纹饰"
},
"skin.preview.armor.trim.snout": {
"message": "猪鼻盔甲纹饰"
},
"skin.preview.armor.trim.spire": {
"message": "尖塔盔甲纹饰"
},
"skin.preview.armor.trim.tide": {
"message": "潮汐盔甲纹饰"
},
"skin.preview.armor.trim.vex": {
"message": "恼鬼盔甲纹饰"
},
"skin.preview.armor.trim.ward": {
"message": "监守盔甲纹饰"
},
"skin.preview.armor.trim.wayfinder": {
"message": "向导盔甲纹饰"
},
"skin.preview.armor.trim.wild": {
"message": "荒野盔甲纹饰"
},
"skin.preview.armor.unequipped": {
"message": "未装备"
} }
} }