feat: xbox360+ps3+java->windows64 world conversion (#145)

Co-authored-by: str1k3r <[email protected]>
This commit is contained in:
/home/neo
2026-08-07 19:01:24 +03:00
committed by GitHub
co-authored by str1k3r
parent cb684177fe
commit d536a6590b
52 changed files with 8530 additions and 476 deletions
+60 -1
View File
@@ -85,7 +85,7 @@ jobs:
with: with:
name: ${{ matrix.artifact-name }} name: ${{ matrix.artifact-name }}
path: | path: |
src-tauri/target/*/release/bundle/**/* src-tauri/target/**/release/bundle/**/*
build-flatpak: build-flatpak:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -108,3 +108,62 @@ jobs:
with: with:
name: flatpak name: flatpak
path: Emerald.Legacy.Launcher.flatpak path: Emerald.Legacy.Launcher.flatpak
build-appimage:
runs-on: ubuntu-latest
container:
image: archlinux:latest
steps:
- name: Bootstrap keyring and git
run: |
pacman -Syu --noconfirm archlinux-keyring
pacman -S --noconfirm --needed git
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/cache@v4
with:
path: |
src-tauri/target
node_modules
/var/cache/pacman/pkg
key: arch-${{ hashFiles('src-tauri/Cargo.lock', 'pnpm-lock.yaml') }}
restore-keys: arch-
- name: Install build dependencies
run: |
pacman -S --noconfirm --needed \
base-devel wget zsync nodejs patchelf \
cargo cargo-tauri \
gtk3 libayatana-appindicator webkit2gtk-4.1 glib-networking librsvg \
xorg-server-xvfb
- name: setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Build deb with tauri
run: |
pnpm install
cargo-tauri build --bundles deb --config '{"bundle":{"createUpdaterArtifacts":false},"build":{"beforeBuildCommand":"pnpm build"}}'
- name: Extract deb into /usr
run: |
cd src-tauri/target/release/bundle/deb
ar x ./*.deb
tar -xf data.tar.*
cp -a usr/. /usr/
- name: Build AppImage
run: |
cd pkg/AppImage
chmod +x ./AppImage.sh
./AppImage.sh
- uses: actions/upload-artifact@v4
with:
name: appimage
path: pkg/AppImage/dist/*.AppImage
-1
View File
@@ -3,7 +3,6 @@ name: Nix
on: on:
push: push:
branches: [main] branches: [main]
pull_request:
workflow_dispatch: workflow_dispatch:
permissions: permissions:
+67 -2
View File
@@ -80,8 +80,67 @@ jobs:
uploadUpdaterJson: true uploadUpdaterJson: true
args: ${{ matrix.args }} args: ${{ matrix.args }}
build-appimage:
runs-on: ubuntu-latest
container:
image: archlinux:latest
steps:
- name: Bootstrap keyring and git
run: |
pacman -Syu --noconfirm archlinux-keyring
pacman -S --noconfirm --needed git
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/cache@v4
with:
path: |
src-tauri/target
node_modules
/var/cache/pacman/pkg
key: arch-${{ hashFiles('src-tauri/Cargo.lock', 'pnpm-lock.yaml') }}
restore-keys: arch-
- name: Install build dependencies
run: |
pacman -S --noconfirm --needed \
base-devel wget zsync nodejs patchelf \
cargo cargo-tauri \
gtk3 libayatana-appindicator webkit2gtk-4.1 glib-networking librsvg \
xorg-server-xvfb
- name: setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Build deb with tauri
run: |
pnpm install
cargo-tauri build --bundles deb --config '{"bundle":{"createUpdaterArtifacts":false},"build":{"beforeBuildCommand":"pnpm build"}}'
- name: Extract deb into /usr
run: |
cd src-tauri/target/release/bundle/deb
ar x ./*.deb
tar -xf data.tar.*
cp -a usr/. /usr/
- name: Build AppImage
run: |
cd pkg/AppImage
chmod +x ./AppImage.sh
./AppImage.sh
- uses: actions/upload-artifact@v4
with:
name: appimage
path: pkg/AppImage/dist/*.AppImage* #neo: *.AppImage* because .zsync as well
publish-flatpak: publish-flatpak:
needs: get-version needs: [get-version, build-appimage]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
@@ -99,11 +158,17 @@ jobs:
bundle: emerald.flatpak bundle: emerald.flatpak
manifest-path: flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.yml manifest-path: flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.yml
cache-key: flatpak-builder-${{ github.sha }} cache-key: flatpak-builder-${{ github.sha }}
- uses: actions/download-artifact@v4
with:
name: appimage
path: appimage
- uses: softprops/action-gh-release@v2 - uses: softprops/action-gh-release@v2
with: with:
body: ${{ needs.get-version.outputs.body }} body: ${{ needs.get-version.outputs.body }}
tag_name: v${{ needs.get-version.outputs.version }} tag_name: v${{ needs.get-version.outputs.version }}
files: emerald.flatpak files: |
emerald.flatpak
appimage/*.AppImage*
draft: true draft: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6 -2
View File
@@ -57,16 +57,19 @@ LCE Emerald Launcher is the easiest way to play Minecraft Legacy Console Edition
| Feature | Description | | Feature | Description |
|---------|-------------| |---------|-------------|
| **Automated Setup** | One-click installation for neoLegacy, Revelations, 360 Revived, and Hellish Ends | | **Automated Setup** | One-click installation for neoLegacy, Revelations, 360 Revived, Hellish Ends and others |
| **Cross-Platform** | Native support for Windows, macOS (Intel & Apple Silicon), and Linux (Steam Deck is also supported!) | | **Cross-Platform** | Native support for Windows, macOS (Intel & Apple Silicon), and Linux (Steam Deck is also supported!) |
| **Lightweight** | Very light RAM usage thanks to Rust backend and Tauri framework | | **Lightweight** | Very light RAM usage thanks to Rust backend and Tauri framework |
| **Easy Configuration** | Built-in settings for username, game parameters, and profiles | | **Easy Configuration** | Built-in settings for username, game parameters, and profiles |
| **Skin Viewer** | Interactive skin preview using Three.js with layer support | | **Skin Viewer** | Interactive skin preview using Three.js with layer support |
| **Custom Skins** | Import and manage your own skins with local storage | | **Custom Skins** | Import and manage your own skins and capes |
| **Controller Support** | Full gamepad navigation support (keyboard support included) | | **Controller Support** | Full gamepad navigation support (keyboard support included) |
| **Discord Rich Presence** | Show your current activity and game status on Discord | | **Discord Rich Presence** | Show your current activity and game status on Discord |
| **Workshop** | Community content like DLCs, Textures, Skins and more | | **Workshop** | Community content like DLCs, Textures, Skins and more |
| **Free Multiplayer** | Powered by LCEOnline, Emerald provides a free multiplayer service so you can play with anyone without port forwarding! | | **Free Multiplayer** | Powered by LCEOnline, Emerald provides a free multiplayer service so you can play with anyone without port forwarding! |
| **Developer Tools** | Create and edit 4J's propriatery file formats with ease! |
| **World conversion** | Import `.ms` files, Java worlds or even an Xbox 360/PS3 world! |
| **Playtime counting** | Count your playtime and see your most active days! |
--- ---
@@ -235,6 +238,7 @@ sudo apt install --reinstall libwebkit2gtk-4.1-0
- **4J Studios & Mojang** - Original creators of Legacy Console Edition - **4J Studios & Mojang** - Original creators of Legacy Console Edition
- **The LCE Community** - Research and foundations for LCE on PC - **The LCE Community** - Research and foundations for LCE on PC
- **Str1k3r** - Original creator of LCEOnline - **Str1k3r** - Original creator of LCEOnline
- **DTention** - Original creator of [LCE-Save-Converter](https://github.com/dtentiion/LCE-Save-Converter)
--- ---
@@ -12,7 +12,7 @@
<url type="homepage">https://github.com</url> <url type="homepage">https://github.com</url>
<developer_name>Emerald Team</developer_name> <developer_name>Emerald Team</developer_name>
<releases> <releases>
<release version="1.5.1" date="2026-03-19"> <release version="1.6.0" date="2026-03-19">
<description> <description>
<p>Latest stable release of the Emerald Legacy Launcher.</p> <p>Latest stable release of the Emerald Legacy Launcher.</p>
</description> </description>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "emerald-legacy-launcher", "name": "emerald-legacy-launcher",
"private": true, "private": true,
"version": "1.5.1", "version": "1.6.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
#neo: credits: https://github.com/cjonas1999/OverBind/blob/master/pkg/AppImage/AppImage.sh
set -eu
ARCH="$(uname -m)"
SHARUN_REPO="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages"
_commits_json=$(mktemp)
wget -qO "$_commits_json" \
"https://api.github.com/repos/pkgforge-dev/Anylinux-AppImages/commits?path=useful-tools/&per_page=100"
STABLE_SHA=$(node -e "
const data = JSON.parse(require('fs').readFileSync('$_commits_json', 'utf8'));
const now = Date.now(), h24 = 864e5;
for (let i = 0; i < data.length; i++) {
const t = new Date(data[i].commit.committer.date).getTime();
if (now - t < h24) continue;
const prev = data[i - 1];
if (!prev || new Date(prev.commit.committer.date).getTime() - t >= h24) {
process.stdout.write(data[i].sha);
process.exit(0);
}
}
process.stderr.write('No settled commit found in last 100 commits\n');
process.exit(1);
")
rm -f "$_commits_json"
echo "Pinning sharun scripts to settled commit $STABLE_SHA"
SHARUN="$SHARUN_REPO/$STABLE_SHA/useful-tools/quick-sharun.sh"
DEBLOATED_PKGS="$SHARUN_REPO/$STABLE_SHA/useful-tools/get-debloated-pkgs.sh"
#export UPINFO="gh-releases-zsync|${GITHUB_REPOSITORY%/*}|${GITHUB_REPOSITORY#*/}|latest|*$ARCH.AppImage.zsync"
export OUTNAME=Emerald-Legacy-Launcher-anylinux-"$ARCH".AppImage
export DESKTOP="/usr/share/applications/LCE Emerald Launcher.desktop"
export ICON=/usr/share/icons/hicolor/256x256@2/apps/emerald-legacy-launcher.png
export DEPLOY_OPENGL=1
rm -rf AppDir dist appinfo
wget --retry-connrefused --tries=30 "$DEBLOATED_PKGS" -O ./get-debloated-pkgs
wget --retry-connrefused --tries=30 "$SHARUN" -O ./quick-sharun
chmod +x ./quick-sharun ./get-debloated-pkgs
./get-debloated-pkgs --add-common --prefer-nano
./quick-sharun \
/usr/bin/emerald-legacy-launcher \
/usr/lib/libayatana-appindicator*.so*
mkdir -p "AppDir/usr/lib"
cp -r "/usr/lib/LCE Emerald Launcher" "AppDir/usr/lib/"
./quick-sharun --make-appimage
mkdir -p ./dist
mv -v ./*.AppImage* ./dist
echo "All Done!"
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

+9 -9
View File
@@ -1043,7 +1043,7 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]] [[package]]
name = "emerald-legacy-launcher" name = "emerald-legacy-launcher"
version = "1.5.1" version = "1.6.0"
dependencies = [ dependencies = [
"base64 0.21.7", "base64 0.21.7",
"byteorder", "byteorder",
@@ -2027,7 +2027,7 @@ dependencies = [
"libc", "libc",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2 0.6.3", "socket2 0.5.10",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -4708,9 +4708,9 @@ dependencies = [
[[package]] [[package]]
name = "tao-macros" name = "tao-macros"
version = "0.1.3" version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -5402,18 +5402,18 @@ dependencies = [
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.1.2+spec-1.1.0" version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [ dependencies = [
"winnow 1.0.1", "winnow 1.0.1",
] ]
[[package]] [[package]]
name = "toml_writer" name = "toml_writer"
version = "1.1.1+spec-1.1.0" version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]] [[package]]
name = "tower" name = "tower"
@@ -6098,7 +6098,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
+8 -3
View File
@@ -1,9 +1,9 @@
[package] [package]
name = "emerald-legacy-launcher" name = "emerald-legacy-launcher"
version = "1.5.1" version = "1.6.0"
description = "A FOSS, cross-platform launcher for Minecraft Legacy Edition" description = "A FOSS, cross-platform launcher for Minecraft Legacy Edition"
authors = ["Emerald Team"] authors = ["Emerald Team"]
edition = "2021" edition = "2024"
[lib] [lib]
name = "emerald_lib" name = "emerald_lib"
@@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [ "tray-icon", "image-png"] } tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -43,3 +43,8 @@ tauri-plugin-single-instance = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
#neo: enable portable appimages. revert back to crates.io once the PR is merged
#[patch.crates-io]
#tauri = { git = "https://github.com/tauri-apps/tauri", branch = "feat/truly-portable-appimage" }
#end neo
File diff suppressed because it is too large Load Diff
+103 -83
View File
@@ -1,8 +1,14 @@
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Stdio;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter, Manager, State}; use tauri::{AppHandle, Emitter, Manager, State};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use tokio::io::AsyncRead;
use tokio::io::AsyncReadExt;
use tokio::sync::Mutex;
use crate::commands::runners; use crate::commands::runners;
use crate::config; use crate::config;
use crate::types::AppConfig; use crate::types::AppConfig;
@@ -24,6 +30,7 @@ pub async fn launch_game(
mut servers: Vec<McServer>, mut servers: Vec<McServer>,
extra_args: Vec<String>, extra_args: Vec<String>,
) -> Result<(), String> { ) -> Result<(), String> {
state.manual_stop.store(false, Ordering::SeqCst);
perform_instance_sync(&app, &instance_id).await?; perform_instance_sync(&app, &instance_id).await?;
let working_dir = util::get_instance_working_dir(&app, &instance_id); let working_dir = util::get_instance_working_dir(&app, &instance_id);
let config_val = config::load_config_raw(app.clone()); let config_val = config::load_config_raw(app.clone());
@@ -117,42 +124,18 @@ pub async fn launch_game(
apply_launch_env_vars(&mut cmd, &config_val); apply_launch_env_vars(&mut cmd, &config_val);
cmd.current_dir(&working_dir); cmd.current_dir(&working_dir);
cmd.env("WINEDEBUG", "+debugstr");
let playtime_start = std::time::Instant::now(); let playtime_start = std::time::Instant::now();
let child = cmd.spawn().map_err(|e| e.to_string())?; let Some(result) = run_game_and_capture(&state, cmd).await? else {
{
let mut lock = state.child.lock().await;
*lock = Some(child);
}
let status = loop {
{
let mut lock = state.child.lock().await;
if let Some(ref mut c) = *lock {
if let Some(s) = c.try_wait().map_err(|e| e.to_string())? {
break s;
}
} else {
return Ok(()); return Ok(());
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}; };
{
let mut lock = state.child.lock().await;
*lock = None;
}
let duration = playtime_start.elapsed(); let duration = playtime_start.elapsed();
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let start = now - duration.as_secs(); let start = now - duration.as_secs();
playtime::record_session(&app, &instance_id, start, now); playtime::record_session(&app, &instance_id, start, now);
return if status.success() || status.code() == Some(253) || status.code() == Some(96) { return handle_game_exit(&app, &state, result);
Ok(())
} else {
Err(format!("Game exited with status: {}", status))
};
} }
} }
Err("No Linux runner selected in settings.".into()) Err("No Linux runner selected in settings.".into())
@@ -207,7 +190,7 @@ pub async fn launch_game(
apply_launch_env_vars(&mut cmd, &config_val); apply_launch_env_vars(&mut cmd, &config_val);
cmd.current_dir(&working_dir); cmd.current_dir(&working_dir);
cmd.env("WINEPREFIX", &prefix_dir); cmd.env("WINEPREFIX", &prefix_dir);
cmd.env("WINEDEBUG", "-all"); cmd.env("WINEDEBUG", "+debugstr");
let perf_boost = config_val.apple_silicon_performance_boost.unwrap_or(false); let perf_boost = config_val.apple_silicon_performance_boost.unwrap_or(false);
if perf_boost { if perf_boost {
#[cfg(target_arch = "aarch64")] #[cfg(target_arch = "aarch64")]
@@ -233,46 +216,19 @@ pub async fn launch_game(
std::env::var("PATH").unwrap_or_default() std::env::var("PATH").unwrap_or_default()
), ),
); );
cmd.stdin(std::process::Stdio::null()) cmd.stdin(std::process::Stdio::null());
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let playtime_start = std::time::Instant::now(); let playtime_start = std::time::Instant::now();
let child = cmd.spawn().map_err(|e| e.to_string())?; let Some(result) = run_game_and_capture(&state, cmd).await? else {
{
let mut lock = state.child.lock().await;
*lock = Some(child);
}
let status = loop {
{
let mut lock = state.child.lock().await;
if let Some(ref mut c) = *lock {
if let Some(s) = c.try_wait().map_err(|e| e.to_string())? {
break s;
}
} else {
return Ok(()); return Ok(());
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}; };
{
let mut lock = state.child.lock().await;
*lock = None;
}
let duration = playtime_start.elapsed(); let duration = playtime_start.elapsed();
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let start = now - duration.as_secs(); let start = now - duration.as_secs();
playtime::record_session(&app, &instance_id, start, now); playtime::record_session(&app, &instance_id, start, now);
return if status.success() || status.code() == Some(253) || status.code() == Some(96) { return handle_game_exit(&app, &state, result);
Ok(())
} else {
Err(format!("Game exited with status: {}", status))
};
} }
#[cfg(all(not(target_os = "macos"), not(target_os = "linux")))] #[cfg(all(not(target_os = "macos"), not(target_os = "linux")))]
@@ -289,37 +245,14 @@ pub async fn launch_game(
apply_launch_env_vars(&mut cmd, &config_val); apply_launch_env_vars(&mut cmd, &config_val);
cmd.current_dir(&working_dir); cmd.current_dir(&working_dir);
let playtime_start = std::time::Instant::now(); let playtime_start = std::time::Instant::now();
let child = cmd.spawn().map_err(|e| e.to_string())?; let Some(result) = run_game_and_capture(&state, cmd).await? else {
{
let mut lock = state.child.lock().await;
*lock = Some(child);
}
let status = loop {
{
let mut lock = state.child.lock().await;
if let Some(ref mut c) = *lock {
if let Some(s) = c.try_wait().map_err(|e| e.to_string())? {
break s;
}
} else {
return Ok(()); return Ok(());
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}; };
{
let mut lock = state.child.lock().await;
*lock = None;
}
let duration = playtime_start.elapsed(); let duration = playtime_start.elapsed();
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let start = now - duration.as_secs(); let start = now - duration.as_secs();
playtime::record_session(&app, &instance_id, start, now); playtime::record_session(&app, &instance_id, start, now);
return if status.success() || status.code() == Some(253) || status.code() == Some(96) { return handle_game_exit(&app, &state, result);
Ok(())
} else {
Err(format!("Game exited with status: {}", status))
};
} }
} }
} }
@@ -330,6 +263,7 @@ pub async fn stop_game(
instance_id: String, instance_id: String,
state: State<'_, GameState>, state: State<'_, GameState>,
) -> Result<(), String> { ) -> Result<(), String> {
state.manual_stop.store(true, Ordering::SeqCst);
let mut lock = state.child.lock().await; let mut lock = state.child.lock().await;
if let Some(mut child) = lock.take() { if let Some(mut child) = lock.take() {
#[cfg(unix)] #[cfg(unix)]
@@ -631,6 +565,92 @@ fn apply_launch_env_vars(cmd: &mut tokio::process::Command, config: &AppConfig)
} }
} }
const MAX_LOG_BYTES: usize = 1024 * 1024;
struct GameRunResult {
log: String,
}
fn spawn_log_reader<R>(mut reader: R, log: Arc<Mutex<Vec<u8>>>) -> tokio::task::JoinHandle<()> where R: AsyncRead + Unpin + Send + 'static, {
tokio::spawn(async move {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let mut guard = log.lock().await;
guard.extend_from_slice(&buf[..n]);
if guard.len() > MAX_LOG_BYTES {
let remove = guard.len() - MAX_LOG_BYTES;
guard.drain(..remove);
}
}
}
}
})
}
async fn run_game_and_capture(
state: &State<'_, GameState>,
mut cmd: tokio::process::Command,
) -> Result<Option<GameRunResult>, String> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| e.to_string())?;
let log = Arc::new(Mutex::new(Vec::new()));
let mut handles = Vec::new();
if let Some(stdout) = child.stdout.take() {
handles.push(spawn_log_reader(stdout, log.clone()));
}
if let Some(stderr) = child.stderr.take() {
handles.push(spawn_log_reader(stderr, log.clone()));
}
{
let mut lock = state.child.lock().await;
*lock = Some(child);
}
loop {
{
let mut lock = state.child.lock().await;
if let Some(ref mut c) = *lock {
if c.try_wait().map_err(|e| e.to_string())?.is_some() {
break;
}
} else {
return Ok(None);
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
{
let mut lock = state.child.lock().await;
*lock = None;
}
for handle in handles {
let _ = handle.await;
}
let bytes = log.lock().await.clone();
let log_str = String::from_utf8_lossy(&bytes).to_string();
Ok(Some(GameRunResult { log: log_str }))
}
fn game_exited_ok(log: &str) -> bool {
log.lines()
.rev()
.take(3)
.any(|line| line.contains("AppPolicyGetProcessTerminationMethod"))
}
fn handle_game_exit(
app: &AppHandle,
state: &State<'_, GameState>,
result: GameRunResult,
) -> Result<(), String> {
if state.manual_stop.swap(false, Ordering::SeqCst) || game_exited_ok(&result.log) {
return Ok(());
}
let _ = app.emit("game-log", result.log);
Err("The game exited unexpectedly. Check the crash log for details.".into())
}
async fn perform_instance_sync(app: &AppHandle, instance_id: &str) -> Result<(), String> { async fn perform_instance_sync(app: &AppHandle, instance_id: &str) -> Result<(), String> {
let target_dir = util::get_instance_working_dir(app, instance_id); let target_dir = util::get_instance_working_dir(app, instance_id);
if !target_dir.exists() { if !target_dir.exists() {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
use super::nbt::{self, NbtCompound, NbtValue};
pub struct SpawnPoint {
pub x: i32,
pub y: i32,
pub z: i32,
}
pub fn read_spawn(root: &NbtCompound) -> SpawnPoint {
let data = match root.compound("Data") {
Some(d) => d,
None => {
return SpawnPoint { x: 0, y: 64, z: 0 };
}
};
if let (Some(sx), Some(sz)) = (data.int("SpawnX"), data.int("SpawnZ")) {
return SpawnPoint {
x: sx,
y: data.int("SpawnY").unwrap_or(64),
z: sz,
};
}
if let Some(spawn_comp) = data.compound("spawn") {
if let Some(pos) = spawn_comp.int_array("pos") {
if pos.len() >= 3 {
return SpawnPoint {
x: pos[0],
y: pos[1],
z: pos[2],
};
}
}
}
SpawnPoint { x: 0, y: 64, z: 0 }
}
pub fn convert_java_to_lce(
java_root: &NbtCompound,
spawn_chunk_x: i32,
spawn_chunk_z: i32,
xz_size: i32,
flat_world: bool,
override_spawn_y: Option<i32>,
) -> Vec<u8> {
let java_data = java_root
.compound("Data")
.expect("Java level.dat missing 'Data' compound tag");
let data_version = java_data
.int("DataVersion")
.or_else(|| java_root.int("DataVersion"))
.unwrap_or(0);
let is_modern_world = data_version >= 1519;
let hell_scale: i32 = 3;
let spawn = read_spawn(java_root);
let spawn_x = spawn.x;
let spawn_y = if let Some(oy) = override_spawn_y {
oy.clamp(1, 127)
} else if is_modern_world {
64
} else {
spawn.y.clamp(1, 127)
};
let spawn_z = spawn.z;
let new_spawn_x = spawn_x - (spawn_chunk_x * 16);
let new_spawn_z = spawn_z - (spawn_chunk_z * 16);
let safe_generator_name = if flat_world {
"flat".to_string()
} else if is_modern_world {
"default".to_string()
} else {
let name = get_string(java_data, "generatorName", "default");
match name.as_str() {
"flat" | "superflat" => "flat".to_string(),
"largeBiomes" => "largeBiomes".to_string(),
_ => "default".to_string(),
}
};
let safe_generator_version = if flat_world {
0
} else if is_modern_world {
1
} else {
let version = get_int(java_data, "generatorVersion");
if safe_generator_name == "default" {
1
} else {
version
}
};
let safe_generator_options = if flat_world {
get_string(java_data, "generatorOptions", "2;7,2x3,2;1;")
} else if is_modern_world {
"".to_string()
} else {
get_string(java_data, "generatorOptions", "")
};
let mut lce_data = NbtCompound::new("Data");
lce_data.insert("RandomSeed", NbtValue::Long(get_long(java_data, "RandomSeed")));
lce_data.insert("generatorName", NbtValue::String(safe_generator_name));
lce_data.insert("generatorVersion", NbtValue::Int(safe_generator_version));
lce_data.insert("generatorOptions", NbtValue::String(safe_generator_options));
lce_data.insert("GameType", NbtValue::Int(get_int(java_data, "GameType")));
lce_data.insert("MapFeatures", NbtValue::Byte(get_bool(java_data, "MapFeatures", true)));
lce_data.insert("SpawnX", NbtValue::Int(new_spawn_x));
lce_data.insert("SpawnY", NbtValue::Int(spawn_y));
lce_data.insert("SpawnZ", NbtValue::Int(new_spawn_z));
lce_data.insert("Time", NbtValue::Long(get_long(java_data, "Time")));
lce_data.insert("DayTime", NbtValue::Long(get_long(java_data, "DayTime")));
lce_data.insert("SizeOnDisk", NbtValue::Long(0));
lce_data.insert("LastPlayed", NbtValue::Long(unix_timestamp_millis()));
lce_data.insert(
"LevelName",
NbtValue::String(get_string(java_data, "LevelName", "Converted World")),
);
lce_data.insert("version", NbtValue::Int(19133));
lce_data.insert("rainTime", NbtValue::Int(get_int(java_data, "rainTime")));
lce_data.insert("raining", NbtValue::Byte(get_bool(java_data, "raining", false)));
lce_data.insert("thunderTime", NbtValue::Int(get_int(java_data, "thunderTime")));
lce_data.insert(
"thundering",
NbtValue::Byte(get_bool(java_data, "thundering", false)),
);
lce_data.insert(
"hardcore",
NbtValue::Byte(get_bool(java_data, "hardcore", false)),
);
lce_data.insert(
"allowCommands",
NbtValue::Byte(get_bool(java_data, "allowCommands", false)),
);
lce_data.insert(
"initialized",
NbtValue::Byte(get_bool(java_data, "initialized", true)),
);
lce_data.insert("newSeaLevel", NbtValue::Byte(1));
lce_data.insert(
"hasBeenInCreative",
NbtValue::Byte(get_bool(java_data, "hasBeenInCreative", false)),
);
lce_data.insert(
"spawnBonusChest",
NbtValue::Byte(get_bool(java_data, "spawnBonusChest", false)),
);
lce_data.insert("hasStronghold", NbtValue::Byte(0));
lce_data.insert("StrongholdX", NbtValue::Int(0));
lce_data.insert("StrongholdY", NbtValue::Int(0));
lce_data.insert("StrongholdZ", NbtValue::Int(0));
lce_data.insert("hasStrongholdEndPortal", NbtValue::Byte(0));
lce_data.insert("StrongholdEndPortalX", NbtValue::Int(0));
lce_data.insert("StrongholdEndPortalZ", NbtValue::Int(0));
lce_data.insert("XZSize", NbtValue::Int(xz_size));
lce_data.insert("HellScale", NbtValue::Int(hell_scale));
if !is_modern_world {
if let Some(game_rules) = java_data.get("GameRules") {
lce_data.insert("GameRules", game_rules.clone());
}
}
let mut root = NbtCompound::new("");
root.insert("Data", NbtValue::Compound(lce_data));
nbt::write_nbt(&root)
}
pub fn convert_lce_to_java(
lce_root: &NbtCompound,
override_spawn_x: Option<i32>,
override_spawn_y: Option<i32>,
override_spawn_z: Option<i32>,
embedded_player: Option<&NbtCompound>,
) -> Vec<u8> {
let lce_data = lce_root.compound("Data").unwrap_or(lce_root);
let mut java_data = lce_data.clone();
let lce_only_fields = [
"newSeaLevel",
"hasBeenInCreative",
"spawnBonusChest",
"XZSize",
"xzSize",
"HellScale",
"hellScale",
"hasStronghold",
"StrongholdX",
"StrongholdY",
"StrongholdZ",
"hasStrongholdEndPortal",
"StrongholdEndPortalX",
"StrongholdEndPortalZ",
"xStronghold",
"yStronghold",
"zStronghold",
"hasStrongholdEP",
"xStrongholdEP",
"zStrongholdEP",
];
for field in lce_only_fields {
java_data.remove(field);
}
if let Some(sx) = override_spawn_x {
java_data.insert("SpawnX", NbtValue::Int(sx));
}
if let Some(sy) = override_spawn_y {
java_data.insert("SpawnY", NbtValue::Int(sy));
}
if let Some(sz) = override_spawn_z {
java_data.insert("SpawnZ", NbtValue::Int(sz));
}
java_data.insert("version", NbtValue::Int(19133));
java_data.insert("DataVersion", NbtValue::Int(1343));
let mut version_compound = NbtCompound::new("Version");
version_compound.insert("Id", NbtValue::Int(1343));
version_compound.insert("Name", NbtValue::String("1.12.2".to_string()));
version_compound.insert("Snapshot", NbtValue::Byte(0));
java_data.insert("Version", NbtValue::Compound(version_compound));
if let Some(player) = embedded_player {
let mut player_tag = player.clone();
player_tag.name = "Player".to_string();
java_data.insert("Player", NbtValue::Compound(player_tag));
}
java_data.insert("LastPlayed", NbtValue::Long(unix_timestamp_millis()));
let mut java_root = NbtCompound::new("");
let mut data_tag = NbtCompound::new("Data");
for (name, value) in &java_data.tags {
data_tag.insert(name, value.clone());
}
java_root.insert("Data", NbtValue::Compound(data_tag));
nbt::write_gzip_nbt(&java_root)
}
fn get_long(compound: &NbtCompound, name: &str) -> i64 {
compound.long(name).unwrap_or(0)
}
fn get_int(compound: &NbtCompound, name: &str) -> i32 {
compound.int(name).unwrap_or(0)
}
fn get_string(compound: &NbtCompound, name: &str, default: &str) -> String {
compound.string(name).unwrap_or(default).to_string()
}
fn get_bool(compound: &NbtCompound, name: &str, default: bool) -> i8 {
compound.byte(name).unwrap_or(if default { 1 } else { 0 })
}
fn unix_timestamp_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
+404
View File
@@ -0,0 +1,404 @@
pub mod block_mapping;
pub mod chunk;
pub mod payload;
pub mod level_dat;
pub mod nbt;
pub mod region;
use std::fs;
use std::path::Path;
use tauri::AppHandle;
use std::collections::HashMap;
use region::{JavaWorldReader, JavaRegionFileWriter, LceRegionFile, SaveDataContainer};
use chunk::{convert_chunk, build_modern_anvil_level};
use level_dat::{read_spawn, convert_java_to_lce, convert_lce_to_java};
const DEFAULT_XZ_SIZE: i32 = 54;
fn floor_div(a: i32, b: i32) -> i32 {
let q = a / b;
let r = a % b;
if r != 0 && ((r < 0) != (b < 0)) {
q - 1
} else {
q
}
}
fn estimate_safe_spawn_y(
reader: &mut JavaWorldReader,
spawn_x: i32,
source_spawn_y: i32,
spawn_z: i32,
spawn_chunk_x: i32,
spawn_chunk_z: i32,
) -> Option<i32> {
let region_x = floor_div(spawn_chunk_x, 32);
let region_z = floor_div(spawn_chunk_z, 32);
let region_path = reader
.get_region_files("")
.into_iter()
.find(|(rx, rz, _)| *rx == region_x && *rz == region_z)
.map(|(_, _, path)| path)?;
let local_chunk_x = ((spawn_chunk_x % 32) + 32) % 32;
let local_chunk_z = ((spawn_chunk_z % 32) + 32) % 32;
let root = match reader.read_chunk_nbt(&region_path, local_chunk_x, local_chunk_z) {
Ok(Some(r)) => r,
_ => return None,
};
let level = root.compound("Level").unwrap_or(&root);
let lx = ((spawn_x % 16) + 16) % 16;
let lz = ((spawn_z % 16) + 16) % 16;
let idx2d = (lx + lz * 16) as usize;
if let Some(hm) = level.byte_array("HeightMap") {
if hm.len() >= 256 {
let height = hm[idx2d];
if height > 0 {
return Some((height as i32 + 1).clamp(1, 127));
}
}
}
if let Some(hm) = level.int_array("HeightMap") {
if hm.len() >= 256 {
let height = hm[idx2d];
if height > 0 {
return Some((height + 1).clamp(1, 127));
}
}
}
if let Some(blocks) = level.byte_array("Blocks") {
if blocks.len() >= 32768 {
for y in (1..=127).rev() {
let flat_index = (y * 256 + lz * 16 + lx) as usize;
if blocks[flat_index] != 0 {
return Some((y + 1).clamp(1, 127));
}
}
}
}
if let Some(sections) = level.list("Sections").or_else(|| level.list("sections")) {
let mut max_y = -1;
for tag in sections {
if let nbt::NbtValue::Compound(section) = tag {
let section_y = match chunk::read_section_y(section) {
Some(y) if (0..=7).contains(&y) => y,
_ => continue,
};
if let Some(section_blocks) = section.byte_array("Blocks") {
if section_blocks.len() >= 4096 {
for y in (0..=15).rev() {
let index = (lx + lz * 16 + y * 256) as usize;
if section_blocks[index] != 0 {
let global_y = section_y * 16 + y;
if global_y > max_y {
max_y = global_y;
}
break;
}
}
}
}
}
}
if max_y >= 0 {
return Some((max_y + 1).clamp(1, 127));
}
}
Some(source_spawn_y.clamp(1, 127))
}
fn convert_dimension(
reader: &mut JavaWorldReader,
container: &mut SaveDataContainer,
dimension: &str,
half_size: i32,
offset_chunk_x: i32,
offset_chunk_z: i32,
global_section_shift: &mut Option<i32>,
errors: &mut Vec<String>,
) -> usize {
let region_files = reader.get_region_files(dimension);
let region_lookup: HashMap<(i32, i32), String> = region_files
.into_iter()
.map(|(rx, rz, path)| ((rx, rz), path))
.collect();
let lce_prefix = match dimension {
"DIM-1" => "DIM-1",
"DIM1" => "DIM1/",
_ => "",
};
let mut lce_regions: HashMap<(i32, i32), LceRegionFile> = HashMap::new();
let mut converted = 0usize;
for lcx in -half_size..half_size {
for lcz in -half_size..half_size {
let jx = lcx + offset_chunk_x;
let jz = lcz + offset_chunk_z;
let jrx = floor_div(jx, 32);
let jrz = floor_div(jz, 32);
let region_path = match region_lookup.get(&(jrx, jrz)) {
Some(p) => p,
None => continue,
};
let local_x = ((jx % 32) + 32) % 32;
let local_z = ((jz % 32) + 32) % 32;
let root = match reader.read_chunk_nbt(region_path, local_x, local_z) {
Ok(Some(r)) => r,
Ok(None) => continue,
Err(e) => {
errors.push(format!("{} ({},{}): {}", dimension, lcx, lcz, e));
continue;
}
};
let payload = convert_chunk(&root, lcx, lcz, false, global_section_shift);
if payload.is_empty() {
continue;
}
let lrx = floor_div(lcx, 32);
let lrz = floor_div(lcz, 32);
let lce_local_x = ((lcx % 32) + 32) % 32;
let lce_local_z = ((lcz % 32) + 32) % 32;
let region = lce_regions.entry((lrx, lrz)).or_insert_with(|| {
LceRegionFile::new(&format!("{}r.{}.{}.mcr", lce_prefix, lrx, lrz))
});
region.write_chunk(lce_local_x, lce_local_z, payload);
converted += 1;
}
}
for (_, region) in lce_regions.iter_mut() {
region.write_to_container(container);
}
converted
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn java_to_lce(
_app: AppHandle,
java_world_path: String,
output_ms_path: String,
) -> Result<String, String> {
let mut reader = JavaWorldReader::new(&java_world_path);
let java_root = reader.read_level_dat().map_err(|e| format!("Failed to read level.dat: {}", e))?;
let spawn = read_spawn(&java_root);
let spawn_chunk_x = spawn.x >> 4;
let spawn_chunk_z = spawn.z >> 4;
let xz_size = DEFAULT_XZ_SIZE;
let half_size = xz_size / 2;
let hell_scale = 3;
let hell_half_size = (xz_size / hell_scale) / 2;
let end_half_size = 9;
let mut container = SaveDataContainer::new(7, 9);
let default_region_order = [
"DIM-1r.-1.-1.mcr", "DIM-1r.0.-1.mcr", "DIM-1r.0.0.mcr", "DIM-1r.-1.0.mcr",
"DIM1/r.-1.-1.mcr", "DIM1/r.0.-1.mcr", "DIM1/r.0.0.mcr", "DIM1/r.-1.0.mcr",
"r.-1.-1.mcr", "r.0.-1.mcr", "r.0.0.mcr", "r.-1.0.mcr",
];
for name in default_region_order {
container.create_file(name);
}
let estimated_spawn_y = estimate_safe_spawn_y(
&mut reader,
spawn.x,
spawn.y,
spawn.z,
spawn_chunk_x,
spawn_chunk_z,
);
let mut global_section_shift: Option<i32> = None;
let mut errors: Vec<String> = Vec::new();
let ow = convert_dimension(
&mut reader,
&mut container,
"",
half_size,
spawn_chunk_x,
spawn_chunk_z,
&mut global_section_shift,
&mut errors,
);
let nether = convert_dimension(
&mut reader,
&mut container,
"DIM-1",
hell_half_size,
floor_div(spawn_chunk_x, 8),
floor_div(spawn_chunk_z, 8),
&mut global_section_shift,
&mut errors,
);
let end = convert_dimension(
&mut reader,
&mut container,
"DIM1",
end_half_size,
0,
0,
&mut global_section_shift,
&mut errors,
);
let level_dat_bytes = convert_java_to_lce(
&java_root,
spawn_chunk_x,
spawn_chunk_z,
xz_size,
false,
estimated_spawn_y,
);
let ld_idx = container.create_file("level.dat");
container.write_to_file(ld_idx, &level_dat_bytes);
container.save(&output_ms_path).map_err(|e| format!("Failed to save output: {}", e))?;
let chunks_written = ow + nether + end;
let mut msg = format!(
"Conversion complete!\nChunks: {}\nOverworld: {}\nNether: {}\nEnd: {}",
chunks_written, ow, nether, end
);
if !errors.is_empty() {
msg.push_str(&format!("\nErrors: {}", errors.len()));
for err in errors.iter().take(5) {
msg.push_str(&format!("\n {}", err));
}
if errors.len() > 5 {
msg.push_str(&format!("\n ... and {} more", errors.len() - 5));
}
}
eprintln!("{}",msg);
Ok(msg)
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn lce_to_java(
_app: AppHandle,
input_ms_path: String,
java_world_output: String,
) -> Result<String, String> {
let ms_data = fs::read(&input_ms_path).map_err(|e| format!("Failed to read saveData.ms: {}", e))?;
let decompressed = region::decompress_zlib(&ms_data[8..]).map_err(|e| format!("Failed to decompress save data: {}", e))?;
let footer_offset = u32::from_le_bytes([
decompressed[0], decompressed[1], decompressed[2], decompressed[3],
]) as usize;
let entry_count = u32::from_le_bytes([
decompressed[4], decompressed[5], decompressed[6], decompressed[7],
]) as usize;
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..entry_count {
let base = footer_offset + i * 144;
if base + 144 > decompressed.len() {
break;
}
let name_bytes = &decompressed[base..base + 128];
let name: String = name_bytes
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.take_while(|&c| c != 0)
.filter_map(|c| char::from_u32(c as u32))
.collect();
let length = u32::from_le_bytes([
decompressed[base + 128],
decompressed[base + 129],
decompressed[base + 130],
decompressed[base + 131],
]) as usize;
let start_offset = u32::from_le_bytes([
decompressed[base + 132],
decompressed[base + 133],
decompressed[base + 134],
decompressed[base + 135],
]) as usize;
if !name.is_empty() && length > 0 && start_offset + length <= decompressed.len() {
let data = decompressed[start_offset..start_offset + length].to_vec();
files.push((name, data));
}
}
let out_dir = Path::new(&java_world_output);
fs::create_dir_all(out_dir).map_err(|e| format!("Failed to create output directory: {}", e))?;
fs::create_dir_all(out_dir.join("region")).map_err(|e| format!("Failed to create region directory: {}", e))?;
let mut chunks_written: usize = 0;
let mut errors: Vec<String> = Vec::new();
let mut region_writers: HashMap<String, JavaRegionFileWriter> = HashMap::new();
for (name, data) in &files {
if name == "level.dat" {
let lce_root = nbt::read_nbt(data).map_err(|e| format!("Failed to parse level.dat: {}", e))?;
let java_level_bytes = convert_lce_to_java(&lce_root, None, None, None, None);
fs::write(out_dir.join("level.dat"), &java_level_bytes).map_err(|e| format!("Failed to write level.dat: {}", e))?;
continue;
}
if name.ends_with(".mcr") || name.ends_with(".mca") {
let mut reader = region::JavaRegionReader::open_from_bytes(data);
let parts: Vec<&str> = name.split('.').collect();
let region_x: i32 = if parts.len() >= 4 { parts[1].parse().unwrap_or(0) } else { 0 };
let region_z: i32 = if parts.len() >= 4 { parts[2].parse().unwrap_or(0) } else { 0 };
for local_z in 0..32 {
for local_x in 0..32 {
let index = (local_x & 31) + (local_z & 31) * 32;
if reader.offsets[index as usize] == 0 {
continue;
}
match reader.read_chunk(local_x, local_z) {
Ok(Some(lce_chunk_data)) => {
if let Some(legacy_nbt) = payload::try_decode_to_legacy_nbt(&lce_chunk_data) {
let root = nbt::read_nbt(&legacy_nbt).unwrap_or_default();
let source_level = root.compound("Level").unwrap_or(&root);
let cx = region_x * 32 + local_x;
let cz = region_z * 32 + local_z;
let modern_root = build_modern_anvil_level(source_level, cx, cz);
let modern_nbt = nbt::write_nbt(&modern_root);
let out_region_x = cx >> 5;
let out_region_z = cz >> 5;
let out_local_x = cx & 31;
let out_local_z = cz & 31;
let section_region = format!("r.{}.{}.mca", out_region_x, out_region_z);
let region_path = out_dir.join("region").join(&section_region);
let region_path_str = region_path.to_string_lossy().to_string();
let writer = region_writers.entry(section_region.clone()).or_insert_with(|| {
JavaRegionFileWriter::load_from_file(&region_path_str)
});
writer.write_chunk(out_local_x, out_local_z, &modern_nbt);
chunks_written += 1;
}
}
Ok(None) => {}
Err(e) => {
errors.push(format!("{}/{}: {}", name, local_x * 32 + local_z, e));
}
}
}
}
continue;
}
}
for (region_name, writer) in &mut region_writers {
if let Err(e) = writer.save() {
errors.push(format!("Failed to save region {}: {}", region_name, e));
}
}
let mut msg = format!(
"Conversion complete!\nChunks: {}\nFiles: {}",
chunks_written, files.len()
);
if !errors.is_empty() {
msg.push_str(&format!("\nErrors: {}", errors.len()));
for err in errors.iter().take(5) {
msg.push_str(&format!("\n {}", err));
}
}
eprintln!("{}", msg);
Ok(msg)
}
+496
View File
@@ -0,0 +1,496 @@
use std::io::{Read, Write};
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
#[derive(Debug, Clone, PartialEq)]
pub enum NbtValue {
Byte(i8),
Short(i16),
Int(i32),
Long(i64),
Float(f32),
Double(f64),
ByteArray(Vec<u8>),
String(String),
List(Vec<NbtValue>),
Compound(NbtCompound),
IntArray(Vec<i32>),
LongArray(Vec<i64>),
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NbtCompound {
pub name: String,
pub tags: Vec<(String, NbtValue)>,
}
impl NbtCompound {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
tags: Vec::new(),
}
}
pub fn insert(&mut self, name: &str, value: NbtValue) {
self.tags.retain(|(n, _)| n != name);
self.tags.push((name.to_string(), value));
}
pub fn get(&self, name: &str) -> Option<&NbtValue> {
self.tags.iter().find(|(n, _)| n == name).map(|(_, v)| v)
}
pub fn get_mut(&mut self, name: &str) -> Option<&mut NbtValue> {
self.tags.iter_mut().find(|(n, _)| n == name).map(|(_, v)| v)
}
pub fn contains(&self, name: &str) -> bool {
self.tags.iter().any(|(n, _)| n == name)
}
pub fn remove(&mut self, name: &str) {
self.tags.retain(|(n, _)| n != name);
}
pub fn compound(&self, name: &str) -> Option<&NbtCompound> {
self.get(name).and_then(|v| match v {
NbtValue::Compound(c) => Some(c),
_ => None,
})
}
pub fn compound_mut(&mut self, name: &str) -> Option<&mut NbtCompound> {
self.get_mut(name).and_then(|v| match v {
NbtValue::Compound(c) => Some(c),
_ => None,
})
}
pub fn byte(&self, name: &str) -> Option<i8> {
self.get(name).and_then(|v| match v {
NbtValue::Byte(b) => Some(*b),
_ => None,
})
}
pub fn short(&self, name: &str) -> Option<i16> {
self.get(name).and_then(|v| match v {
NbtValue::Short(s) => Some(*s),
_ => None,
})
}
pub fn int(&self, name: &str) -> Option<i32> {
self.get(name).and_then(|v| match v {
NbtValue::Int(i) => Some(*i),
_ => None,
})
}
pub fn long(&self, name: &str) -> Option<i64> {
self.get(name).and_then(|v| match v {
NbtValue::Long(l) => Some(*l),
_ => None,
})
}
pub fn float(&self, name: &str) -> Option<f32> {
self.get(name).and_then(|v| match v {
NbtValue::Float(f) => Some(*f),
_ => None,
})
}
pub fn double(&self, name: &str) -> Option<f64> {
self.get(name).and_then(|v| match v {
NbtValue::Double(d) => Some(*d),
_ => None,
})
}
pub fn string(&self, name: &str) -> Option<&str> {
self.get(name).and_then(|v| match v {
NbtValue::String(s) => Some(s.as_str()),
_ => None,
})
}
pub fn byte_array(&self, name: &str) -> Option<&[u8]> {
self.get(name).and_then(|v| match v {
NbtValue::ByteArray(b) => Some(b.as_slice()),
_ => None,
})
}
pub fn int_array(&self, name: &str) -> Option<&[i32]> {
self.get(name).and_then(|v| match v {
NbtValue::IntArray(a) => Some(a.as_slice()),
_ => None,
})
}
pub fn long_array(&self, name: &str) -> Option<&[i64]> {
self.get(name).and_then(|v| match v {
NbtValue::LongArray(a) => Some(a.as_slice()),
_ => None,
})
}
pub fn list(&self, name: &str) -> Option<&[NbtValue]> {
self.get(name).and_then(|v| match v {
NbtValue::List(l) => Some(l.as_slice()),
_ => None,
})
}
pub fn list_compounds(&self, name: &str) -> Vec<&NbtCompound> {
self.list(name)
.unwrap_or(&[])
.iter()
.filter_map(|v| match v {
NbtValue::Compound(c) => Some(c),
_ => None,
})
.collect()
}
}
fn tag_type_id(v: &NbtValue) -> u8 {
match v {
NbtValue::Byte(_) => 1,
NbtValue::Short(_) => 2,
NbtValue::Int(_) => 3,
NbtValue::Long(_) => 4,
NbtValue::Float(_) => 5,
NbtValue::Double(_) => 6,
NbtValue::ByteArray(_) => 7,
NbtValue::String(_) => 8,
NbtValue::List(_) => 9,
NbtValue::Compound(_) => 10,
NbtValue::IntArray(_) => 11,
NbtValue::LongArray(_) => 12,
}
}
fn read_be_i16(data: &[u8], off: usize) -> i16 {
i16::from_be_bytes([data[off], data[off + 1]])
}
fn read_be_i32(data: &[u8], off: usize) -> i32 {
i32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
}
fn read_be_i64(data: &[u8], off: usize) -> i64 {
i64::from_be_bytes([
data[off],
data[off + 1],
data[off + 2],
data[off + 3],
data[off + 4],
data[off + 5],
data[off + 6],
data[off + 7],
])
}
fn read_be_f32(data: &[u8], off: usize) -> f32 {
f32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
}
fn read_be_f64(data: &[u8], off: usize) -> f64 {
f64::from_be_bytes([
data[off],
data[off + 1],
data[off + 2],
data[off + 3],
data[off + 4],
data[off + 5],
data[off + 6],
data[off + 7],
])
}
fn read_string(data: &[u8], off: usize) -> Result<(String, usize), String> {
let len = read_be_i16(data, off) as usize;
let start = off + 2;
if start + len > data.len() {
return Err("string extends past end of data".into());
}
let s = String::from_utf8_lossy(&data[start..start + len]).to_string();
Ok((s, start + len))
}
pub fn read_nbt(data: &[u8]) -> Result<NbtCompound, String> {
if data.is_empty() {
return Err("empty NBT data".into());
}
let mut pos = 0;
let tag_type = data[pos];
pos += 1;
if tag_type != 10 {
return Err(format!("root tag is not compound (type={})", tag_type));
}
let (name, new_pos) = read_string(data, pos)?;
pos = new_pos;
let (compound, _new_pos) = read_compound_payload(data, pos)?;
let mut result = compound;
result.name = name;
Ok(result)
}
fn read_compound_payload(data: &[u8], mut pos: usize) -> Result<(NbtCompound, usize), String> {
let mut compound = NbtCompound::default();
loop {
if pos >= data.len() {
return Err("unexpected end in compound".into());
}
let tag_type = data[pos];
pos += 1;
if tag_type == 0 {
break;
}
let (name, new_pos) = read_string(data, pos)?;
pos = new_pos;
let (value, new_pos) = read_value(data, pos, tag_type)?;
pos = new_pos;
compound.tags.push((name, value));
}
Ok((compound, pos))
}
fn read_value(data: &[u8], pos: usize, tag_type: u8) -> Result<(NbtValue, usize), String> {
match tag_type {
1 => Ok((NbtValue::Byte(data[pos] as i8), pos + 1)),
2 => Ok((NbtValue::Short(read_be_i16(data, pos)), pos + 2)),
3 => Ok((NbtValue::Int(read_be_i32(data, pos)), pos + 4)),
4 => Ok((NbtValue::Long(read_be_i64(data, pos)), pos + 8)),
5 => Ok((NbtValue::Float(read_be_f32(data, pos)), pos + 4)),
6 => Ok((NbtValue::Double(read_be_f64(data, pos)), pos + 8)),
7 => {
let len = read_be_i32(data, pos) as usize;
let start = pos + 4;
if start + len > data.len() {
return Err("byte array extends past end".into());
}
Ok((NbtValue::ByteArray(data[start..start + len].to_vec()), start + len))
}
8 => {
let (s, new_pos) = read_string(data, pos)?;
Ok((NbtValue::String(s), new_pos))
}
9 => {
let elem_type = data[pos];
let count = read_be_i32(data, pos + 1) as usize;
let mut list_pos = pos + 5;
let mut list = Vec::with_capacity(count);
for _ in 0..count {
let (val, new_pos) = read_value(data, list_pos, elem_type)?;
list_pos = new_pos;
list.push(val);
}
Ok((NbtValue::List(list), list_pos))
}
10 => {
let (compound, new_pos) = read_compound_payload(data, pos)?;
Ok((NbtValue::Compound(compound), new_pos))
}
11 => {
let len = read_be_i32(data, pos) as usize;
let start = pos + 4;
let mut arr = Vec::with_capacity(len);
for i in 0..len {
let off = start + i * 4;
if off + 4 > data.len() {
return Err("int array extends past end".into());
}
arr.push(read_be_i32(data, off));
}
Ok((NbtValue::IntArray(arr), start + len * 4))
}
12 => {
let len = read_be_i32(data, pos) as usize;
let start = pos + 4;
let mut arr = Vec::with_capacity(len);
for i in 0..len {
let off = start + i * 8;
if off + 8 > data.len() {
return Err("long array extends past end".into());
}
arr.push(read_be_i64(data, off));
}
Ok((NbtValue::LongArray(arr), start + len * 8))
}
_ => Err(format!("unknown NBT tag type: {}", tag_type)),
}
}
pub fn write_nbt(compound: &NbtCompound) -> Vec<u8> {
let mut out = Vec::new();
out.push(10);
write_string(&mut out, &compound.name);
write_compound_payload(&mut out, compound);
out.push(0);
out
}
fn write_string(out: &mut Vec<u8>, s: &str) {
let bytes = s.as_bytes();
let len = (bytes.len() as i16).to_be_bytes();
out.extend_from_slice(&len);
out.extend_from_slice(bytes);
}
fn write_compound_payload(out: &mut Vec<u8>, compound: &NbtCompound) {
for (name, value) in &compound.tags {
out.push(tag_type_id(value));
write_string(out, name);
write_value(out, value);
}
}
fn write_value(out: &mut Vec<u8>, value: &NbtValue) {
match value {
NbtValue::Byte(b) => out.push(*b as u8),
NbtValue::Short(s) => out.extend_from_slice(&s.to_be_bytes()),
NbtValue::Int(i) => out.extend_from_slice(&i.to_be_bytes()),
NbtValue::Long(l) => out.extend_from_slice(&l.to_be_bytes()),
NbtValue::Float(f) => out.extend_from_slice(&f.to_be_bytes()),
NbtValue::Double(d) => out.extend_from_slice(&d.to_be_bytes()),
NbtValue::ByteArray(arr) => {
out.extend_from_slice(&(arr.len() as i32).to_be_bytes());
out.extend_from_slice(arr);
}
NbtValue::String(s) => write_string(out, s),
NbtValue::List(list) => {
let elem_type = list.first().map(|v| tag_type_id(v)).unwrap_or(0);
out.push(elem_type);
out.extend_from_slice(&(list.len() as i32).to_be_bytes());
for item in list {
write_value(out, item);
}
}
NbtValue::Compound(c) => {
write_compound_payload(out, c);
out.push(0);
}
NbtValue::IntArray(arr) => {
out.extend_from_slice(&(arr.len() as i32).to_be_bytes());
for i in arr {
out.extend_from_slice(&i.to_be_bytes());
}
}
NbtValue::LongArray(arr) => {
out.extend_from_slice(&(arr.len() as i32).to_be_bytes());
for l in arr {
out.extend_from_slice(&l.to_be_bytes());
}
}
}
}
pub fn read_gzip_nbt(data: &[u8]) -> Result<NbtCompound, String> {
let mut decoder = GzDecoder::new(data);
let mut buf = Vec::new();
decoder
.read_to_end(&mut buf)
.map_err(|e| format!("gzip decompress failed: {}", e))?;
read_nbt(&buf)
}
pub fn write_gzip_nbt(compound: &NbtCompound) -> Vec<u8> {
let raw = write_nbt(compound);
let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
encoder.write_all(&raw).unwrap();
encoder.finish().unwrap()
}
pub fn read_zlib_nbt(data: &[u8]) -> Result<NbtCompound, String> {
use flate2::read::ZlibDecoder;
let mut decoder = ZlibDecoder::new(data);
let mut buf = Vec::new();
decoder
.read_to_end(&mut buf)
.map_err(|e| format!("zlib decompress failed: {}", e))?;
read_nbt(&buf)
}
pub fn write_zlib_nbt(compound: &NbtCompound) -> Vec<u8> {
use flate2::write::ZlibEncoder;
let raw = write_nbt(compound);
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
encoder.write_all(&raw).unwrap();
encoder.finish().unwrap()
}
pub fn get_byte_array_or(compound: &NbtCompound, name: &str, default_len: usize) -> Vec<u8> {
compound
.byte_array(name)
.map(|b| {
let mut v = b.to_vec();
if v.len() < default_len {
v.resize(default_len, 0);
}
v
})
.unwrap_or_else(|| vec![0u8; default_len])
}
pub fn get_int_array_or(compound: &NbtCompound, name: &str, default_len: usize) -> Vec<i32> {
compound
.int_array(name)
.map(|a| {
let mut v = a.to_vec();
if v.len() < default_len {
v.resize(default_len, 0);
}
v
})
.unwrap_or_else(|| vec![0i32; default_len])
}
pub fn get_long_array_or(compound: &NbtCompound, name: &str, default_len: usize) -> Vec<i64> {
compound
.long_array(name)
.map(|a| {
let mut v = a.to_vec();
if v.len() < default_len {
v.resize(default_len, 0);
}
v
})
.unwrap_or_else(|| vec![0i64; default_len])
}
pub fn get_nibble(data: &[u8], index: usize) -> u8 {
let byte_index = index >> 1;
if byte_index >= data.len() {
return 0;
}
let b = data[byte_index];
if index & 1 == 0 {
b & 0x0F
} else {
(b >> 4) & 0x0F
}
}
pub fn set_nibble(data: &mut [u8], index: usize, value: u8) {
let byte_index = index >> 1;
if byte_index >= data.len() {
return;
}
let val = value & 0x0F;
if index & 1 == 0 {
data[byte_index] = (data[byte_index] & 0xF0) | val;
} else {
data[byte_index] = (data[byte_index] & 0x0F) | (val << 4);
}
}
pub fn clone_or_empty_list(compound: &NbtCompound, name: &str) -> Vec<NbtValue> {
compound
.list(name)
.map(|l| l.to_vec())
.unwrap_or_default()
}
+683
View File
@@ -0,0 +1,683 @@
use super::nbt;
const SAVE_FILE_VERSION_COMPRESSED_CHUNK_STORAGE: i16 = 8;
const SAVE_FILE_VERSION_CHUNK_INHABITED_TIME: i16 = 9;
const COMPRESSED_CHUNK_SECTION_HEIGHT: usize = 128;
const BLOCKS_PER_SECTION: usize = COMPRESSED_CHUNK_SECTION_HEIGHT * 16 * 16;
const NIBBLES_PER_SECTION: usize = BLOCKS_PER_SECTION / 2;
const FULL_CHUNK_BLOCKS: usize = 256 * 16 * 16;
const FULL_CHUNK_NIBBLES: usize = FULL_CHUNK_BLOCKS / 2;
const INDEX_TYPE_MASK: u16 = 0x0003;
const INDEX_TYPE_1BIT: u16 = 0x0000;
const INDEX_TYPE_2BIT: u16 = 0x0001;
const INDEX_TYPE_4BIT: u16 = 0x0002;
const INDEX_TYPE_0_OR_8BIT: u16 = 0x0003;
const INDEX_TYPE_0BIT_FLAG: u16 = 0x0004;
const SPARSE_ALL_ZERO_INDEX: u8 = 128;
const SPARSE_ALL_FIFTEEN_INDEX: u8 = 129;
fn read_be_i16(data: &[u8], off: usize) -> i16 {
i16::from_be_bytes([data[off], data[off + 1]])
}
fn read_be_i32(data: &[u8], off: usize) -> i32 {
i32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
}
fn read_be_i64(data: &[u8], off: usize) -> i64 {
i64::from_be_bytes([
data[off], data[off + 1], data[off + 2], data[off + 3], data[off + 4], data[off + 5],
data[off + 6], data[off + 7],
])
}
fn write_be_i16(out: &mut Vec<u8>, v: i16) {
out.extend_from_slice(&v.to_be_bytes());
}
fn write_be_i32(out: &mut Vec<u8>, v: i32) {
out.extend_from_slice(&v.to_be_bytes());
}
fn write_be_i64(out: &mut Vec<u8>, v: i64) {
out.extend_from_slice(&v.to_be_bytes());
}
fn get_compressed_tile_index(block: usize, tile: usize) -> usize {
let mut index = ((block & 0x180) << 6) | ((block & 0x060) << 4) | ((block & 0x01F) << 2);
index |= ((tile & 0x30) << 7) | ((tile & 0x0C) << 5) | (tile & 0x03);
index
}
fn get_nibble_value(nibble_data: &[u8], xz: usize, y: usize) -> u8 {
let pos = (xz << 7) | y;
let slot = pos >> 1;
let part = pos & 1;
if slot >= nibble_data.len() {
return 0;
}
let b = nibble_data[slot];
if part == 0 {
b & 0x0F
} else {
(b >> 4) & 0x0F
}
}
fn set_nibble_value(nibble_data: &mut [u8], xz: usize, y: usize, value: u8) {
let pos = (xz << 7) | y;
let slot = pos >> 1;
let part = pos & 1;
if slot >= nibble_data.len() {
return;
}
let val = value & 0x0F;
if part == 0 {
nibble_data[slot] = (nibble_data[slot] & 0xF0) | val;
} else {
nibble_data[slot] = (nibble_data[slot] & 0x0F) | (val << 4);
}
}
fn ensure_length(input: &[u8], expected_len: usize) -> Vec<u8> {
if input.len() == expected_len {
return input.to_vec();
}
let mut v = vec![0u8; expected_len];
let copy_len = input.len().min(expected_len);
v[..copy_len].copy_from_slice(&input[..copy_len]);
v
}
fn extract_lower_block_section(full_blocks: &[u8]) -> Vec<u8> {
if full_blocks.len() == BLOCKS_PER_SECTION {
return full_blocks.to_vec();
}
let mut lower = vec![0u8; BLOCKS_PER_SECTION];
for xz in 0..256 {
let src_start = xz * 256;
let dst_start = xz * COMPRESSED_CHUNK_SECTION_HEIGHT;
let copy_len = COMPRESSED_CHUNK_SECTION_HEIGHT.min(full_blocks.len() - src_start.min(full_blocks.len()));
if src_start + copy_len <= full_blocks.len() && dst_start + copy_len <= BLOCKS_PER_SECTION {
lower[dst_start..dst_start + copy_len]
.copy_from_slice(&full_blocks[src_start..src_start + copy_len]);
}
}
lower
}
fn extract_upper_block_section(full_blocks: &[u8]) -> Vec<u8> {
if full_blocks.len() < FULL_CHUNK_BLOCKS {
return vec![0u8; BLOCKS_PER_SECTION];
}
let mut upper = vec![0u8; BLOCKS_PER_SECTION];
for xz in 0..256 {
let src_start = xz * 256 + COMPRESSED_CHUNK_SECTION_HEIGHT;
let dst_start = xz * COMPRESSED_CHUNK_SECTION_HEIGHT;
upper[dst_start..dst_start + COMPRESSED_CHUNK_SECTION_HEIGHT].copy_from_slice(&full_blocks[src_start..src_start + COMPRESSED_CHUNK_SECTION_HEIGHT]);
}
upper
}
fn extract_lower_nibble_section(full_nibbles: &[u8]) -> Vec<u8> {
let nibble_height = COMPRESSED_CHUNK_SECTION_HEIGHT / 2;
if full_nibbles.len() == NIBBLES_PER_SECTION {
return full_nibbles.to_vec();
}
let mut lower = vec![0u8; NIBBLES_PER_SECTION];
for xz in 0..256 {
let src_start = xz * nibble_height * 2;
let dst_start = xz * nibble_height;
let copy_len = nibble_height.min(
full_nibbles.len().saturating_sub(src_start),
);
if copy_len > 0 {
lower[dst_start..dst_start + copy_len].copy_from_slice(&full_nibbles[src_start..src_start + copy_len]);
}
}
lower
}
fn extract_upper_nibble_section(full_nibbles: &[u8]) -> Vec<u8> {
let nibble_height = COMPRESSED_CHUNK_SECTION_HEIGHT / 2;
if full_nibbles.len() < FULL_CHUNK_NIBBLES {
return vec![0u8; NIBBLES_PER_SECTION];
}
let mut upper = vec![0u8; NIBBLES_PER_SECTION];
for xz in 0..256 {
let src_start = xz * nibble_height * 2 + nibble_height;
let dst_start = xz * nibble_height;
upper[dst_start..dst_start + nibble_height]
.copy_from_slice(&full_nibbles[src_start..src_start + nibble_height]);
}
upper
}
fn combine_block_sections(lower: &[u8], upper: &[u8]) -> Vec<u8> {
if lower.len() == FULL_CHUNK_BLOCKS {
return lower.to_vec();
}
let mut combined = vec![0u8; FULL_CHUNK_BLOCKS];
for xz in 0..256 {
let lower_start = xz * COMPRESSED_CHUNK_SECTION_HEIGHT;
let out_start = xz * 256;
let copy_len = COMPRESSED_CHUNK_SECTION_HEIGHT.min(lower.len().saturating_sub(lower_start));
if copy_len > 0 {
combined[out_start..out_start + copy_len].copy_from_slice(&lower[lower_start..lower_start + copy_len]);
}
let upper_start = xz * COMPRESSED_CHUNK_SECTION_HEIGHT;
let out_upper = out_start + COMPRESSED_CHUNK_SECTION_HEIGHT;
let copy_len2 = COMPRESSED_CHUNK_SECTION_HEIGHT.min(upper.len().saturating_sub(upper_start));
if copy_len2 > 0 {
combined[out_upper..out_upper + copy_len2]
.copy_from_slice(&upper[upper_start..upper_start + copy_len2]);
}
}
combined
}
fn combine_nibble_sections(lower: &[u8], upper: &[u8]) -> Vec<u8> {
let nibble_height = COMPRESSED_CHUNK_SECTION_HEIGHT / 2;
if lower.len() == FULL_CHUNK_NIBBLES {
return lower.to_vec();
}
let mut combined = vec![0u8; FULL_CHUNK_NIBBLES];
for xz in 0..256 {
let lower_start = xz * nibble_height;
let out_start = xz * nibble_height * 2;
let copy_len = nibble_height.min(lower.len().saturating_sub(lower_start));
if copy_len > 0 {
combined[out_start..out_start + copy_len]
.copy_from_slice(&lower[lower_start..lower_start + copy_len]);
}
let upper_start = xz * nibble_height;
let out_upper = out_start + nibble_height;
let copy_len2 = nibble_height.min(upper.len().saturating_sub(upper_start));
if copy_len2 > 0 {
combined[out_upper..out_upper + copy_len2]
.copy_from_slice(&upper[upper_start..upper_start + copy_len2]);
}
}
combined
}
pub fn encode_legacy_nbt(level: &nbt::NbtCompound) -> Vec<u8> {
let mut root = nbt::NbtCompound::new("");
root.insert("Level", nbt::NbtValue::Compound(level.clone()));
nbt::write_nbt(&root)
}
fn write_compressed_tile_storage(out: &mut Vec<u8>, blocks: &[u8]) {
let normalized = ensure_length(blocks, BLOCKS_PER_SECTION);
let mut blob = vec![0u8; 1024 + BLOCKS_PER_SECTION];
let mut data_offset: usize = 0;
for block in 0..512 {
let index = (INDEX_TYPE_0_OR_8BIT | ((data_offset as u16) << 1)) as u16;
blob[block * 2..block * 2 + 2].copy_from_slice(&index.to_le_bytes());
for tile in 0..64 {
blob[1024 + data_offset + tile] = normalized[get_compressed_tile_index(block, tile)];
}
data_offset += 64;
}
write_be_i32(out, blob.len() as i32);
out.extend_from_slice(&blob);
}
fn write_empty_compressed_tile_storage(out: &mut Vec<u8>) {
let mut blob = vec![0u8; 1024];
for block in 0..512 {
let index = (INDEX_TYPE_0_OR_8BIT | INDEX_TYPE_0BIT_FLAG) as u16;
blob[block * 2..block * 2 + 2].copy_from_slice(&index.to_le_bytes());
}
write_be_i32(out, blob.len() as i32);
out.extend_from_slice(&blob);
}
fn write_sparse_nibble_storage(out: &mut Vec<u8>, nibble_data: &[u8], supports_all_fifteen: bool) {
let normalized = ensure_length(nibble_data, NIBBLES_PER_SECTION);
let mut plane_indices = [0u8; 128];
let mut planes: Vec<Vec<u8>> = Vec::new();
for y in 0..128 {
let mut all_zero = true;
let mut all_fifteen = supports_all_fifteen;
let mut plane = vec![0u8; 128];
let mut plane_cursor = 0;
for xz in 0..128 {
let first = get_nibble_value(&normalized, xz * 2, y);
let second = get_nibble_value(&normalized, xz * 2 + 1, y);
if first != 0 || second != 0 {
all_zero = false;
}
if supports_all_fifteen && (first != 15 || second != 15) {
all_fifteen = false;
}
plane[plane_cursor] = first | (second << 4);
plane_cursor += 1;
}
if all_zero {
plane_indices[y] = SPARSE_ALL_ZERO_INDEX;
continue;
}
if supports_all_fifteen && all_fifteen {
plane_indices[y] = SPARSE_ALL_FIFTEEN_INDEX;
continue;
}
plane_indices[y] = planes.len() as u8;
planes.push(plane);
}
write_be_i32(out, planes.len() as i32);
out.extend_from_slice(&plane_indices);
for plane in &planes {
out.extend_from_slice(plane);
}
}
fn write_empty_sparse_nibble_storage(out: &mut Vec<u8>, supports_all_fifteen: bool, fill_with_fifteen: bool) {
write_be_i32(out, 0);
let fill = if supports_all_fifteen && fill_with_fifteen {
SPARSE_ALL_FIFTEEN_INDEX
} else {
SPARSE_ALL_ZERO_INDEX
};
let plane_indices = [fill; 128];
out.extend_from_slice(&plane_indices);
}
pub fn encode_compressed_storage(level: &nbt::NbtCompound) -> Vec<u8> {
let blocks = level.byte_array("Blocks").unwrap_or(&[]);
let data = level.byte_array("Data").unwrap_or(&[]);
let sky_light_raw = level.byte_array("SkyLight").unwrap_or(&[]);
let block_light_raw = level.byte_array("BlockLight").unwrap_or(&[]);
let height_map = level.byte_array("HeightMap").unwrap_or(&[]);
let biomes = level.byte_array("Biomes").unwrap_or(&[]);
let default_sky = vec![0xFF; NIBBLES_PER_SECTION];
let default_light = vec![0u8; NIBBLES_PER_SECTION];
let sky_light = if sky_light_raw.is_empty() { default_sky.as_slice() } else { sky_light_raw };
let block_light = if block_light_raw.is_empty() { default_light.as_slice() } else { block_light_raw };
let mut out = Vec::new();
write_be_i16(&mut out, SAVE_FILE_VERSION_CHUNK_INHABITED_TIME);
write_be_i32(&mut out, level.int("xPos").unwrap_or(0));
write_be_i32(&mut out, level.int("zPos").unwrap_or(0));
write_be_i64(&mut out, level.long("LastUpdate").unwrap_or(0));
write_be_i64(&mut out, level.long("InhabitedTime").unwrap_or(0));
write_compressed_tile_storage(&mut out, &extract_lower_block_section(blocks));
write_compressed_tile_storage(&mut out, &extract_upper_block_section(blocks));
write_sparse_nibble_storage(&mut out, &extract_lower_nibble_section(data), false);
write_sparse_nibble_storage(&mut out, &extract_upper_nibble_section(data), false);
write_sparse_nibble_storage(&mut out, &extract_lower_nibble_section(sky_light), true);
write_sparse_nibble_storage(&mut out, &extract_upper_nibble_section(sky_light), true);
write_sparse_nibble_storage(&mut out, &extract_lower_nibble_section(block_light), true);
write_sparse_nibble_storage(&mut out, &extract_upper_nibble_section(block_light), true);
let hm = ensure_length(height_map, 256);
out.extend_from_slice(&hm[..256]);
write_be_i16(&mut out, level.short("TerrainPopulatedFlags").unwrap_or(0));
let bio = ensure_length(biomes, 256);
out.extend_from_slice(&bio[..256]);
let dynamic_root = nbt::NbtCompound::new("");
let dynamic_bytes = nbt::write_nbt(&dynamic_root);
out.extend_from_slice(&dynamic_bytes);
out
}
fn is_compressed_chunk_storage(data: &[u8]) -> bool {
if data.len() < 2 + 4 + 4 + 8 {
return false;
}
let version = read_be_i16(data, 0);
version == SAVE_FILE_VERSION_COMPRESSED_CHUNK_STORAGE || version == SAVE_FILE_VERSION_CHUNK_INHABITED_TIME
}
pub fn try_read_chunk_coordinates(
data: &[u8],
) -> Option<(i32, i32, bool)> {
if let Some((cx, cz, wrapped)) = try_read_legacy_level_coords(data) {
return Some((cx, cz, wrapped));
}
if is_compressed_chunk_storage(data) {
let cx = read_be_i32(data, 2);
let cz = read_be_i32(data, 6);
return Some((cx, cz, false));
}
None
}
fn try_read_legacy_level_coords(data: &[u8]) -> Option<(i32, i32, bool)> {
if data.is_empty() || data[0] != 10 {
return None;
}
let compound = nbt::read_nbt(data).ok()?;
let (level, has_wrapper) = if let Some(nbt::NbtValue::Compound(c)) = compound.get("Level") {
(c, true)
} else {
(&compound, false)
};
let cx = level.int("xPos")?;
let cz = level.int("zPos")?;
Some((cx, cz, has_wrapper))
}
pub fn force_chunk_coordinates(data: &[u8], expected_x: i32, expected_z: i32) -> Vec<u8> {
if data.is_empty() {
return Vec::new();
}
if let Ok((mut level, _)) = read_legacy_level(data) {
level.insert("xPos", nbt::NbtValue::Int(expected_x));
level.insert("zPos", nbt::NbtValue::Int(expected_z));
return encode_legacy_nbt(&level);
}
if is_compressed_chunk_storage(data) {
let mut patched = data.to_vec();
patched[2..6].copy_from_slice(&expected_x.to_be_bytes());
patched[6..10].copy_from_slice(&expected_z.to_be_bytes());
return patched;
}
Vec::new()
}
fn read_compressed_tile_storage(data: &[u8], offset: &mut usize) -> Result<Vec<u8>, String> {
let allocated_size = read_be_i32(data, *offset) as usize;
*offset += 4;
if allocated_size < 1024 || *offset + allocated_size > data.len() {
return Err("Invalid CompressedTileStorage payload.".into());
}
let blob = &data[*offset..*offset + allocated_size];
*offset += allocated_size;
let data_region = &blob[1024..];
let mut blocks = vec![0u8; BLOCKS_PER_SECTION];
for block in 0..512 {
let block_index = u16::from_le_bytes([blob[block * 2], blob[block * 2 + 1]]);
let index_type = block_index & INDEX_TYPE_MASK;
if index_type == INDEX_TYPE_0_OR_8BIT {
if (block_index & INDEX_TYPE_0BIT_FLAG) != 0 {
let value = ((block_index >> 8) & 0xFF) as u8;
for tile in 0..64 {
blocks[get_compressed_tile_index(block, tile)] = value;
}
} else {
let data_offset = ((block_index >> 1) & 0x7FFE) as usize;
if data_offset + 64 > data_region.len() {
return Err("Invalid 8-bit CompressedTileStorage offset.".into());
}
for tile in 0..64 {
blocks[get_compressed_tile_index(block, tile)] = data_region[data_offset + tile];
}
}
continue;
}
let bits_per_tile = match index_type {
INDEX_TYPE_1BIT => 1,
INDEX_TYPE_2BIT => 2,
INDEX_TYPE_4BIT => 4,
_ => return Err("Unsupported CompressedTileStorage index type.".into()),
};
let tile_type_count = 1usize << bits_per_tile;
let tile_type_mask = (tile_type_count - 1) as u8;
let index_shift = 3 - index_type as usize;
let index_mask_bits = (7 >> index_type) as usize;
let index_mask_bytes = (62 >> index_shift) as usize;
let packed_data_size = (8usize << index_type) as usize;
let data_offset_packed = ((block_index >> 1) & 0x7FFE) as usize;
if data_offset_packed + tile_type_count + packed_data_size > data_region.len() {
return Err("Invalid packed CompressedTileStorage offset.".into());
}
let tile_types = &data_region[data_offset_packed..data_offset_packed + tile_type_count];
let packed = &data_region[data_offset_packed + tile_type_count..data_offset_packed + tile_type_count + packed_data_size];
for tile in 0..64 {
let idx = (tile >> index_shift) & index_mask_bytes;
let bit = (tile & index_mask_bits) * bits_per_tile;
let palette_index = (packed[idx] >> bit) & tile_type_mask;
blocks[get_compressed_tile_index(block, tile)] = tile_types[palette_index as usize];
}
}
Ok(blocks)
}
fn skip_compressed_tile_storage(data: &[u8], offset: &mut usize) -> Result<(), String> {
let allocated_size = read_be_i32(data, *offset) as usize;
*offset += 4;
if *offset + allocated_size > data.len() {
return Err("Invalid CompressedTileStorage payload.".into());
}
*offset += allocated_size;
Ok(())
}
fn read_sparse_nibble_storage(data: &[u8], offset: &mut usize, supports_all_fifteen: bool) -> Result<Vec<u8>, String> {
let count = read_be_i32(data, *offset) as usize;
*offset += 4;
let storage_bytes = 128 + count * 128;
if count > data.len() || *offset + storage_bytes > data.len() {
return Err("Invalid SparseStorage payload.".into());
}
let blob = &data[*offset..*offset + storage_bytes];
*offset += storage_bytes;
let plane_indices = &blob[..128];
let plane_data = &blob[128..];
let mut nibble_data = vec![0u8; NIBBLES_PER_SECTION];
for y in 0..128 {
let plane_index = plane_indices[y];
if plane_index == SPARSE_ALL_ZERO_INDEX {
continue;
}
if supports_all_fifteen && plane_index == SPARSE_ALL_FIFTEEN_INDEX {
for xz in 0..256 {
set_nibble_value(&mut nibble_data, xz, y, 15);
}
continue;
}
let plane_offset = plane_index as usize * 128;
if plane_offset + 128 > plane_data.len() {
return Err("Invalid sparse plane index.".into());
}
let plane = &plane_data[plane_offset..plane_offset + 128];
for xz in 0..128 {
let packed = plane[xz];
set_nibble_value(&mut nibble_data, xz * 2, y, packed & 0x0F);
set_nibble_value(&mut nibble_data, xz * 2 + 1, y, (packed >> 4) & 0x0F);
}
}
Ok(nibble_data)
}
fn skip_sparse_nibble_storage(data: &[u8], offset: &mut usize) -> Result<(), String> {
let count = read_be_i32(data, *offset) as usize;
*offset += 4;
let storage_bytes = 128 + count * 128;
if count > data.len() || *offset + storage_bytes > data.len() {
return Err("Invalid SparseStorage payload.".into());
}
*offset += storage_bytes;
Ok(())
}
fn read_sized_bytes(data: &[u8], offset: &mut usize, length: usize) -> Vec<u8> {
let end = (*offset + length).min(data.len());
let result = data[*offset..end].to_vec();
*offset += length;
result
}
fn read_legacy_level(data: &[u8]) -> Result<(nbt::NbtCompound, bool), String> {
if data.is_empty() || data[0] != 10 {
return Err("Not NBT compound".into());
}
let file = nbt::read_nbt(data)?;
let has_level = file.get("Level").is_some();
let level = if let Some(nbt::NbtValue::Compound(c)) = file.get("Level") {
let mut cloned = c.clone();
cloned.name = "Level".to_string();
cloned
} else {
let mut cloned = file.clone();
cloned.name = "Level".to_string();
cloned
};
Ok((level, has_level))
}
pub fn try_decode_to_legacy_nbt(data: &[u8]) -> Option<Vec<u8>> {
if let Ok((mut level, _)) = read_legacy_level(data) {
level.name = "Level".to_string();
return Some(encode_legacy_nbt(&level));
}
if !is_compressed_chunk_storage(data) {
return None;
}
decode_compressed_chunk_to_legacy_root(data).ok().map(|root| nbt::write_nbt(&root))
}
fn decode_compressed_chunk_to_legacy_root(data: &[u8]) -> Result<nbt::NbtCompound, String> {
let mut offset = 0;
let version = read_be_i16(data, offset);
offset = 2;
if version != SAVE_FILE_VERSION_COMPRESSED_CHUNK_STORAGE
&& version != SAVE_FILE_VERSION_CHUNK_INHABITED_TIME
{
return Err(format!("Unsupported compressed chunk version: {}", version));
}
let chunk_x = read_be_i32(data, offset);
offset += 4;
let chunk_z = read_be_i32(data, offset);
offset += 4;
let last_update = read_be_i64(data, offset);
offset += 8;
let inhabited_time = if version >= SAVE_FILE_VERSION_CHUNK_INHABITED_TIME {
let v = read_be_i64(data, offset);
offset += 8;
v
} else {
0
};
let lower_blocks = read_compressed_tile_storage(data, &mut offset)?;
let upper_blocks = read_compressed_tile_storage(data, &mut offset)?;
let lower_data = read_sparse_nibble_storage(data, &mut offset, false)?;
let upper_data = read_sparse_nibble_storage(data, &mut offset, false)?;
let lower_sky = read_sparse_nibble_storage(data, &mut offset, true)?;
let upper_sky = read_sparse_nibble_storage(data, &mut offset, true)?;
let lower_block_light = read_sparse_nibble_storage(data, &mut offset, true)?;
let upper_block_light = read_sparse_nibble_storage(data, &mut offset, true)?;
let height_map = read_sized_bytes(data, &mut offset, 256);
let terrain_populated_flags = read_be_i16(data, offset);
offset += 2;
let biomes = read_sized_bytes(data, &mut offset, 256);
let dynamic_root = if offset < data.len() {
nbt::read_nbt(&data[offset..]).unwrap_or_default()
} else {
nbt::NbtCompound::default()
};
let mut level = nbt::NbtCompound::new("Level");
level.insert("xPos", nbt::NbtValue::Int(chunk_x));
level.insert("zPos", nbt::NbtValue::Int(chunk_z));
level.insert("LastUpdate", nbt::NbtValue::Long(last_update));
level.insert("InhabitedTime", nbt::NbtValue::Long(inhabited_time));
level.insert(
"Blocks",
nbt::NbtValue::ByteArray(combine_block_sections(&lower_blocks, &upper_blocks)),
);
level.insert(
"Data",
nbt::NbtValue::ByteArray(combine_nibble_sections(&lower_data, &upper_data)),
);
level.insert(
"SkyLight",
nbt::NbtValue::ByteArray(combine_nibble_sections(&lower_sky, &upper_sky)),
);
level.insert(
"BlockLight",
nbt::NbtValue::ByteArray(combine_nibble_sections(
&lower_block_light,
&upper_block_light,
)),
);
level.insert("HeightMap", nbt::NbtValue::ByteArray(height_map));
level.insert(
"TerrainPopulatedFlags",
nbt::NbtValue::Short(terrain_populated_flags),
);
level.insert("Biomes", nbt::NbtValue::ByteArray(biomes));
let entities = dynamic_root
.list("Entities")
.map(|l| nbt::NbtValue::List(l.to_vec()))
.unwrap_or_else(|| nbt::NbtValue::List(Vec::new()));
level.insert("Entities", entities);
let tile_entities = dynamic_root
.list("TileEntities")
.map(|l| nbt::NbtValue::List(l.to_vec()))
.unwrap_or_else(|| nbt::NbtValue::List(Vec::new()));
level.insert("TileEntities", tile_entities);
if let Some(tile_ticks) = dynamic_root.get("TileTicks") {
level.insert("TileTicks", tile_ticks.clone());
}
let mut root = nbt::NbtCompound::new("");
root.insert("Level", nbt::NbtValue::Compound(level));
Ok(root)
}
pub fn try_get_compressed_chunk_nbt_offset(data: &[u8]) -> Option<usize> {
if !is_compressed_chunk_storage(data) {
return None;
}
let mut offset = 0;
let version = read_be_i16(data, offset);
offset = 2;
if version != SAVE_FILE_VERSION_COMPRESSED_CHUNK_STORAGE
&& version != SAVE_FILE_VERSION_CHUNK_INHABITED_TIME
{
return None;
}
offset += 8;
if version >= SAVE_FILE_VERSION_CHUNK_INHABITED_TIME {
offset += 8;
}
let r1 = skip_compressed_tile_storage(data, &mut offset);
if r1.is_err() { return None; }
let r2 = skip_compressed_tile_storage(data, &mut offset);
if r2.is_err() { return None; }
let r3 = skip_sparse_nibble_storage(data, &mut offset);
if r3.is_err() { return None; }
let r4 = skip_sparse_nibble_storage(data, &mut offset);
if r4.is_err() { return None; }
let r5 = skip_sparse_nibble_storage(data, &mut offset);
if r5.is_err() { return None; }
let r6 = skip_sparse_nibble_storage(data, &mut offset);
if r6.is_err() { return None; }
let r7 = skip_sparse_nibble_storage(data, &mut offset);
if r7.is_err() { return None; }
let r8 = skip_sparse_nibble_storage(data, &mut offset);
if r8.is_err() { return None; }
offset += 256;
offset += 2;
offset += 256;
if offset > data.len() {
return None;
}
Some(offset)
}
+802
View File
@@ -0,0 +1,802 @@
use std::collections::HashMap;
use std::fs;
use std::io::{Read, Write};
use std::path::Path;
use flate2::read::ZlibDecoder;
use flate2::write::ZlibEncoder;
use flate2::Compression;
use super::nbt::{self, NbtCompound, NbtValue};
const SECTOR_BYTES: usize = 4096;
pub struct JavaWorldReader {
world_path: String,
region_readers: HashMap<String, JavaRegionReader>,
}
pub struct JavaRegionReader {
pub data: Vec<u8>,
pub offsets: [u32; 1024],
}
impl JavaWorldReader {
pub fn new(world_path: &str) -> Self {
Self {
world_path: world_path.to_string(),
region_readers: HashMap::new(),
}
}
pub fn read_level_dat(&self) -> Result<NbtCompound, String> {
let path = Path::new(&self.world_path).join("level.dat");
let data = fs::read(&path).map_err(|e| format!("Failed to read level.dat: {}", e))?;
nbt::read_gzip_nbt(&data)
}
pub fn get_region_dir(&self, dimension: &str) -> String {
if dimension.is_empty() {
Path::new(&self.world_path)
.join("region")
.to_string_lossy()
.to_string()
} else {
Path::new(&self.world_path)
.join(dimension)
.join("region")
.to_string_lossy()
.to_string()
}
}
pub fn get_region_files(&self, dimension: &str) -> Vec<(i32, i32, String)> {
let mut result = Vec::new();
let dir = self.get_region_dir(dimension);
let dir_path = Path::new(&dir);
if !dir_path.is_dir() {
return result;
}
if let Ok(entries) = fs::read_dir(dir_path) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let lower = name.to_lowercase();
if lower.ends_with(".mcr") || lower.ends_with(".mca") {
let parts: Vec<&str> = name.split('.').collect();
if parts.len() == 4 && parts[0] == "r" {
if let (Ok(rx), Ok(rz)) =(parts[1].parse::<i32>(), parts[2].parse::<i32>())
{
result.push((rx, rz, entry.path().to_string_lossy().to_string()));
}
}
}
}
}
result
}
pub fn is_anvil_world(&self) -> bool {
let region_dir = self.get_region_dir("");
let dir_path = Path::new(&region_dir);
if !dir_path.is_dir() {
return false;
}
if let Ok(entries) = fs::read_dir(dir_path) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.to_lowercase().ends_with(".mca") {
return true;
}
}
}
false
}
fn get_or_create_reader(&mut self, region_path: &str) -> Result<&JavaRegionReader, String> {
if !self.region_readers.contains_key(region_path) {
let reader = JavaRegionReader::open(region_path)?;
self.region_readers.insert(region_path.to_string(), reader);
}
Ok(self.region_readers.get(region_path).unwrap())
}
pub fn has_chunk(&mut self, region_path: &str, local_x: i32, local_z: i32) -> bool {
let reader = match self.get_or_create_reader(region_path) {
Ok(r) => r,
Err(_) => return false,
};
let index = (local_x & 31) + (local_z & 31) * 32;
reader.offsets[index as usize] != 0
}
pub fn read_chunk_nbt(
&mut self,
region_path: &str,
local_x: i32,
local_z: i32,
) -> Result<Option<NbtCompound>, String> {
let reader = self.get_or_create_reader(region_path)?;
let index = (local_x & 31) + (local_z & 31) * 32;
let offset_entry = reader.offsets[index as usize];
if offset_entry == 0 {
return Ok(None);
}
let sector_offset = (offset_entry >> 8) as usize;
let chunk_pos = sector_offset * SECTOR_BYTES;
let data_len = reader.data.len();
if chunk_pos + 5 > data_len {
return Ok(None);
}
let length = u32::from_be_bytes([
reader.data[chunk_pos],
reader.data[chunk_pos + 1],
reader.data[chunk_pos + 2],
reader.data[chunk_pos + 3],
]) as usize;
let compression_type = reader.data[chunk_pos + 4];
if length <= 1 {
return Ok(None);
}
let compressed_length = length - 1;
if chunk_pos + 5 + compressed_length > data_len {
return Ok(None);
}
let compressed_data = &reader.data[chunk_pos + 5..chunk_pos + 5 + compressed_length];
let decompressed = match compression_type {
1 => {
let mut decoder = flate2::read::GzDecoder::new(compressed_data);
let mut buf = Vec::new();
decoder.read_to_end(&mut buf).map_err(|e| format!("gzip decompress failed: {}", e))?;
buf
}
2 => {
let mut decoder = ZlibDecoder::new(compressed_data);
let mut buf = Vec::new();
decoder
.read_to_end(&mut buf)
.map_err(|e| format!("zlib decompress failed: {}", e))?;
buf
}
_ => return Ok(None),
};
if decompressed.is_empty() {
return Ok(None);
}
let compound = nbt::read_nbt(&decompressed)?;
Ok(Some(compound))
}
}
impl JavaRegionReader {
fn open(path: &str) -> Result<Self, String> {
let data = fs::read(path).map_err(|e| format!("Failed to read region file: {}", e))?;
let mut offsets = [0u32; 1024];
for i in 0..1024 {
let off = i * 4;
if off + 4 > data.len() {
break;
}
offsets[i] = u32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]);
}
Ok(Self { data, offsets })
}
pub fn open_from_bytes(data: &[u8]) -> Self {
let data = data.to_vec();
let mut offsets = [0u32; 1024];
for i in 0..1024 {
let off = i * 4;
if off + 4 > data.len() {
break;
}
offsets[i] = u32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]);
}
Self { data, offsets }
}
pub fn read_chunk(
&mut self,
local_x: i32,
local_z: i32,
) -> Result<Option<Vec<u8>>, String> {
let index = (local_x & 31) + (local_z & 31) * 32;
let offset_entry = self.offsets[index as usize];
if offset_entry == 0 {
return Ok(None);
}
let sector_offset = (offset_entry >> 8) as usize;
let chunk_pos = sector_offset * SECTOR_BYTES;
let data_len = self.data.len();
if chunk_pos + 5 > data_len {
return Ok(None);
}
let length = u32::from_be_bytes([
self.data[chunk_pos],
self.data[chunk_pos + 1],
self.data[chunk_pos + 2],
self.data[chunk_pos + 3],
]) as usize;
let compression_type = self.data[chunk_pos + 4];
if length <= 1 {
return Ok(None);
}
let compressed_length = length - 1;
if chunk_pos + 5 + compressed_length > data_len {
return Ok(None);
}
let compressed_data = &self.data[chunk_pos + 5..chunk_pos + 5 + compressed_length];
let decompressed = match compression_type {
1 => {
let mut decoder = flate2::read::GzDecoder::new(compressed_data);
let mut buf = Vec::new();
decoder
.read_to_end(&mut buf)
.map_err(|e| format!("gzip decompress failed: {}", e))?;
buf
}
2 => {
let mut decoder = ZlibDecoder::new(compressed_data);
let mut buf = Vec::new();
decoder
.read_to_end(&mut buf)
.map_err(|e| format!("zlib decompress failed: {}", e))?;
buf
}
_ => return Ok(None),
};
if decompressed.is_empty() {
return Ok(None);
}
Ok(Some(decompressed))
}
}
pub struct JavaRegionFileWriter {
path: String,
buffer: Vec<u8>,
offsets: [i32; 1024],
timestamps: [i32; 1024],
next_sector: usize,
}
impl JavaRegionFileWriter {
pub fn new(path: &str) -> Self {
let buffer = vec![0u8; SECTOR_BYTES * 2];
let offsets = [0i32; 1024];
let timestamps = [0i32; 1024];
Self {
path: path.to_string(),
buffer,
offsets,
timestamps,
next_sector: 2,
}
}
pub fn load_from_file(path: &str) -> Self {
if let Ok(data) = fs::read(path) {
let buffer = data.clone();
let mut offsets = [0i32; 1024];
let mut timestamps = [0i32; 1024];
for i in 0..1024 {
let pos = i * 4;
if pos + 4 <= data.len() {
offsets[i] = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
}
}
for i in 0..1024 {
let pos = SECTOR_BYTES + i * 4;
if pos + 4 <= data.len() {
timestamps[i] = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
}
}
let mut max_sector = 2;
for &off in &offsets {
if off != 0 {
let sector = (off >> 8) as usize;
let count = (off & 0xFF) as usize;
let end = sector + count;
if end > max_sector {
max_sector = end;
}
}
}
Self {
path: path.to_string(),
buffer,
offsets,
timestamps,
next_sector: max_sector,
}
} else {
Self::new(path)
}
}
pub fn write_chunk(&mut self, local_x: i32, local_z: i32, uncompressed_nbt: &[u8]) {
if local_x < 0 || local_x > 31 || local_z < 0 || local_z > 31 {
return;
}
let compressed = compress_zlib(uncompressed_nbt);
let payload_length = 1 + compressed.len();
let total_length = 4 + payload_length;
let sectors_needed = (total_length + SECTOR_BYTES - 1) / SECTOR_BYTES;
if sectors_needed >= 256 {
return;
}
let sector_start = self.next_sector;
self.next_sector += sectors_needed;
while self.buffer.len() < (sector_start + sectors_needed) * SECTOR_BYTES {
self.buffer.extend(std::iter::repeat(0u8).take(SECTOR_BYTES));
}
let write_pos = sector_start * SECTOR_BYTES;
self.buffer[write_pos..write_pos + 4]
.copy_from_slice(&(payload_length as u32).to_be_bytes());
self.buffer[write_pos + 4] = 2;
self.buffer[write_pos + 5..write_pos + 5 + compressed.len()]
.copy_from_slice(&compressed);
let padding = sectors_needed * SECTOR_BYTES - total_length;
if padding > 0 {
for i in 0..padding {
self.buffer[write_pos + total_length + i] = 0;
}
}
let offset_index = (local_x & 31) + (local_z & 31) * 32;
self.offsets[offset_index as usize] = ((sector_start as i32) << 8) | (sectors_needed as i32);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i32;
self.timestamps[offset_index as usize] = now;
}
pub fn save(&mut self) -> Result<(), String> {
while self.buffer.len() < self.next_sector * SECTOR_BYTES {
self.buffer.extend(std::iter::repeat(0u8).take(SECTOR_BYTES));
}
for i in 0..1024 {
let pos = i * 4;
self.buffer[pos..pos + 4].copy_from_slice(&(self.offsets[i] as u32).to_be_bytes());
}
for i in 0..1024 {
let pos = SECTOR_BYTES + i * 4;
self.buffer[pos..pos + 4]
.copy_from_slice(&(self.timestamps[i] as u32).to_be_bytes());
}
let total = self.next_sector * SECTOR_BYTES;
let data = &self.buffer[..total];
if let Some(parent) = Path::new(&self.path).parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create region directory: {}", e))?;
}
fs::write(&self.path, data)
.map_err(|e| format!("Failed to write region file: {}", e))?;
Ok(())
}
}
pub struct LceRegionFile {
filename: String,
data: Vec<u8>,
offsets: [i32; 1024],
timestamps: [i32; 1024],
region_x: i32,
region_z: i32,
sector_count: usize,
}
impl LceRegionFile {
pub fn new(filename: &str) -> Self {
let (region_x, region_z) = parse_region_coordinates(filename);
let data = vec![0u8; SECTOR_BYTES * 2];
Self {
filename: filename.to_string(),
data,
offsets: [0i32; 1024],
timestamps: [0i32; 1024],
region_x,
region_z,
sector_count: 2,
}
}
pub fn write_chunk(&mut self, x: i32, z: i32, mut uncompressed_data: Vec<u8>) {
if x < 0 || x >= 32 || z < 0 || z >= 32 {
return;
}
if uncompressed_data.is_empty() {
return;
}
uncompressed_data = force_chunk_coordinates(
&uncompressed_data,
self.region_x * 32 + x,
self.region_z * 32 + z,
);
if uncompressed_data.is_empty() {
return;
}
let compressed = compress_zlib_only(&uncompressed_data);
let total_size = 8 + compressed.len();
let sectors_needed = (total_size + SECTOR_BYTES - 1) / SECTOR_BYTES;
if sectors_needed >= 256 {
return;
}
let sector_number = self.sector_count;
self.sector_count += sectors_needed;
while self.data.len() < (sector_number + sectors_needed) * SECTOR_BYTES {
self.data.extend(std::iter::repeat(0u8).take(SECTOR_BYTES));
}
let write_pos = sector_number * SECTOR_BYTES;
let stored_length = compressed.len() as u32;
self.data[write_pos..write_pos + 4]
.copy_from_slice(&stored_length.to_le_bytes());
self.data[write_pos + 4..write_pos + 8]
.copy_from_slice(&(uncompressed_data.len() as u32).to_le_bytes());
self.data[write_pos + 8..write_pos + 8 + compressed.len()]
.copy_from_slice(&compressed);
let padding = sectors_needed * SECTOR_BYTES - total_size;
if padding > 0 {
for i in 0..padding {
self.data[write_pos + total_size + i] = 0;
}
}
let offset_index = (x + z * 32) as usize;
self.offsets[offset_index] = ((sector_number as i32) << 8) | (sectors_needed as i32);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i32;
self.timestamps[offset_index] = now;
}
pub fn write_to_container(&mut self, container: &mut SaveDataContainer) {
let _sorted_entries: Vec<(usize, i32, i32)> = (0..1024)
.map(|i| (i, self.offsets[i], self.timestamps[i]))
.filter(|(_, off, _)| *off != 0)
.collect();
for i in 0..1024 {
let pos = i * 4;
self.data[pos..pos + 4].copy_from_slice(&(self.offsets[i] as u32).to_le_bytes());
}
for i in 0..1024 {
let pos = SECTOR_BYTES + i * 4;
self.data[pos..pos + 4]
.copy_from_slice(&(self.timestamps[i] as u32).to_le_bytes());
}
let total = self.sector_count * SECTOR_BYTES;
let region_bytes = self.data[..total].to_vec();
let entry = container.create_file(&self.filename);
container.write_to_file(entry, &region_bytes);
}
}
fn parse_region_coordinates(filename: &str) -> (i32, i32) {
let name = Path::new(filename)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let parts: Vec<&str> = name.split('.').collect();
if parts.len() < 3 {
return (0, 0);
}
let rx = parts[parts.len() - 2].parse().unwrap_or(0);
let rz = parts[parts.len() - 1].parse().unwrap_or(0);
(rx, rz)
}
pub fn force_chunk_coordinates(data: &[u8], expected_x: i32, expected_z: i32) -> Vec<u8> {
if data.is_empty() {
return Vec::new();
}
if let Ok(mut compound) = nbt::read_nbt(data) {
if compound.get("Level").is_some() {
if let Some(nbt::NbtValue::Compound(c)) = compound.get_mut("Level") {
c.insert("xPos", NbtValue::Int(expected_x));
c.insert("zPos", NbtValue::Int(expected_z));
}
return nbt::write_nbt(&compound);
}
compound.insert("xPos", NbtValue::Int(expected_x));
compound.insert("zPos", NbtValue::Int(expected_z));
let mut root = NbtCompound::new("");
root.insert("Level", NbtValue::Compound(compound));
nbt::write_nbt(&root)
} else if data.len() >= 10 {
let version = i16::from_be_bytes([data[0], data[1]]);
if version == 8 || version == 9 {
let mut patched = data.to_vec();
patched[2..6].copy_from_slice(&expected_x.to_be_bytes());
patched[6..10].copy_from_slice(&expected_z.to_be_bytes());
return patched;
}
Vec::new()
} else {
Vec::new()
}
}
pub struct SaveDataContainer {
original_save_version: i16,
current_save_version: i16,
entries: Vec<SaveFileEntry>,
blob: Vec<u8>,
data_end: usize,
}
pub struct SaveFileEntry {
pub name: String,
pub length: u32,
pub start_offset: u32,
pub last_mod: i64,
pub current_pointer: u32,
}
impl SaveDataContainer {
pub const FILE_ENTRY_SIZE: usize = 144;
pub fn new(original_save_version: i16, current_save_version: i16) -> Self {
Self {
original_save_version,
current_save_version,
entries: Vec::new(),
blob: vec![0u8; 2 * 1024 * 1024],
data_end: 12,
}
}
pub fn create_file(&mut self, name: &str) -> usize {
if let Some(idx) = self.entries.iter().position(|e| e.name == name) {
return idx;
}
let entry = SaveFileEntry {
name: name.to_string(),
length: 0,
start_offset: self.data_end as u32,
last_mod: 0,
current_pointer: self.data_end as u32,
};
self.entries.push(entry);
self.entries.len() - 1
}
pub fn write_to_file(&mut self, index: usize, data: &[u8]) {
if data.is_empty() {
return;
}
let needs_reinit = self.entries[index].length == 0;
if needs_reinit {
self.entries[index].start_offset = self.data_end as u32;
self.entries[index].current_pointer = self.data_end as u32;
}
let write_pos = self.entries[index].current_pointer as usize;
let end_pos = write_pos + data.len();
self.ensure_capacity(end_pos);
self.blob[write_pos..write_pos + data.len()].copy_from_slice(data);
self.entries[index].current_pointer += data.len() as u32;
let written = self.entries[index].current_pointer - self.entries[index].start_offset;
if written > self.entries[index].length {
self.entries[index].length = written;
}
let entry_end = (self.entries[index].start_offset + self.entries[index].length) as usize;
if entry_end > self.data_end {
self.data_end = entry_end;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
self.entries[index].last_mod = now;
}
pub fn save(&mut self, output_path: &str) -> Result<(), String> {
self.write_header();
let total_size = self.data_end + self.entries.len() * Self::FILE_ENTRY_SIZE;
self.ensure_capacity(total_size);
self.write_footer();
let raw_blob = self.blob[..total_size].to_vec();
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
encoder
.write_all(&raw_blob)
.map_err(|e| format!("zlib compress failed: {}", e))?;
let compressed_data = encoder
.finish()
.map_err(|e| format!("zlib finish failed: {}", e))?;
if let Some(parent) = Path::new(output_path).parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create output directory: {}", e))?;
}
let mut file = fs::File::create(output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
file.write_all(&(0u32).to_le_bytes())
.map_err(|e| format!("write failed: {}", e))?;
file.write_all(&(total_size as u32).to_le_bytes())
.map_err(|e| format!("write failed: {}", e))?;
file.write_all(&compressed_data)
.map_err(|e| format!("write failed: {}", e))?;
Ok(())
}
fn write_header(&mut self) {
self.blob[0..4]
.copy_from_slice(&(self.data_end as u32).to_le_bytes());
self.blob[4..8]
.copy_from_slice(&(self.entries.len() as u32).to_le_bytes());
self.blob[8..10]
.copy_from_slice(&self.original_save_version.to_le_bytes());
self.blob[10..12]
.copy_from_slice(&self.current_save_version.to_le_bytes());
}
fn write_footer(&mut self) {
let mut sorted: Vec<usize> = (0..self.entries.len()).collect();
sorted.sort_by_key(|&i| self.entries[i].start_offset);
let mut pos = self.data_end;
for &i in &sorted {
let entry = &self.entries[i];
let mut name_bytes = vec![0u8; 128];
let encoded: Vec<u8> = entry
.name
.encode_utf16()
.flat_map(|c| c.to_le_bytes())
.collect();
let copy_len = encoded.len().min(126);
name_bytes[..copy_len].copy_from_slice(&encoded[..copy_len]);
self.blob[pos..pos + 128].copy_from_slice(&name_bytes);
pos += 128;
self.blob[pos..pos + 4].copy_from_slice(&entry.length.to_le_bytes());
pos += 4;
self.blob[pos..pos + 4].copy_from_slice(&entry.start_offset.to_le_bytes());
pos += 4;
self.blob[pos..pos + 8].copy_from_slice(&entry.last_mod.to_le_bytes());
pos += 8;
}
}
fn ensure_capacity(&mut self, required: usize) {
if required <= self.blob.len() {
return;
}
let mut new_size = self.blob.len();
while new_size < required {
new_size *= 2;
}
self.blob.resize(new_size, 0);
}
}
pub fn compress_zlib(data: &[u8]) -> Vec<u8> {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
encoder.write_all(data).unwrap();
encoder.finish().unwrap()
}
pub fn compress_zlib_only(data: &[u8]) -> Vec<u8> {
compress_zlib(data)
}
pub fn decompress_zlib(data: &[u8]) -> Result<Vec<u8>, String> {
let mut decoder = ZlibDecoder::new(data);
let mut buf = Vec::new();
decoder.read_to_end(&mut buf).map_err(|e| format!("zlib decompress failed: {}", e))?;
Ok(buf)
}
pub fn decompress_rle_zlib(data: &[u8], decompressed_size: usize) -> Result<Vec<u8>, String> {
let rle_data = decompress_zlib(data)?;
Ok(rle_decode(&rle_data, decompressed_size))
}
pub fn rle_encode(data: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut i = 0;
while i < data.len() {
let current = data[i];
i += 1;
let mut count = 1usize;
while i < data.len() && data[i] == current && count < 256 {
i += 1;
count += 1;
}
if count <= 3 {
if current == 0xFF {
out.push(0xFF);
out.push((count - 1) as u8);
} else {
for _ in 0..count {
out.push(current);
}
}
} else {
out.push(0xFF);
out.push((count - 1) as u8);
out.push(current);
}
}
out
}
pub fn rle_decode(data: &[u8], expected_size: usize) -> Vec<u8> {
let mut output = vec![0u8; expected_size];
let mut in_pos = 0;
let mut out_pos = 0;
while in_pos < data.len() && out_pos < expected_size {
let current = data[in_pos];
in_pos += 1;
if current == 0xFF {
if in_pos >= data.len() {
break;
}
let count = data[in_pos] as usize;
in_pos += 1;
if count < 3 {
let run = count + 1;
for _ in 0..run {
if out_pos < expected_size {
output[out_pos] = 0xFF;
out_pos += 1;
}
}
} else {
let run = count + 1;
if in_pos >= data.len() {
break;
}
let value = data[in_pos];
in_pos += 1;
for _ in 0..run {
if out_pos < expected_size {
output[out_pos] = value;
out_pos += 1;
}
}
}
} else {
output[out_pos] = current;
out_pos += 1;
}
}
output
}
+1
View File
@@ -4,6 +4,7 @@ pub mod dlc;
pub mod download; pub mod download;
pub mod file_dialogs; pub mod file_dialogs;
pub mod game; pub mod game;
pub mod java2lce;
pub mod macos_setup; pub mod macos_setup;
pub mod plugins; pub mod plugins;
pub mod proxy_cmd; pub mod proxy_cmd;
+177 -93
View File
@@ -1,9 +1,176 @@
use std::fs; use std::fs;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use tauri::AppHandle; use tauri::{AppHandle, Emitter};
use crate::types::{WorkshopInstallRequest, InstalledWorkshopPackage, InstalledPackageEntry}; use crate::types::{WorkshopInstallRequest, InstalledWorkshopPackage, InstalledPackageEntry};
use crate::config; use crate::config;
use crate::util; use crate::util;
use super::download::DownloadProgress;
fn group_archives(zips: &std::collections::HashMap<String, String>) -> Vec<(Vec<String>, String)> {
let mut groups: Vec<(Vec<String>, String)> = Vec::new();
let mut consumed: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut entries: Vec<(&String, &String)> = zips.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
for &(name, dest) in &entries {
if consumed.contains(name) {
continue;
}
if !name.to_lowercase().ends_with(".zip") {
continue;
}
let base = &name[..name.len() - 4];
let mut parts = vec![name.clone()];
consumed.insert(name.clone());
let mut n = 1;
loop {
let candidate = format!("{}.z{:02}", base, n);
if zips.contains_key(&candidate) {
parts.push(candidate.clone());
consumed.insert(candidate);
n += 1;
} else {
break;
}
}
if parts.len() == 1 {
let mut n = 1;
loop {
let candidate = format!("{}.zip.{:03}", base, n);
if zips.contains_key(&candidate) {
parts.push(candidate.clone());
consumed.insert(candidate);
n += 1;
} else {
break;
}
}
}
groups.push((parts, dest.clone()));
}
for &(name, dest) in &entries {
if !consumed.contains(name) {
consumed.insert(name.clone());
groups.push((vec![name.clone()], dest.clone()));
}
}
groups
}
async fn download_and_assemble(
app: &AppHandle,
tmp_dir: &Path,
parts: &[String],
raw_base: &str,
package_id: &str,
done: &mut usize,
total: usize,
) -> Result<PathBuf, String> {
let mut downloaded: Vec<PathBuf> = Vec::new();
for part in parts {
let url = format!("{}/{}", raw_base, part);
let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("Failed to download {}: HTTP {}", part, response.status()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
let part_path = tmp_dir.join(part);
fs::write(&part_path, &bytes).map_err(|e| e.to_string())?;
*done += 1;
let _ = app.emit("workshop-progress", DownloadProgress {
instance_id: package_id.to_string(),
percent: (*done as f64 / total.max(1) as f64) * 100.0,
});
downloaded.push(part_path);
}
if downloaded.len() == 1 {
return Ok(downloaded.remove(0));
}
let combined_dir = tmp_dir.join("_combined");
fs::create_dir_all(&combined_dir).map_err(|e| e.to_string())?;
let combined = combined_dir.join(parts[0].clone());
let mut out = std::io::BufWriter::new(fs::File::create(&combined).map_err(|e| e.to_string())?);
for path in &downloaded {
let mut f = std::io::BufReader::new(fs::File::open(path).map_err(|e| e.to_string())?);
std::io::copy(&mut f, &mut out).map_err(|e| e.to_string())?;
}
out.flush().map_err(|e| e.to_string())?;
Ok(combined)
}
fn extract_archive(zip_tmp: &Path, dest_dir: &Path) -> Result<(Vec<String>, bool), String> {
let zip_str = zip_tmp.to_str().unwrap_or("");
if cfg!(target_os = "linux") {
let mut extract_ok = false;
let mut files: Vec<String> = Vec::new();
if let Ok(out) = std::process::Command::new("bsdtar")
.args(["-tf", zip_str])
.output()
{
if out.status.success() {
let listing = String::from_utf8_lossy(&out.stdout);
files = listing.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.ends_with('/'))
.map(|l| dest_dir.join(l).to_string_lossy().to_string())
.collect();
let st = std::process::Command::new("bsdtar")
.args(["-xf", zip_str, "-C", dest_dir.to_str().unwrap()])
.status()
.map_err(|e| e.to_string())?;
extract_ok = st.success();
}
}
if !extract_ok {
let unzip_list = std::process::Command::new("unzip")
.args(["-l", zip_str])
.output()
.map_err(|e| e.to_string())?;
if !unzip_list.status.success() {
return Err(format!("Failed to list contents of {}", zip_str));
}
let listing = String::from_utf8_lossy(&unzip_list.stdout);
files = listing.lines()
.filter_map(|l| {
let mut parts = l.trim().split_whitespace();
let size_str = parts.next()?;
size_str.parse::<u64>().ok()?;
parts.next()?;
parts.next()?;
Some(parts.collect::<Vec<&str>>().join(" "))
})
.filter(|l| !l.ends_with('/') && !l.contains('*'))
.map(|l| dest_dir.join(l).to_string_lossy().to_string())
.collect();
let st = std::process::Command::new("unzip")
.args(["-o", zip_str, "-d", dest_dir.to_str().unwrap()])
.status()
.map_err(|e| e.to_string())?;
extract_ok = st.success();
}
Ok((files, extract_ok))
} else {
let st = std::process::Command::new("tar")
.args(["-xf", zip_str, "-C", dest_dir.to_str().unwrap()])
.output()
.map_err(|e| e.to_string())?;
let listing = std::process::Command::new("tar")
.args(["-tf", zip_str])
.output()
.map_err(|e| e.to_string())?;
let listing_str = String::from_utf8_lossy(&listing.stdout);
let files: Vec<String> = listing_str.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.ends_with('/'))
.map(|l| dest_dir.join(l).to_string_lossy().to_string())
.collect();
Ok((files, st.status.success()))
}
}
#[tauri::command] #[tauri::command]
pub async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -> Result<(), String> { pub async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -> Result<(), String> {
let instance_dir = util::get_instance_working_dir(&app, &request.instance_id); let instance_dir = util::get_instance_working_dir(&app, &request.instance_id);
@@ -30,111 +197,28 @@ pub async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -
workshop_packages.retain(|p| p.id != request.package_id); workshop_packages.retain(|p| p.id != request.package_id);
let raw_base = format!("https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/{}", request.package_id); let raw_base = format!("https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/{}", request.package_id);
let tmp_dir = root.join(format!("workshop_tmp_{}", request.package_id)); let tmp_dir = root.join(format!("workshop_tmp_{}", request.package_id));
let _ = fs::remove_dir_all(&tmp_dir);
fs::create_dir_all(&tmp_dir).map_err(|e| e.to_string())?; fs::create_dir_all(&tmp_dir).map_err(|e| e.to_string())?;
let mut pkg_dirs: Vec<String> = Vec::new(); let mut pkg_dirs: Vec<String> = Vec::new();
for (zip_name, placeholder) in &request.zips { let total_parts = request.zips.len();
let zip_url = format!("{}/{}", raw_base, zip_name); let mut done_parts = 0usize;
let zip_tmp = tmp_dir.join(zip_name); for (parts, placeholder) in group_archives(&request.zips) {
let response = reqwest::get(&zip_url).await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
let _ = fs::remove_dir_all(&tmp_dir);
return Err(format!("Failed to download {}: HTTP {}", zip_name, response.status()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
fs::write(&zip_tmp, &bytes).map_err(|e| e.to_string())?;
let dest_dir = if placeholder.is_empty() { let dest_dir = if placeholder.is_empty() {
instance_dir.clone() instance_dir.clone()
} else { } else {
let resolved = instance_dir.clone().join(placeholder instance_dir.clone().join(placeholder
.replace("{MediaDir}", media_dir.to_str().unwrap_or("")) .replace("{MediaDir}", media_dir.to_str().unwrap_or(""))
.replace("{DLCDir}", dlc_dir.to_str().unwrap_or("")) .replace("{DLCDir}", dlc_dir.to_str().unwrap_or(""))
.replace("{GameHDD}", game_hdd.to_str().unwrap_or("")) .replace("{GameHDD}", game_hdd.to_str().unwrap_or(""))
.replace("{MobDir}", mob_dir.to_str().unwrap_or(""))); .replace("{MobDir}", mob_dir.to_str().unwrap_or("")))
PathBuf::from(resolved)
}; };
fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?; fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?;
let (extracted_files, extract_ok) = if cfg!(target_os = "linux") { let archive_tmp = download_and_assemble(&app, &tmp_dir, &parts, &raw_base, &request.package_id, &mut done_parts, total_parts).await?;
let bsdtar_list = std::process::Command::new("bsdtar") let (extracted_files, extract_ok) = extract_archive(&archive_tmp, &dest_dir)?;
.args(["-tf", zip_tmp.to_str().unwrap()])
.output();
if let Ok(out) = bsdtar_list {
if out.status.success() {
let listing = String::from_utf8_lossy(&out.stdout);
let files: Vec<String> = listing.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.ends_with('/'))
.map(|l| dest_dir.join(l).to_string_lossy().to_string())
.collect();
let st = std::process::Command::new("bsdtar")
.args(["-xf", zip_tmp.to_str().unwrap(), "-C", dest_dir.to_str().unwrap()])
.status()
.map_err(|e| e.to_string())?;
(files, st.success())
} else {
(Vec::new(), false)
}
} else {
(Vec::new(), false)
}
} else {
(Vec::new(), false)
};
#[cfg(target_os = "linux")]
let (extracted_files, extract_ok) = if !extract_ok {
let unzip_list = std::process::Command::new("unzip")
.args(["-l", zip_tmp.to_str().unwrap()])
.output()
.map_err(|e| e.to_string())?;
if !unzip_list.status.success() {
let _ = fs::remove_dir_all(&tmp_dir);
return Err(format!("Failed to list contents of {}", zip_name));
}
let listing = String::from_utf8_lossy(&unzip_list.stdout);
let files: Vec<String> = listing.lines()
.filter_map(|l| {
let mut parts = l.trim().split_whitespace();
let size_str = parts.next()?;
size_str.parse::<u64>().ok()?;
parts.next()?;
parts.next()?;
Some(parts.collect::<Vec<&str>>().join(" "))
})
.filter(|l| !l.ends_with('/') && !l.contains('*'))
.map(|l| dest_dir.join(l).to_string_lossy().to_string())
.collect();
let st = std::process::Command::new("unzip")
.args(["-o", zip_tmp.to_str().unwrap(), "-d", dest_dir.to_str().unwrap()])
.status()
.map_err(|e| e.to_string())?;
(files, st.success())
} else {
(extracted_files, extract_ok)
};
#[cfg(not(target_os = "linux"))]
let (extracted_files, extract_ok) = {
let st = std::process::Command::new("tar")
.args(["-xf", zip_tmp.to_str().unwrap(), "-C", dest_dir.to_str().unwrap()])
.output()
.map_err(|e| e.to_string())?;
let listing = std::process::Command::new("tar")
.args(["-tf", zip_tmp.to_str().unwrap()])
.output()
.map_err(|e| e.to_string())?;
let listing_str = String::from_utf8_lossy(&listing.stdout);
let files: Vec<String> = listing_str.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.ends_with('/'))
.map(|l| dest_dir.join(l).to_string_lossy().to_string())
.collect();
(files, st.status.success())
};
if !extract_ok { if !extract_ok {
let _ = fs::remove_dir_all(&tmp_dir); let _ = fs::remove_dir_all(&tmp_dir);
return Err(format!("Extraction failed for {}", zip_name)); return Err(format!("Extraction failed for {}", parts[0]));
} }
for f in &extracted_files { for f in &extracted_files {
+5
View File
@@ -9,6 +9,7 @@ mod workshop_server;
mod commands; mod commands;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
use commands::config as config_cmds; use commands::config as config_cmds;
@@ -54,6 +55,7 @@ pub fn run() {
.manage(GameState { .manage(GameState {
child: Arc::new(Mutex::new(None)), child: Arc::new(Mutex::new(None)),
workshop_cancel: Arc::new(Mutex::new(None)), workshop_cancel: Arc::new(Mutex::new(None)),
manual_stop: Arc::new(AtomicBool::new(false)),
}) })
.manage(ProxyGuard { .manage(ProxyGuard {
cancel_tokens: Arc::new(Mutex::new(HashMap::new())), cancel_tokens: Arc::new(Mutex::new(HashMap::new())),
@@ -108,6 +110,9 @@ pub fn run() {
game::backup_instance, game::backup_instance,
game::restore_instance, game::restore_instance,
commands::console2lce::import_world, commands::console2lce::import_world,
commands::console2lce::import_lce_save,
commands::java2lce::java_to_lce,
commands::java2lce::lce_to_java,
stun::stun_discover, stun::stun_discover,
relay::start_relay_proxy, relay::start_relay_proxy,
relay::start_host_relay, relay::start_host_relay,
-2
View File
@@ -1,8 +1,6 @@
use tauri::State; use tauri::State;
use tauri::webview::cookie::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
use tokio::time::sleep;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::state::ProxyGuard; use crate::state::ProxyGuard;
const PROXY_ADDR: &str = "proxy.mclegacyedition.xyz:2052"; //neo: yeah bro im hardcoding it const PROXY_ADDR: &str = "proxy.mclegacyedition.xyz:2052"; //neo: yeah bro im hardcoding it
+2
View File
@@ -1,5 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
pub struct DownloadState { pub struct DownloadState {
@@ -9,6 +10,7 @@ pub struct DownloadState {
pub struct GameState { pub struct GameState {
pub child: Arc<Mutex<Option<tokio::process::Child>>>, pub child: Arc<Mutex<Option<tokio::process::Child>>>,
pub workshop_cancel: Arc<Mutex<Option<CancellationToken>>>, pub workshop_cancel: Arc<Mutex<Option<CancellationToken>>>,
pub manual_stop: Arc<AtomicBool>,
} }
pub struct ProxyGuard { pub struct ProxyGuard {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "LCE Emerald Launcher", "productName": "LCE Emerald Launcher",
"version": "1.5.1", "version": "1.6.0",
"identifier": "com.emerald.legacy", "identifier": "com.emerald.legacy",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Emerald Legacy Launcher Nightly", "productName": "Emerald Legacy Launcher Nightly",
"version": "1.5.1-nightly", "version": "1.6.0-nightly",
"identifier": "com.emerald.legacy.nightly", "identifier": "com.emerald.legacy.nightly",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useRef, memo } from "react";
import * as THREE from "three";
interface CapePreviewProps {
src: string;
className?: string;
}
const CapePreview = memo(function CapePreview({
src,
className,
}: CapePreviewProps) {
const mountRef = useRef<HTMLDivElement>(null);
const groupRef = useRef<THREE.Group | null>(null);
useEffect(() => {
if (!mountRef.current) return;
const width = mountRef.current.clientWidth || 64;
const height = mountRef.current.clientHeight || 64;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);
camera.position.set(0, 0, 30);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
mountRef.current.innerHTML = "";
mountRef.current.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
const dl = new THREE.DirectionalLight(0xffffff, 0.8);
dl.position.set(10, 20, 10);
scene.add(dl);
const group = new THREE.Group();
group.rotation.y = -0.35;
scene.add(group);
groupRef.current = group;
const render = () => renderer.render(scene, camera);
const textureLoader = new THREE.TextureLoader();
let active = true;
textureLoader.load(
src,
(texture) => {
if (!active) return;
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.colorSpace = THREE.SRGBColorSpace;
const texW = texture.image.width || 64;
const texH = texture.image.height || 32;
const createFace = (
x: number,
y: number,
w: number,
h: number,
flipX = false,
flipY = false,
) => {
const matTex = texture.clone();
matTex.repeat.set((flipX ? -w : w) / texW, (flipY ? -h : h) / texH);
matTex.offset.set(
(flipX ? x + w : x) / texW,
1 - (flipY ? y : y + h) / texH,
);
matTex.needsUpdate = true;
return new THREE.MeshLambertMaterial({
map: matTex,
transparent: true,
alphaTest: 0.5,
side: THREE.FrontSide,
});
};
const capeUv = {
top: [1, 0, 10, 1],
bottom: [11, 0, 10, 1],
right: [0, 1, 1, 16],
front: [1, 1, 10, 16],
left: [11, 1, 1, 16],
back: [12, 1, 10, 16],
};
const geo = new THREE.BoxGeometry(10, 16, 1);
const mats = [
createFace(
capeUv.left[0],
capeUv.left[1],
capeUv.left[2],
capeUv.left[3],
),
createFace(
capeUv.right[0],
capeUv.right[1],
capeUv.right[2],
capeUv.right[3],
),
createFace(
capeUv.top[0],
capeUv.top[1],
capeUv.top[2],
capeUv.top[3],
false,
true,
),
createFace(
capeUv.bottom[0],
capeUv.bottom[1],
capeUv.bottom[2],
capeUv.bottom[3],
false,
true,
),
createFace(
capeUv.front[0],
capeUv.front[1],
capeUv.front[2],
capeUv.front[3],
),
createFace(
capeUv.back[0],
capeUv.back[1],
capeUv.back[2],
capeUv.back[3],
),
];
group.add(new THREE.Mesh(geo, mats));
render();
},
undefined,
() => {
render();
},
);
let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };
const onMouseDown = (e: MouseEvent) => {
isDragging = true;
previousMousePosition = { x: e.clientX, y: e.clientY };
};
const onMouseUp = () => {
isDragging = false;
};
const onMouseMove = (e: MouseEvent) => {
if (isDragging && groupRef.current) {
groupRef.current.rotation.y +=
(e.clientX - previousMousePosition.x) * 0.01;
previousMousePosition = { x: e.clientX, y: e.clientY };
render();
}
};
renderer.domElement.addEventListener("mousedown", onMouseDown);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
active = false;
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
if (object.geometry) object.geometry.dispose();
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach((mat) => {
if (mat.map) mat.map.dispose();
mat.dispose();
});
} else {
if (object.material.map) object.material.map.dispose();
object.material.dispose();
}
}
}
});
renderer.dispose();
};
}, [src]);
return (
<div
ref={mountRef}
className={`w-full h-full cursor-ew-resize ${className ?? ""}`}
/>
);
});
export default CapePreview;
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion";
import { TauriService } from "../../services/TauriService";
const MAX_DISPLAY_LINES = 5000;
const WINE_CHANNEL =
/^\s*(?:[0-9a-fA-F]{1,8}:)?(err|fixme|warn|trace|debugstr):/;
function lineColor(line: string): string {
const match = WINE_CHANNEL.exec(line);
switch (match?.[1]) {
case "err":
return "#FF5555";
case "fixme":
return "#FFFF55";
case "warn":
return "#FFB347";
case "trace":
return "#7FFF7F";
case "debugstr":
return "#55FFFF";
default:
return "#E0E0E0";
}
}
function LogActionButton({
children,
onClick,
className = "",
}: {
children: React.ReactNode;
onClick: () => void;
className?: string;
}) {
const [hovered, setHovered] = useState(false);
return (
<button
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={onClick}
className={`h-10 flex items-center justify-center text-lg mc-text-shadow border-none outline-none cursor-pointer text-white hover:text-[#ffff00] ${className}`}
style={{
backgroundImage: hovered
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{children}
</button>
);
}
export default function GameLogModal({
isOpen,
log,
onClose,
playBackSound,
}: {
isOpen: boolean;
log: string | null;
onClose: () => void;
playBackSound: () => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [feedback, setFeedback] = useState("");
const lines = useMemo(() => (log ?? "").split("\n"), [log]);
const truncated = lines.length > MAX_DISPLAY_LINES;
const visibleLines = truncated ? lines.slice(-MAX_DISPLAY_LINES) : lines;
useEffect(() => {
if (isOpen) {
setFeedback("");
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}
}, [isOpen, log]);
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
playBackSound();
onClose();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, onClose, playBackSound]);
if (!isOpen) return null;
const handleSave = async () => {
if (!log) return;
try {
const path = await TauriService.saveFileDialog(
"Save Game Log",
"game-log.txt",
["txt", "log"],
);
if (!path) return;
await TauriService.writeBinaryFile(path, new TextEncoder().encode(log));
setFeedback("Saved!");
} catch (e) {
console.error(e);
setFeedback("Failed to save.");
}
};
const handleCopy = async () => {
if (!log) return;
try {
await navigator.clipboard.writeText(log);
setFeedback("Copied to clipboard!");
} catch (e) {
console.error(e);
setFeedback("Failed to copy.");
}
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80"
onClick={() => {
playBackSound();
onClose();
}}
>
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 20, opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={(e) => e.stopPropagation()}
className="flex flex-col w-[640px] max-w-[92vw] max-h-[85vh] p-5 font-['Mojangles'] mc-options-bg"
>
<h3 className="text-2xl font-bold text-[#333333] mb-3 text-left w-full px-2 mc-text-shadow">
Game Crash Log
</h3>
{truncated && (
<p className="text-[#666666] text-sm mb-3 text-left w-full px-2">
Showing last {MAX_DISPLAY_LINES} lines of {lines.length}.
</p>
)}
<div
ref={scrollRef}
className="w-full flex-1 min-h-0 overflow-x-hidden overflow-y-auto bg-black/75 border-2 border-[#555] p-3 mb-4 text-left"
>
<div className="font-mono text-xs leading-relaxed whitespace-pre-wrap break-words">
{visibleLines.map((line, i) => (
<span key={i} style={{ color: lineColor(line) }}>
{line}
{"\n"}
</span>
))}
</div>
</div>
<div className="flex gap-3 w-full px-2 mb-2">
<LogActionButton onClick={handleSave} className="flex-1">
Save As File
</LogActionButton>
<LogActionButton onClick={handleCopy} className="flex-1">
Copy
</LogActionButton>
<LogActionButton
onClick={() => {
playBackSound();
onClose();
}}
className="flex-1"
>
Close
</LogActionButton>
</div>
{feedback && (
<p className="text-[#333333] text-sm text-center w-full">
{feedback}
</p>
)}
</motion.div>
</motion.div>
);
}
+200 -25
View File
@@ -1,7 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { TauriService } from "../../services/TauriService"; import { TauriService } from "../../services/TauriService";
export default function ImportWorldModal({ export default function ImportWorldModal({
isOpen, isOpen,
onClose, onClose,
@@ -20,7 +19,6 @@ export default function ImportWorldModal({
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setStatus(""); setStatus("");
@@ -29,19 +27,18 @@ export default function ImportWorldModal({
} }
}, [isOpen]); }, [isOpen]);
const handleImport = async () => { const handleImportMs = async () => {
if (!targetInstanceId) return; if (!targetInstanceId) return;
playPressSound(); playPressSound();
setIsImporting(true); setIsImporting(true);
setError(""); setError("");
setStatus("Selecting source..."); setStatus("Selecting source...");
try { try {
setStatus("Selecting LCE save folder or .ms file..."); setStatus("Selecting saveData.ms file...");
const picked = await TauriService.pickFile( const picked = await TauriService.pickFile("Select saveData.ms", [
"Select saveData.ms or GameHDD folder", "*.ms",
["*.ms", "*"], "*",
); ]);
if (!picked) { if (!picked) {
setIsImporting(false); setIsImporting(false);
return; return;
@@ -64,6 +61,129 @@ export default function ImportWorldModal({
} }
}; };
const handleImportXbox = async () => {
if (!targetInstanceId) return;
playPressSound();
setIsImporting(true);
setError("");
setStatus("Selecting source...");
try {
setStatus("Selecting Xbox 360 save (.bin)...");
const picked = await TauriService.pickFile(
"Select Xbox 360 Minecraft save",
["*.bin", "*"],
);
if (!picked) {
setIsImporting(false);
return;
}
setStatus("Converting Xbox 360 save...");
const instancePath = await TauriService.getInstancePath(targetInstanceId);
const gameHdd = `${instancePath}/Windows64/GameHDD`;
await TauriService.importLceSave(picked, gameHdd);
setStatus(`Xbox 360 save converted into "${targetInstanceName}"!`);
setTimeout(() => {
onClose();
}, 2000);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setStatus("");
setIsImporting(false);
}
};
const handleImportPs3 = async () => {
if (!targetInstanceId) return;
playPressSound();
setIsImporting(true);
setError("");
setStatus("Selecting source...");
try {
setStatus("Selecting PS3 save folder...");
const picked = await TauriService.pickFolder();
if (!picked) {
setIsImporting(false);
return;
}
setStatus("Converting PS3 save...");
const instancePath = await TauriService.getInstancePath(targetInstanceId);
const gameHdd = `${instancePath}/Windows64/GameHDD`;
await TauriService.importLceSave(picked, gameHdd);
setStatus(`PS3 save converted into "${targetInstanceName}"!`);
setTimeout(() => {
onClose();
}, 2000);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setStatus("");
setIsImporting(false);
}
};
const handleImportJava = async () => {
if (!targetInstanceId) return;
playPressSound();
setIsImporting(true);
setError("");
setStatus("Selecting source...");
try {
setStatus("Selecting Java world folder...");
const picked = await TauriService.pickFolder();
if (!picked) {
setIsImporting(false);
return;
}
const worldName = deriveWorldName(picked);
setStatus("Converting Java world to LCE...");
const instancePath = await TauriService.getInstancePath(targetInstanceId);
const saveDir = `${instancePath}/Windows64/GameHDD/${worldName}`;
await TauriService.javaToLce(picked, `${saveDir}/saveData.ms`);
setStatus(`Java world converted into "${targetInstanceName}"!`);
setTimeout(() => {
onClose();
}, 2000);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setStatus("");
setIsImporting(false);
}
};
const handleExportJava = async () => {
if (!targetInstanceId) return;
playPressSound();
setIsImporting(true);
setError("");
setStatus("Selecting source...");
try {
setStatus("Selecting saveData.ms file...");
const picked = await TauriService.pickFile("Select saveData.ms", [
"*.ms",
"*",
]);
if (!picked) {
setIsImporting(false);
return;
}
setStatus("Selecting output folder for Java world...");
const outputFolder = await TauriService.pickFolder();
if (!outputFolder) {
setIsImporting(false);
return;
}
setStatus("Converting LCE save to Java world...");
await TauriService.lceToJava(picked, outputFolder);
setStatus(`Java world exported to "${outputFolder}"!`);
setTimeout(() => {
onClose();
}, 2000);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setStatus("");
setIsImporting(false);
}
};
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
playBackSound(); playBackSound();
@@ -104,9 +224,77 @@ export default function ImportWorldModal({
Import into:{" "} Import into:{" "}
<span className="text-[#FFFF55]">{targetInstanceName}</span> <span className="text-[#FFFF55]">{targetInstanceName}</span>
</p> </p>
<p className="text-gray-400 text-xs mc-text-shadow mb-4 text-center">
Select an existing LCE save (.ms file or GameHDD folder) <p className="text-gray-400 text-xs mc-text-shadow mb-2 text-center">
Import an existing .ms save file:
</p> </p>
<button
onClick={handleImportMs}
className="w-48 h-9 flex items-center justify-center text-sm text-white mc-text-shadow hover:text-[#FFFF55] mb-4"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Select .ms File
</button>
<p className="text-gray-400 text-xs mc-text-shadow mb-2 text-center">
Convert an Xbox 360 or PS3 save:
</p>
<div className="flex gap-3 mb-2">
<button
onClick={handleImportXbox}
className="w-40 h-9 flex items-center justify-center text-sm text-white mc-text-shadow hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Xbox 360 (.bin)
</button>
<button
onClick={handleImportPs3}
className="w-40 h-9 flex items-center justify-center text-sm text-white mc-text-shadow hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
PS3 (Folder)
</button>
</div>
<p className="text-gray-400 text-xs mc-text-shadow mb-2 text-center">
Java Edition world conversion:
</p>
<div className="flex gap-3 mb-2">
<button
onClick={handleImportJava}
className="w-40 h-9 flex items-center justify-center text-sm text-white mc-text-shadow hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Java LCE
</button>
<button
onClick={handleExportJava}
className="w-40 h-9 flex items-center justify-center text-sm text-white mc-text-shadow hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
LCE Java
</button>
</div>
{error && ( {error && (
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3"> <div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3">
@@ -114,13 +302,12 @@ export default function ImportWorldModal({
</div> </div>
)} )}
<div className="flex gap-4 w-full justify-center">
<button <button
onClick={() => { onClick={() => {
playBackSound(); playBackSound();
onClose(); onClose();
}} }}
className="w-32 h-10 flex items-center justify-center text-xl text-white mc-text-shadow" className="w-32 h-10 flex items-center justify-center text-xl text-white mc-text-shadow mt-2"
style={{ style={{
backgroundImage: "url('/images/Button_Background.png')", backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%", backgroundSize: "100% 100%",
@@ -129,18 +316,6 @@ export default function ImportWorldModal({
> >
Cancel Cancel
</button> </button>
<button
onClick={handleImport}
className="w-40 h-10 flex items-center justify-center text-xl text-white mc-text-shadow hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Select File
</button>
</div>
</> </>
) : ( ) : (
<> <>
+3 -3
View File
@@ -319,7 +319,7 @@ export const ArcEditorView: React.FC = () => {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }} transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-7xl h-[85vh] outline-none" className="flex flex-col items-center w-full max-w-7xl h-full outline-none"
> >
<div className="w-full flex justify-between items-center mb-4 px-8"> <div className="w-full flex justify-between items-center mb-4 px-8">
<h2 className="text-2xl text-white mc-text-shadow border-b-2 border-[#373737] pb-1 tracking-widest uppercase font-bold"> <h2 className="text-2xl text-white mc-text-shadow border-b-2 border-[#373737] pb-1 tracking-widest uppercase font-bold">
@@ -567,10 +567,10 @@ export const ArcEditorView: React.FC = () => {
</div> </div>
</div> </div>
)} )}
<div className="flex justify-center mt-6 h-14 w-full"> <div className="flex justify-center mt-6 h-14 w-full shrink-0">
<button <button
onClick={() => { playBackSound(); setActiveView("devtools"); }} onClick={() => { playBackSound(); setActiveView("devtools"); }}
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white" className="w-72 h-full shrink-0 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }} style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
> >
Back Back
+3 -3
View File
@@ -129,7 +129,7 @@ export default function ColEditorView() {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }} transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col w-full h-[85vh] max-w-7xl relative" className="flex flex-col w-full h-full max-w-7xl relative"
> >
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".col" /> <input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".col" />
<div className="flex items-center justify-between mb-6 px-4"> <div className="flex items-center justify-between mb-6 px-4">
@@ -365,10 +365,10 @@ export default function ColEditorView() {
</div> </div>
)} )}
<div className="flex justify-center mt-6 h-14"> <div className="flex justify-center mt-6 h-14 shrink-0">
<button <button
onClick={() => { playBackSound(); setActiveView("devtools"); }} onClick={() => { playBackSound(); setActiveView("devtools"); }}
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white" className="w-72 h-full shrink-0 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }} style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
> >
Back Back
+3 -3
View File
@@ -78,14 +78,14 @@ export default function DevtoolsView() {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }} transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-3xl outline-none" className="flex flex-col items-center w-full max-w-3xl h-full outline-none"
> >
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-75 text-center tracking-widest uppercase opacity-80 font-bold"> <h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-75 text-center tracking-widest uppercase opacity-80 font-bold">
Developer Tools Developer Tools
</h2> </h2>
<div <div
className="w-full max-w-160 h-85 mb-4 p-8 shadow-2xl flex flex-col items-center" className="w-full max-w-160 flex-1 min-h-0 mb-4 p-8 shadow-2xl flex flex-col items-center overflow-hidden"
style={{ style={{
backgroundImage: "url('/images/frame_background.png')", backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%", backgroundSize: "100% 100%",
@@ -150,7 +150,7 @@ export default function DevtoolsView() {
playBackSound(); playBackSound();
setActiveView("main"); setActiveView("main");
}} }}
className={`w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-2 outline-none border-none ${focusIndex === BACK_BUTTON_INDEX ? "text-[#FFFF55]" : "text-white" className={`w-72 h-14 shrink-0 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-2 outline-none border-none ${focusIndex === BACK_BUTTON_INDEX ? "text-[#FFFF55]" : "text-white"
}`} }`}
style={{ style={{
backgroundImage: backgroundImage:
+3 -3
View File
@@ -114,7 +114,7 @@ export default function GrfEditorView() {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }} transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col w-full h-[85vh] max-w-7xl relative" className="flex flex-col w-full h-full max-w-7xl relative"
> >
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".grf" /> <input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".grf" />
<input type="file" ref={addFileInputRef} onChange={handleAddFile} className="hidden" /> <input type="file" ref={addFileInputRef} onChange={handleAddFile} className="hidden" />
@@ -223,10 +223,10 @@ export default function GrfEditorView() {
</div> </div>
)} )}
<div className="flex justify-center mt-6 h-14"> <div className="flex justify-center mt-6 h-14 shrink-0">
<button <button
onClick={() => { playBackSound(); setActiveView("devtools"); }} onClick={() => { playBackSound(); setActiveView("devtools"); }}
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white" className="w-72 h-full shrink-0 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }} style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
> >
Back Back
+3 -3
View File
@@ -184,7 +184,7 @@ export default function GuidesView() {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }} transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-5xl outline-none" className="flex flex-col items-center w-full max-w-5xl h-full outline-none"
> >
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-75 text-center tracking-widest uppercase opacity-80 font-bold"> <h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-75 text-center tracking-widest uppercase opacity-80 font-bold">
{selectedGuide {selectedGuide
@@ -193,7 +193,7 @@ export default function GuidesView() {
</h2> </h2>
<div <div
className="w-full max-w-5xl h-[42rem] mb-4 p-8 shadow-2xl flex flex-col items-center" className="w-full max-w-5xl flex-1 min-h-0 mb-4 p-8 shadow-2xl flex flex-col items-center overflow-hidden"
style={{ style={{
backgroundImage: "url('/images/frame_background.png')", backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%", backgroundSize: "100% 100%",
@@ -265,7 +265,7 @@ export default function GuidesView() {
data-index={BACK_BUTTON_INDEX} data-index={BACK_BUTTON_INDEX}
onMouseEnter={() => setFocusIndex(BACK_BUTTON_INDEX)} onMouseEnter={() => setFocusIndex(BACK_BUTTON_INDEX)}
onClick={goBack} onClick={goBack}
className={`w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-2 outline-none border-none ${ className={`w-72 h-14 shrink-0 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-2 outline-none border-none ${
focusIndex === BACK_BUTTON_INDEX ? "text-[#FFFF55]" : "text-white" focusIndex === BACK_BUTTON_INDEX ? "text-[#FFFF55]" : "text-white"
}`} }`}
style={{ style={{
+142 -16
View File
@@ -7,7 +7,7 @@ import {
useGame, useGame,
} from "../../context/LauncherContext"; } from "../../context/LauncherContext";
import ChooseInstanceModal from "../modals/ChooseInstanceModal"; import ChooseInstanceModal from "../modals/ChooseInstanceModal";
import { lceOnlineService } from "../../services/LceOnlineService"; import { lceOnlineService, SocialEntry } from "../../services/LceOnlineService";
import { TauriService } from "../../services/TauriService"; import { TauriService } from "../../services/TauriService";
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'; import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
@@ -25,7 +25,7 @@ const LceOnlineView = memo(function LceOnlineView({
onClearAddFriendTarget, onClearAddFriendTarget,
invites: invitesProp, invites: invitesProp,
}: LceOnlineViewProps) { }: LceOnlineViewProps) {
const { setActiveView } = useUI(); const { setActiveView, setIsUiHidden } = useUI();
const { animationsEnabled } = useConfig(); const { animationsEnabled } = useConfig();
const { playPressSound, playBackSound } = useAudio(); const { playPressSound, playBackSound } = useAudio();
const game = useGame(); const game = useGame();
@@ -35,9 +35,9 @@ const LceOnlineView = memo(function LceOnlineView({
"friends" | "requests" | "invites" "friends" | "requests" | "invites"
>("friends"); >("friends");
const [focusIndex, setFocusIndex] = useState<number | null>(0); const [focusIndex, setFocusIndex] = useState<number | null>(0);
const [friends, setFriends] = useState<string[]>([]); const [friends, setFriends] = useState<SocialEntry[]>([]);
const [incomingReqs, setIncomingReqs] = useState<string[]>([]); const [incomingReqs, setIncomingReqs] = useState<SocialEntry[]>([]);
const [outgoingReqs, setOutgoingReqs] = useState<string[]>([]); const [outgoingReqs, setOutgoingReqs] = useState<SocialEntry[]>([]);
const invites = invitesProp ?? []; const invites = invitesProp ?? [];
const [isHosting, setIsHosting] = useState(false); const [isHosting, setIsHosting] = useState(false);
const [isAddingFriend, setIsAddingFriend] = useState(false); const [isAddingFriend, setIsAddingFriend] = useState(false);
@@ -189,34 +189,34 @@ const LceOnlineView = memo(function LceOnlineView({
}); });
friends.forEach((f) => { friends.forEach((f) => {
items.push({ items.push({
id: `friend_${f}`, id: `friend_${f.username}`,
type: "friend", type: "friend",
label: f, label: f.displayName,
onClick: () => handleAction(() => lceOnlineService.removeFriend(f)), onClick: () => handleAction(() => lceOnlineService.removeFriend(f.username)),
onClickSecondary: isHosting onClickSecondary: isHosting
? () => handleAction(() => lceOnlineService.sendInvite(f)) ? () => handleAction(() => lceOnlineService.sendInvite(f.username))
: undefined, : undefined,
}); });
}); });
} else if (currentTab === "requests") { } else if (currentTab === "requests") {
incomingReqs.forEach((r) => { incomingReqs.forEach((r) => {
items.push({ items.push({
id: `req_in_${r}`, id: `req_in_${r.username}`,
type: "request_in", type: "request_in",
label: r, label: r.displayName,
onClick: () => onClick: () =>
handleAction(() => lceOnlineService.acceptFriendRequest(r)), handleAction(() => lceOnlineService.acceptFriendRequest(r.username)),
onClickSecondary: () => onClickSecondary: () =>
handleAction(() => lceOnlineService.declineFriendRequest(r)), handleAction(() => lceOnlineService.declineFriendRequest(r.username)),
}); });
}); });
outgoingReqs.forEach((r) => { outgoingReqs.forEach((r) => {
items.push({ items.push({
id: `req_out_${r}`, id: `req_out_${r.username}`,
type: "request_out", type: "request_out",
label: r, label: r.displayName,
onClick: () => onClick: () =>
handleAction(() => lceOnlineService.declineFriendRequest(r)), handleAction(() => lceOnlineService.declineFriendRequest(r.username)),
}); });
}); });
} else if (currentTab === "invites") { } else if (currentTab === "invites") {
@@ -364,6 +364,122 @@ const LceOnlineView = memo(function LceOnlineView({
} }
}, [focusIndex, isAddingFriend]); }, [focusIndex, isAddingFriend]);
const touhouOverlayRef = useRef<HTMLDivElement | null>(null);
const touhouBlobUrlRef = useRef<string | null>(null);
const touhouAudioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
const GIF_URL =
"https://raw.githubusercontent.com/neoapps-dev/neoapps-dev/main/badapple_small.gif";
const MP3_URL =
"https://raw.githubusercontent.com/Soldr/bad-apple-but-its-node.js/master/bad-apple.mp3";
const stopAudio = () => {
if (touhouAudioRef.current) {
touhouAudioRef.current.pause();
touhouAudioRef.current.src = "";
touhouAudioRef.current = null;
}
};
const onlineUser = lceOnlineService.account?.username;
if (onlineUser === "TOUHOU") {
if (!touhouOverlayRef.current) {
setIsUiHidden(true);
const overlay = document.createElement("div");
overlay.style.cssText =
"position:fixed;inset:0;z-index:99999;background:#000;display:flex;align-items:center;justify-content:center;cursor:pointer";
const spinner = document.createElement("div");
spinner.textContent = "Loading...";
spinner.style.cssText =
"color:#fff;font-family:'Mojangles',monospace;font-size:24px;letter-spacing:4px";
overlay.appendChild(spinner);
document.body.appendChild(overlay);
const img = document.createElement("img");
img.style.cssText = "width:100%;height:100%;object-fit:contain";
const audio = new Audio(MP3_URL);
audio.loop = true;
audio.volume = 0.5;
touhouAudioRef.current = audio;
const audioReady = new Promise<void>((resolve) => {
if (audio.readyState >= 3) resolve();
else {
audio.oncanplaythrough = () => resolve();
audio.onerror = () => resolve();
}
});
const gifReady = fetch(GIF_URL)
.then((r) => r.arrayBuffer())
.then((buf) => {
const blob = new Blob([buf], { type: "image/gif" });
const url = URL.createObjectURL(blob);
touhouBlobUrlRef.current = url;
img.src = url;
return new Promise<void>((resolve) => {
if (img.complete) resolve();
else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
});
})
.catch(() => {
img.src = GIF_URL;
return new Promise<void>((resolve) => {
if (img.complete) resolve();
else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
});
});
Promise.all([gifReady, audioReady]).then(() => {
spinner.remove();
overlay.appendChild(img);
audio.currentTime = 2;
audio.play().catch(() => {});
});
const cleanup = () => {
stopAudio();
setIsUiHidden(false);
if (overlay.parentNode) overlay.remove();
touhouOverlayRef.current = null;
if (touhouBlobUrlRef.current) {
URL.revokeObjectURL(touhouBlobUrlRef.current);
touhouBlobUrlRef.current = null;
}
};
overlay.onclick = cleanup;
touhouOverlayRef.current = overlay;
}
} else {
if (touhouOverlayRef.current) {
stopAudio();
touhouOverlayRef.current.remove();
touhouOverlayRef.current = null;
if (touhouBlobUrlRef.current) {
URL.revokeObjectURL(touhouBlobUrlRef.current);
touhouBlobUrlRef.current = null;
}
setIsUiHidden(false);
}
}
return () => {
stopAudio();
setIsUiHidden(false);
if (touhouOverlayRef.current) {
touhouOverlayRef.current.remove();
touhouOverlayRef.current = null;
if (touhouBlobUrlRef.current) {
URL.revokeObjectURL(touhouBlobUrlRef.current);
touhouBlobUrlRef.current = null;
}
}
};
}, [isSignedIn, setIsUiHidden]);
const renderContent = () => { const renderContent = () => {
if (!isSignedIn) { if (!isSignedIn) {
return ( return (
@@ -448,6 +564,16 @@ const LceOnlineView = memo(function LceOnlineView({
<span className="text-[#2a2a2a] font-bold text-2xl truncate pr-4"> <span className="text-[#2a2a2a] font-bold text-2xl truncate pr-4">
{item.label} {item.label}
</span> </span>
<span className="text-[#555] text-base font-bold truncate">
@
{item.type === "friend"
? friends.find((f) => `friend_${f.username}` === item.id)?.username
: item.type === "request_in"
? incomingReqs.find((r) => `req_in_${r.username}` === item.id)?.username
: item.type === "request_out"
? outgoingReqs.find((r) => `req_out_${r.username}` === item.id)?.username
: "Invite"}
</span>
</div> </div>
</div> </div>
<div className="flex space-x-3 pr-2 shrink-0"> <div className="flex space-x-3 pr-2 shrink-0">
+3 -3
View File
@@ -103,7 +103,7 @@ export default function LocEditorView() {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }} transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col w-full h-[85vh] max-w-7xl relative" className="flex flex-col w-full h-full max-w-7xl relative"
> >
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".loc" /> <input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".loc" />
<div className="flex items-center justify-between mb-6 px-4"> <div className="flex items-center justify-between mb-6 px-4">
@@ -201,10 +201,10 @@ export default function LocEditorView() {
</div> </div>
</div> </div>
)} )}
<div className="flex justify-center mt-6 h-14"> <div className="flex justify-center mt-6 h-14 shrink-0">
<button <button
onClick={() => { playBackSound(); setActiveView("devtools"); }} onClick={() => { playBackSound(); setActiveView("devtools"); }}
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white" className="w-72 h-full shrink-0 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }} style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
> >
Back Back
+1 -1
View File
@@ -314,7 +314,7 @@ export default function ModelEditorView() {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }} transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col w-full h-[85vh] max-w-7xl relative" className="flex flex-col w-full h-full max-w-7xl relative"
> >
<input <input
type="file" type="file"
+3 -3
View File
@@ -80,7 +80,7 @@ export default function OptionsEditorView() {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }} transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col w-full h-[85vh] max-w-7xl relative" className="flex flex-col w-full h-full max-w-7xl relative"
> >
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".dat,.bin" /> <input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".dat,.bin" />
<div className="flex items-center justify-between mb-6 px-4"> <div className="flex items-center justify-between mb-6 px-4">
@@ -211,10 +211,10 @@ export default function OptionsEditorView() {
</div> </div>
)} )}
<div className="flex justify-center mt-6 h-14"> <div className="flex justify-center mt-6 h-14 shrink-0">
<button <button
onClick={() => { playBackSound(); setActiveView("devtools"); }} onClick={() => { playBackSound(); setActiveView("devtools"); }}
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white" className="w-72 h-full shrink-0 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }} style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
> >
Back Back
+2 -2
View File
@@ -500,7 +500,7 @@ export default function PckEditorView() {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }} transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-6xl h-[85vh] outline-none" className="flex flex-col items-center w-full max-w-6xl h-full outline-none"
> >
<div className="w-full flex justify-between items-center mb-4 px-8"> <div className="w-full flex justify-between items-center mb-4 px-8">
<h2 className="text-2xl text-white mc-text-shadow border-b-2 border-[#373737] pb-1 tracking-widest uppercase font-bold"> <h2 className="text-2xl text-white mc-text-shadow border-b-2 border-[#373737] pb-1 tracking-widest uppercase font-bold">
@@ -1028,7 +1028,7 @@ export default function PckEditorView() {
playBackSound(); playBackSound();
setActiveView("devtools"); setActiveView("devtools");
}} }}
className="w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-6 outline-none border-none hover:text-[#FFFF55] text-white" className="w-72 h-14 shrink-0 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-6 outline-none border-none hover:text-[#FFFF55] text-white"
style={{ style={{
backgroundImage: "url('/images/Button_Background.png')", backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%", backgroundSize: "100% 100%",
+49 -47
View File
@@ -9,6 +9,7 @@ import {
useConfig, useConfig,
} from "../../context/LauncherContext"; } from "../../context/LauncherContext";
import SkinViewer from "../common/SkinViewer"; import SkinViewer from "../common/SkinViewer";
import CapePreview from "../common/CapePreview";
interface SavedSkin { interface SavedSkin {
id: string; id: string;
@@ -47,6 +48,13 @@ const DEFAULT_SKINS: SavedSkin[] = [
}, },
{ id: "peter", name: "Peter", url: "/Skins/Peter.png", isSlim: false }, { id: "peter", name: "Peter", url: "/Skins/Peter.png", isSlim: false },
{ id: "piebot", name: "piebot", url: "/Skins/piebot.png", isSlim: false }, { id: "piebot", name: "piebot", url: "/Skins/piebot.png", isSlim: false },
{
id: "kowhaifan",
name: "Kowhaifan",
url: "/Skins/kowhaifan.png",
isSlim: false,
},
{ id: "striker", name: "str1k3r", url: "/Skins/str1k3r.png", isSlim: true },
{ id: "andipog", name: "Andi_Pog", url: "/Skins/andi.png", isSlim: false }, { id: "andipog", name: "Andi_Pog", url: "/Skins/andi.png", isSlim: false },
{ id: "sevenhundred", name: "700", url: "/Skins/700.png", isSlim: false }, { id: "sevenhundred", name: "700", url: "/Skins/700.png", isSlim: false },
{ {
@@ -55,10 +63,41 @@ const DEFAULT_SKINS: SavedSkin[] = [
url: "/Skins/PrismaChunk0.png", url: "/Skins/PrismaChunk0.png",
isSlim: false, isSlim: false,
}, },
{ id: "amy", name: "Amy", url: "/Skins/amy.png", isSlim: true }, { id: "amy", name: "Amy", url: "/Skins/amy.png", isSlim: true }, //neo: she's the best btw
{ id: "huckle", name: "Huckle", url: "/Skins/huckle.png", isSlim: true }, { id: "huckle", name: "Huckle", url: "/Skins/huckle.png", isSlim: true },
]; ];
const HeadPreview = memo(function HeadPreview({ src }: { src: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const cvs = canvasRef.current;
if (!cvs) return;
const ctx = cvs.getContext("2d");
if (!ctx) return;
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, cvs.width, cvs.height);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, 8, 8, 8, 8, 0, 0, cvs.width, cvs.height);
if (img.height !== 32) {
ctx.drawImage(img, 40, 8, 8, 8, 0, 0, cvs.width, cvs.height);
}
};
img.src = src;
}, [src]);
return (
<canvas
ref={canvasRef}
width={64}
height={64}
className="absolute w-full h-full"
style={{ imageRendering: "pixelated" }}
/>
);
});
const SkinsView = memo(function SkinsView() { const SkinsView = memo(function SkinsView() {
const { setActiveView, setIsUiHidden } = useUI(); const { setActiveView, setIsUiHidden } = useUI();
const { playPressSound, playBackSound } = useAudio(); const { playPressSound, playBackSound } = useAudio();
@@ -271,7 +310,7 @@ const SkinsView = memo(function SkinsView() {
if (focusIndex === null || focusIndex < TOP_BUTTONS_COUNT) { if (focusIndex === null || focusIndex < TOP_BUTTONS_COUNT) {
setFocusIndex(SKINS_START_INDEX); setFocusIndex(SKINS_START_INDEX);
} else if (focusIndex < BACK_BUTTON_INDEX) { } else if (focusIndex < BACK_BUTTON_INDEX) {
const rowCount = viewMode === "cape" ? 3 : 4; const rowCount = 4;
const next = focusIndex + rowCount; const next = focusIndex + rowCount;
setFocusIndex(next >= BACK_BUTTON_INDEX ? BACK_BUTTON_INDEX : next); setFocusIndex(next >= BACK_BUTTON_INDEX ? BACK_BUTTON_INDEX : next);
} }
@@ -283,7 +322,7 @@ const SkinsView = memo(function SkinsView() {
viewMode === "cape" ? storedCapes.length + 1 : savedSkins.length; viewMode === "cape" ? storedCapes.length + 1 : savedSkins.length;
setFocusIndex(SKINS_START_INDEX + itemCount - 1); setFocusIndex(SKINS_START_INDEX + itemCount - 1);
} else if (focusIndex >= SKINS_START_INDEX) { } else if (focusIndex >= SKINS_START_INDEX) {
const rowCount = viewMode === "cape" ? 3 : 4; const rowCount = 4;
const next = focusIndex - rowCount; const next = focusIndex - rowCount;
setFocusIndex(next < SKINS_START_INDEX ? 0 : next); setFocusIndex(next < SKINS_START_INDEX ? 0 : next);
} }
@@ -432,22 +471,14 @@ const SkinsView = memo(function SkinsView() {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }} transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-3xl outline-none" className="flex flex-col items-center w-full max-w-3xl h-full outline-none"
> >
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-75 text-center tracking-widest uppercase opacity-80 font-bold"> <h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-75 text-center tracking-widest uppercase opacity-80 font-bold">
{viewMode === "skin" ? "Skin Library" : "Cape Library"} {viewMode === "skin" ? "Skin Library" : "Cape Library"}
</h2> </h2>
<div <div className="w-full max-w-160 flex-1 min-h-0 mb-4 p-5 flex flex-col relative overflow-hidden">
className="w-full max-w-160 h-85 mb-4 p-5 shadow-2xl flex flex-col relative" <div className="w-full flex items-center gap-4 ml-5 pb-4 mb-4 min-h-10">
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<div className="w-full flex items-center border-b-2 border-[#373737] pb-4 mb-4 relative min-h-10">
<div className="absolute left-0 right-0 flex justify-center gap-4 items-center">
<button <button
data-index="0" data-index="0"
onMouseEnter={() => setFocusIndex(0)} onMouseEnter={() => setFocusIndex(0)}
@@ -503,7 +534,6 @@ const SkinsView = memo(function SkinsView() {
> >
{viewMode === "skin" ? "Delete Skin" : "Delete Cape"} {viewMode === "skin" ? "Delete Skin" : "Delete Cape"}
</button> </button>
</div>
<div className="flex-1"></div> <div className="flex-1"></div>
<div className="flex justify-end z-10"> <div className="flex justify-end z-10">
@@ -551,7 +581,7 @@ const SkinsView = memo(function SkinsView() {
/> />
</div> </div>
<div className="flex-1 overflow-y-auto pr-2 flex flex-wrap gap-x-8 gap-y-6 items-start content-start justify-center"> <div className="flex-1 overflow-y-auto pr-2 flex flex-wrap gap-x-4 gap-y-6 items-start content-start justify-center hidden-scrollbar">
{viewMode === "skin" ? ( {viewMode === "skin" ? (
savedSkins.map((skin, i) => { savedSkins.map((skin, i) => {
const idx = SKINS_START_INDEX + i; const idx = SKINS_START_INDEX + i;
@@ -583,21 +613,7 @@ const SkinsView = memo(function SkinsView() {
onClick={() => handleSkinSelect(skin)} onClick={() => handleSkinSelect(skin)}
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none ${isActive || isFocused ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`} className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none ${isActive || isFocused ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
> >
<img <HeadPreview src={skin.url} />
src={skin.url}
draggable={false}
alt={skin.name}
className="absolute max-w-none"
style={{
width: "800%",
height: "auto",
left: "-100%",
top: "-100%",
imageRendering: "pixelated",
}}
loading="lazy"
decoding="async"
/>
</div> </div>
<input <input
type="text" type="text"
@@ -668,21 +684,7 @@ const SkinsView = memo(function SkinsView() {
onClick={() => handleCapeSelect(cape)} onClick={() => handleCapeSelect(cape)}
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none ${isActive || isFocused ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`} className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none ${isActive || isFocused ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
> >
<img <CapePreview src={cape.url} />
src={cape.url}
draggable={false}
alt={cape.name}
className="absolute max-w-none"
style={{
width: "auto",
height: "100%",
left: "0",
top: "0",
imageRendering: "pixelated",
}}
loading="lazy"
decoding="async"
/>
</div> </div>
<input <input
type="text" type="text"
@@ -727,7 +729,7 @@ const SkinsView = memo(function SkinsView() {
playBackSound(); playBackSound();
setActiveView("main"); setActiveView("main");
}} }}
className={`w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-2 outline-none border-none hover:text-[#FFFF55] ${focusIndex === BACK_BUTTON_INDEX ? "text-[#FFFF55]" : "text-white"}`} className={`w-72 h-14 shrink-0 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-2 outline-none border-none hover:text-[#FFFF55] ${focusIndex === BACK_BUTTON_INDEX ? "text-[#FFFF55]" : "text-white"}`}
style={{ style={{
backgroundImage: backgroundImage:
focusIndex === BACK_BUTTON_INDEX focusIndex === BACK_BUTTON_INDEX
+2 -2
View File
@@ -178,7 +178,7 @@ export default function SwfView() {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }} transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-6xl h-[85vh] outline-none" className="flex flex-col items-center w-full max-w-6xl h-full outline-none"
> >
<div className="w-full flex justify-between items-center mb-4 px-8"> <div className="w-full flex justify-between items-center mb-4 px-8">
<h2 className="text-2xl text-white mc-text-shadow border-b-2 border-[#373737] pb-1 tracking-widest uppercase font-bold"> <h2 className="text-2xl text-white mc-text-shadow border-b-2 border-[#373737] pb-1 tracking-widest uppercase font-bold">
@@ -308,7 +308,7 @@ export default function SwfView() {
<button <button
onClick={handleBack} onClick={handleBack}
className="w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-6 outline-none border-none hover:text-[#FFFF55] text-white" className="w-72 h-14 shrink-0 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-6 outline-none border-none hover:text-[#FFFF55] text-white"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }} style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
> >
Back Back
+132 -8
View File
@@ -124,6 +124,55 @@ interface PluginRegistryEntry {
} }
const COLS = 4; const COLS = 4;
interface ZipGroup {
main: string;
parts: string[];
dest: string;
}
function groupZips(zips: Record<string, string>): ZipGroup[] {
const entries = Object.entries(zips).sort(([a], [b]) => a.localeCompare(b));
const consumed = new Set<string>();
const groups: ZipGroup[] = [];
for (const [name, dest] of entries) {
if (consumed.has(name)) continue;
if (!name.toLowerCase().endsWith(".zip")) continue;
const base = name.slice(0, -4);
const parts = [name];
consumed.add(name);
let n = 1;
for (;;) {
const cand = `${base}.z${String(n).padStart(2, "0")}`;
if (zips[cand] !== undefined) {
parts.push(cand);
consumed.add(cand);
n++;
} else {
break;
}
}
if (parts.length === 1) {
n = 1;
for (;;) {
const cand = `${base}.zip.${String(n).padStart(3, "0")}`;
if (zips[cand] !== undefined) {
parts.push(cand);
consumed.add(cand);
n++;
} else {
break;
}
}
}
groups.push({ main: name, parts, dest });
}
for (const [name, dest] of entries) {
if (!consumed.has(name)) {
consumed.add(name);
groups.push({ main: name, parts: [name], dest });
}
}
return groups;
}
interface WorkshopViewProps { interface WorkshopViewProps {
workshopTarget?: { id: string; type?: string } | null; workshopTarget?: { id: string; type?: string } | null;
onClearWorkshopTarget?: () => void; onClearWorkshopTarget?: () => void;
@@ -1736,21 +1785,36 @@ function PackageModal({
Files Files
</span> </span>
<div className="space-y-1.5"> <div className="space-y-1.5">
{Object.entries(pkg.zips).map(([file, dest]) => ( {groupZips(pkg.zips).map((group) => {
const isSplit = group.parts.length > 1;
return (
<div <div
key={file} key={group.main}
className="flex items-center justify-between gap-4 bg-black/20 p-2 rounded-sm border border-[#222]" className="flex items-center justify-between gap-4 bg-black/20 p-2 rounded-sm border border-[#222]"
> >
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs text-[#A0A0A0] mc-text-shadow font-mono"> <span className="text-xs text-[#A0A0A0] mc-text-shadow font-mono">
{file} {group.main}
{isSplit && (
<span className="ml-2 text-[8px] text-[#FFFF55] bg-black/60 border border-[#555] px-1.5 py-0.5 uppercase tracking-widest">
Split archive · {group.parts.length} parts
</span> </span>
{dest && ( )}
<span className="text-[9px] text-[#fff] mc-text-shadow truncate uppercase tracking-tighter"> </span>
{dest} {isSplit && (
<span className="text-[9px] text-[#666] mc-text-shadow font-mono">
{group.parts.slice(1).join(", ")}
</span> </span>
)} )}
</div> </div>
))} {group.dest && (
<span className="text-[9px] text-[#fff] mc-text-shadow truncate uppercase tracking-tighter">
{group.dest}
</span>
)}
</div>
);
})}
</div> </div>
</div> </div>
)} )}
@@ -1963,10 +2027,37 @@ function InstallModal({
"idle" | "installing" | "success" | "error" "idle" | "installing" | "success" | "error"
>("idle"); >("idle");
const [errorMsg, setErrorMsg] = useState<string | null>(null); const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [progressLabel, setProgressLabel] = useState<string | null>(null);
const [pkgPct, setPkgPct] = useState<Record<string, number>>({});
const installTargets = useMemo(() => {
const targets: { id: string; zips: number }[] = [];
for (const depId of dependencies) {
const dep = allPackages.find((p) => p.id === depId);
if (dep?.zips) targets.push({ id: dep.id, zips: Object.keys(dep.zips).length });
}
if (pkg.zips) targets.push({ id: pkg.id, zips: Object.keys(pkg.zips).length });
return targets;
}, [dependencies, allPackages, pkg.zips, pkg.id]);
const totalZips = installTargets.reduce((acc, t) => acc + t.zips, 0);
const overallProgress = useMemo(() => {
if (totalZips === 0) return 0;
let done = 0;
for (const t of installTargets) {
const pct = pkgPct[t.id];
if (pct !== undefined) done += (pct / 100) * t.zips;
}
return Math.min(100, Math.round((done / totalZips) * 100));
}, [pkgPct, installTargets, totalZips]);
const installPlugin = useCallback(async () => { const installPlugin = useCallback(async () => {
setStatus("installing"); setStatus("installing");
setErrorMsg(null); setErrorMsg(null);
setProgress(0);
setProgressLabel(null);
playPressSound(); playPressSound();
try { try {
const pluginsDir = await TauriService.getPluginsDir(); const pluginsDir = await TauriService.getPluginsDir();
@@ -1994,7 +2085,10 @@ function InstallModal({
const pluginBaseUrl = `${RAW_BASE}/.00plugins/${pkg.id}`; const pluginBaseUrl = `${RAW_BASE}/.00plugins/${pkg.id}`;
const allFiles = [pkg.main || "main.js", ...(pkg.files || [])]; const allFiles = [pkg.main || "main.js", ...(pkg.files || [])];
for (const file of allFiles) { for (let i = 0; i < allFiles.length; i++) {
const file = allFiles[i];
setProgressLabel(file);
setProgress(Math.round((i / allFiles.length) * 100));
const res = await TauriService.httpProxyRequest( const res = await TauriService.httpProxyRequest(
"GET", "GET",
`${pluginBaseUrl}/${file}`, `${pluginBaseUrl}/${file}`,
@@ -2006,6 +2100,7 @@ function InstallModal({
`${pluginDir}/${file}`, `${pluginDir}/${file}`,
encoder.encode(res.body), encoder.encode(res.body),
); );
setProgress(Math.round(((i + 1) / allFiles.length) * 100));
} }
await PluginManager.instance.reload(); await PluginManager.instance.reload();
@@ -2063,6 +2158,7 @@ function InstallModal({
for (const depId of dependencies) { for (const depId of dependencies) {
const depPkg = allPackages.find((p) => p.id === depId); const depPkg = allPackages.find((p) => p.id === depId);
if (!depPkg || !depPkg.zips) continue; if (!depPkg || !depPkg.zips) continue;
setProgressLabel(depPkg.name);
try { try {
await TauriService.workshopInstall( await TauriService.workshopInstall(
instanceId, instanceId,
@@ -2079,11 +2175,18 @@ function InstallModal({
const installTo = async (instanceId: string) => { const installTo = async (instanceId: string) => {
setStatus("installing"); setStatus("installing");
setErrorMsg(null); setErrorMsg(null);
setProgress(0);
setProgressLabel(null);
setPkgPct({});
playPressSound(); playPressSound();
const unlisten = await TauriService.onWorkshopProgress((data) => {
setPkgPct((prev) => ({ ...prev, [data.packageId]: data.percent }));
});
try { try {
if (dependencies.length > 0) { if (dependencies.length > 0) {
await installDeps(instanceId); await installDeps(instanceId);
} }
setProgressLabel(pkg.name);
await TauriService.workshopInstall( await TauriService.workshopInstall(
instanceId, instanceId,
pkg.id, pkg.id,
@@ -2101,6 +2204,8 @@ function InstallModal({
? e ? e
: "Unknown error", : "Unknown error",
); );
} finally {
unlisten();
} }
}; };
@@ -2153,6 +2258,25 @@ function InstallModal({
? "Downloading and extracting required dependencies" ? "Downloading and extracting required dependencies"
: "Downloading and extracting assets"} : "Downloading and extracting assets"}
</span> </span>
<div className="w-full flex flex-col gap-1 px-2 mt-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-[#FFFF55] mc-text-shadow truncate">
{progressLabel ||
(isPluginTab ? "Downloading plugin files" : "Downloading assets")}
</span>
<span className="text-[11px] text-[#FFFF55] mc-text-shadow shrink-0">
{Math.floor(isPluginTab ? progress : overallProgress)}%
</span>
</div>
<div className="h-3 border-2 border-[#3F3F3F] bg-black/60 p-0.5">
<div
className="h-full bg-[#FFFF55]"
style={{
width: `${isPluginTab ? progress : overallProgress}%`,
}}
/>
</div>
</div>
{dependencies.length > 0 && ( {dependencies.length > 0 && (
<div className="flex flex-wrap gap-1.5 justify-center mt-2"> <div className="flex flex-wrap gap-1.5 justify-center mt-2">
{dependencies.map((depId) => { {dependencies.map((depId) => {
+6
View File
@@ -48,6 +48,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
setProfile: configRaw.setProfile, setProfile: configRaw.setProfile,
customEditions: configRaw.customEditions, customEditions: configRaw.customEditions,
setCustomEditions: configRaw.setCustomEditions, setCustomEditions: configRaw.setCustomEditions,
customPaths: configRaw.customPaths,
setCustomPaths: configRaw.setCustomPaths,
customizations: configRaw.customizations, customizations: configRaw.customizations,
setCustomizations: configRaw.setCustomizations, setCustomizations: configRaw.setCustomizations,
extraLaunchArgs: configRaw.extraLaunchArgs, extraLaunchArgs: configRaw.extraLaunchArgs,
@@ -64,6 +66,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
configRaw.username, configRaw.theme, configRaw.layout, configRaw.vfxEnabled, configRaw.username, configRaw.theme, configRaw.layout, configRaw.vfxEnabled,
configRaw.rpcEnabled, configRaw.musicVol, configRaw.sfxVol, configRaw.isDayTime, configRaw.rpcEnabled, configRaw.musicVol, configRaw.sfxVol, configRaw.isDayTime,
configRaw.profile, configRaw.linuxRunner, configRaw.perfBoost, configRaw.customEditions, configRaw.profile, configRaw.linuxRunner, configRaw.perfBoost, configRaw.customEditions,
configRaw.customPaths,
configRaw.customizations, configRaw.customizations,
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled, configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled,
configRaw.extraLaunchArgs, configRaw.launchPrefix, configRaw.launchEnvVars, configRaw.startFullscreen, configRaw.extraLaunchArgs, configRaw.launchPrefix, configRaw.launchEnvVars, configRaw.startFullscreen,
@@ -80,6 +83,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
gameRaw.handleLaunch, gameRaw.stopGame, gameRaw.addCustomEdition, gameRaw.handleLaunch, gameRaw.stopGame, gameRaw.addCustomEdition,
gameRaw.deleteCustomEdition, gameRaw.downloadRunner, gameRaw.deleteCustomEdition, gameRaw.downloadRunner,
gameRaw.customizations, gameRaw.updateCustomization, gameRaw.customizations, gameRaw.updateCustomization,
gameRaw.gameLog, gameRaw.clearGameLog,
]); ]);
const audio = useMemo(() => audioRaw, [ const audio = useMemo(() => audioRaw, [
@@ -126,6 +130,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
appleSiliconPerformanceBoost: config.perfBoost, appleSiliconPerformanceBoost: config.perfBoost,
profile: config.profile, profile: config.profile,
customEditions: config.customEditions, customEditions: config.customEditions,
customPaths: config.customPaths,
customizations: config.customizations, customizations: config.customizations,
animationsEnabled: config.animationsEnabled, animationsEnabled: config.animationsEnabled,
vfxEnabled: config.vfxEnabled, vfxEnabled: config.vfxEnabled,
@@ -144,6 +149,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
}, [ }, [
config.username, skinSync.skinBase64, config.theme, config.linuxRunner, config.username, skinSync.skinBase64, config.theme, config.linuxRunner,
config.perfBoost, config.customEditions, config.profile, config.perfBoost, config.customEditions, config.profile,
config.customPaths,
config.customizations, config.vfxEnabled, config.animationsEnabled, config.customizations, config.vfxEnabled, config.animationsEnabled,
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode, config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode,
config.mangohudEnabled, config.extraLaunchArgs, config.launchPrefix, config.mangohudEnabled, config.extraLaunchArgs, config.launchPrefix,
+8
View File
@@ -230,6 +230,14 @@ body {
scrollbar-color: rgba(255, 255, 255, 0.15) transparent; scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
} }
.hidden-scrollbar {
scrollbar-width: none;
}
.hidden-scrollbar::-webkit-scrollbar {
display: none;
}
.no-animations * { .no-animations * {
transition: none !important; transition: none !important;
animation: none !important; animation: none !important;
+6 -1
View File
@@ -19,6 +19,7 @@ export function useAppConfig() {
const [linuxRunner, setLinuxRunner] = useState<string | undefined>(); const [linuxRunner, setLinuxRunner] = useState<string | undefined>();
const [perfBoost, setPerfBoost] = useState(false); const [perfBoost, setPerfBoost] = useState(false);
const [customEditions, setCustomEditions] = useState<CustomEdition[]>([]); const [customEditions, setCustomEditions] = useState<CustomEdition[]>([]);
const [customPaths, setCustomPaths] = useState<Record<string, string>>({});
const [customizations, setCustomizations] = useState<Record<string, { titleImage?: string; panorama?: string }>>({}); const [customizations, setCustomizations] = useState<Record<string, { titleImage?: string; panorama?: string }>>({});
const [mangohudEnabled, setMangohudEnabled] = useState(false); const [mangohudEnabled, setMangohudEnabled] = useState(false);
const [extraLaunchArgs, setExtraLaunchArgs] = useState<string[] | undefined>(); const [extraLaunchArgs, setExtraLaunchArgs] = useState<string[] | undefined>();
@@ -33,6 +34,7 @@ export function useAppConfig() {
if (config.appleSiliconPerformanceBoost !== undefined) if (config.appleSiliconPerformanceBoost !== undefined)
setPerfBoost(config.appleSiliconPerformanceBoost); setPerfBoost(config.appleSiliconPerformanceBoost);
if (config.customEditions) setCustomEditions(config.customEditions); if (config.customEditions) setCustomEditions(config.customEditions);
if (config.customPaths) setCustomPaths(config.customPaths);
if (config.customizations) setCustomizations(config.customizations); if (config.customizations) setCustomizations(config.customizations);
if (config.profile) setProfile(config.profile); if (config.profile) setProfile(config.profile);
if (config.vfxEnabled !== undefined) setVfxEnabled(config.vfxEnabled); if (config.vfxEnabled !== undefined) setVfxEnabled(config.vfxEnabled);
@@ -60,6 +62,7 @@ export function useAppConfig() {
appleSiliconPerformanceBoost: perfBoost, appleSiliconPerformanceBoost: perfBoost,
profile, profile,
customEditions, customEditions,
customPaths,
customizations, customizations,
animationsEnabled, animationsEnabled,
vfxEnabled, vfxEnabled,
@@ -75,7 +78,7 @@ export function useAppConfig() {
skipIntro, skipIntro,
}).catch(console.error); }).catch(console.error);
} }
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, customizations, animationsEnabled, vfxEnabled, rpcEnabled, startFullscreen, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, skipIntro, isLoaded]); }, [username, theme, linuxRunner, perfBoost, profile, customEditions, customPaths, customizations, animationsEnabled, vfxEnabled, rpcEnabled, startFullscreen, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, skipIntro, isLoaded]);
return { return {
username, username,
@@ -108,6 +111,8 @@ export function useAppConfig() {
setPerfBoost, setPerfBoost,
customEditions, customEditions,
setCustomEditions, setCustomEditions,
customPaths,
setCustomPaths,
customizations, customizations,
setCustomizations, setCustomizations,
isLoaded, isLoaded,
+24 -2
View File
@@ -71,7 +71,7 @@ export const BASE_EDITIONS = [
id: "moon_edition", id: "moon_edition",
name: "Minecraft: Moon Edition", name: "Minecraft: Moon Edition",
desc: "Galacticraft LCE port (Modded build!)", desc: "Galacticraft LCE port (Modded build!)",
url: "https://github.com/blazin-blaze/moon-edition/releases/download/v1.0.1/moonEditionWindows64.zip", url: "https://github.com/blazin-blaze/moon-edition/releases/latest/download/moonEditionWindows64.zip",
titleImage: "/images/minecraft_title_moon.png", titleImage: "/images/minecraft_title_moon.png",
supportsSlimSkins: false, supportsSlimSkins: false,
logo: "/images/moonEdition.png", logo: "/images/moonEdition.png",
@@ -113,6 +113,8 @@ interface GameManagerProps {
setProfile: (id: string) => void; setProfile: (id: string) => void;
customEditions: CustomEdition[]; customEditions: CustomEdition[];
setCustomEditions: (editions: CustomEdition[]) => void; setCustomEditions: (editions: CustomEdition[]) => void;
customPaths: Record<string, string>;
setCustomPaths: Dispatch<SetStateAction<Record<string, string>>>;
customizations: Record<string, { titleImage?: string; panorama?: string }>; customizations: Record<string, { titleImage?: string; panorama?: string }>;
setCustomizations: Dispatch< setCustomizations: Dispatch<
SetStateAction<Record<string, { titleImage?: string; panorama?: string }>> SetStateAction<Record<string, { titleImage?: string; panorama?: string }>>
@@ -142,6 +144,7 @@ export function useGameManager({
setProfile, setProfile,
customEditions, customEditions,
setCustomEditions, setCustomEditions,
setCustomPaths,
customizations, customizations,
setCustomizations, setCustomizations,
extraLaunchArgs, extraLaunchArgs,
@@ -157,6 +160,8 @@ export function useGameManager({
number | null number | null
>(null); >(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [gameLog, setGameLog] = useState<string | null>(null);
const gameLogRef = useRef(false);
const [gameUpdateMessage, setGameUpdateMessage] = useState<string | null>( const [gameUpdateMessage, setGameUpdateMessage] = useState<string | null>(
null, null,
); );
@@ -392,11 +397,18 @@ export function useGameManager({
const unlistenRetry = TauriService.onDownloadRetry((attempt) => { const unlistenRetry = TauriService.onDownloadRetry((attempt) => {
setError(`Download failed, retrying (${attempt}/3)...`); setError(`Download failed, retrying (${attempt}/3)...`);
}); });
const unlistenGameLog = TauriService.onGameLog((log) => {
gameLogRef.current = true;
setError(null);
setGameLog(log);
getCurrentWindow().unminimize();
});
return () => { return () => {
unlistenDownload.then((u) => u()); unlistenDownload.then((u) => u());
unlistenRunner.then((u) => u()); unlistenRunner.then((u) => u());
unlistenError.then((u) => u()); unlistenError.then((u) => u());
unlistenRetry.then((u) => u()); unlistenRetry.then((u) => u());
unlistenGameLog.then((u) => u());
}; };
}, [customEditions, checkInstalls]); }, [customEditions, checkInstalls]);
@@ -513,6 +525,7 @@ export function useGameManager({
); );
} catch (e: unknown) { } catch (e: unknown) {
console.error(e); console.error(e);
if (!gameLogRef.current) {
setError( setError(
e instanceof Error e instanceof Error
? e.message ? e.message
@@ -520,6 +533,7 @@ export function useGameManager({
? e ? e
: "Failed to launch game", : "Failed to launch game",
); );
}
} finally { } finally {
setIsGameRunning(false); setIsGameRunning(false);
} }
@@ -597,10 +611,16 @@ export function useGameManager({
[instanceId]: path, [instanceId]: path,
}; };
await TauriService.saveConfig(config); await TauriService.saveConfig(config);
setCustomPaths((prev) => ({ ...prev, [instanceId]: path }));
}, },
[], [setCustomPaths],
); );
const clearGameLog = useCallback(() => {
gameLogRef.current = false;
setGameLog(null);
}, []);
const addToSteam = useCallback( const addToSteam = useCallback(
async ( async (
id: string, id: string,
@@ -638,6 +658,8 @@ export function useGameManager({
runnerDownloadProgress, runnerDownloadProgress,
error, error,
setError, setError,
gameLog,
clearGameLog,
editions, editions,
toggleInstall, toggleInstall,
handleUninstall, handleUninstall,
+17 -19
View File
@@ -1,17 +1,12 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { lceOnlineService } from "../services/LceOnlineService"; import { lceOnlineService, SocialEntry, InviteEntry } from "../services/LceOnlineService";
export function useLceOnlineNotifications() { export function useLceOnlineNotifications() {
const [friendRequestMessage, setFriendRequestMessage] = useState< const [friendRequestMessage, setFriendRequestMessage] = useState<
string | null string | null
>(null); >(null);
const [InviteMessage, setInviteMessage] = useState<string | null>(null); const [InviteMessage, setInviteMessage] = useState<string | null>(null);
const [invites, setInvites] = useState< const [invites, setInvites] = useState<InviteEntry[]>([]);
Array<{ const [requests, setRequests] = useState<SocialEntry[]>([]);
inviteid: string;
from: { uuid: string; username: string };
sessionid: string;
}>
>([]);
const seenRequests = useRef<Set<string>>(new Set()); const seenRequests = useRef<Set<string>>(new Set());
const seenInvites = useRef<Set<string>>(new Set()); const seenInvites = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
@@ -20,11 +15,12 @@ export function useLceOnlineNotifications() {
const poll = async () => { const poll = async () => {
if (!lceOnlineService.signedIn) return; if (!lceOnlineService.signedIn) return;
try { try {
const lists = await lceOnlineService.getSocialLists(); const requestsData = await lceOnlineService.getSocialLists();
lists.requests.forEach((r: string) => { setRequests(requestsData.requests);
if (!seenRequests.current.has(r)) { requestsData.requests.forEach((r) => {
seenRequests.current.add(r); if (!seenRequests.current.has(r.username)) {
setFriendRequestMessage(`${r} wants to be friends!`); seenRequests.current.add(r.username);
setFriendRequestMessage(`${r.displayName} wants to be friends!`);
} }
}); });
} catch (e) {} } catch (e) {}
@@ -34,7 +30,7 @@ export function useLceOnlineNotifications() {
invitesData.forEach((i) => { invitesData.forEach((i) => {
if (!seenInvites.current.has(i.inviteid)) { if (!seenInvites.current.has(i.inviteid)) {
seenInvites.current.add(i.inviteid); seenInvites.current.add(i.inviteid);
setInviteMessage(`${i.from.username} invited you to play!`); setInviteMessage(`${i.from.displayName} invited you to play!`);
} }
}); });
} catch {} } catch {}
@@ -43,10 +39,11 @@ export function useLceOnlineNotifications() {
const init = async () => { const init = async () => {
if (lceOnlineService.signedIn) { if (lceOnlineService.signedIn) {
try { try {
const lists = await lceOnlineService.getSocialLists(); const requestData = await lceOnlineService.getSocialLists();
lists.requests.forEach((r: string) => { setRequests(requestData.requests);
if (!seenRequests.current.has(r)) { requestData.requests.forEach((r) => {
seenRequests.current.add(r); if (!seenRequests.current.has(r.username)) {
seenRequests.current.add(r.username);
} }
}); });
} catch (e) {} } catch (e) {}
@@ -56,7 +53,7 @@ export function useLceOnlineNotifications() {
invitesData.forEach((i) => { invitesData.forEach((i) => {
if (!seenInvites.current.has(i.inviteid)) { if (!seenInvites.current.has(i.inviteid)) {
seenInvites.current.add(i.inviteid); seenInvites.current.add(i.inviteid);
setInviteMessage(`${i.from.username} invited you to play!`); setInviteMessage(`${i.from.displayName} invited you to play!`);
} }
}); });
} catch {} } catch {}
@@ -76,5 +73,6 @@ export function useLceOnlineNotifications() {
clearFriendRequestMessage: () => setFriendRequestMessage(null), clearFriendRequestMessage: () => setFriendRequestMessage(null),
clearInviteMessage: () => setInviteMessage(null), clearInviteMessage: () => setInviteMessage(null),
invites, invites,
requests,
}; };
} }
+3 -2
View File
@@ -103,8 +103,9 @@ export function useSkinSync({ username, profile, editions }: UseSkinSyncProps) {
}; };
const seededId = getSeededId(username); const seededId = getSeededId(username);
const packId = seededId.slice(-4); //neo: (comment reason stated below) const packId = seededId.slice(-4);
const files: PCKAsset[] = [ const files: PCKAsset[] = [
/*neo: commented because it causes neoLegacy to think its a Texture Pack, while it does nothing when removed. preserved for future reference.
{ {
id: "0", id: "0",
path: "0", path: "0",
@@ -117,7 +118,7 @@ export function useSkinSync({ username, profile, editions }: UseSkinSyncProps) {
value: packId, value: packId,
}, },
], ],
}, },*/
{ {
id: `dlcskin${seededId}`, id: `dlcskin${seededId}`,
path: `dlcskin${seededId}.png`, path: `dlcskin${seededId}.png`,
+14 -6
View File
@@ -27,6 +27,7 @@ import { CinematicIntro } from "../components/common/CinematicIntro";
import { DownloadOverlay } from "../components/layout/DownloadOverlay"; import { DownloadOverlay } from "../components/layout/DownloadOverlay";
import { AppHeader } from "../components/layout/AppHeader"; import { AppHeader } from "../components/layout/AppHeader";
import { AchievementToast } from "../components/common/AchievementToast"; import { AchievementToast } from "../components/common/AchievementToast";
import GameLogModal from "../components/modals/GameLogModal";
import { import {
useUI, useUI,
useConfig, useConfig,
@@ -74,7 +75,7 @@ export default function App() {
InviteMessage, InviteMessage,
clearFriendRequestMessage, clearFriendRequestMessage,
clearInviteMessage, clearInviteMessage,
invites invites,
} = notifications; } = notifications;
const [showSetup, setShowSetup] = useState(false); const [showSetup, setShowSetup] = useState(false);
const [isSetupChecked, setIsSetupChecked] = useState(false); const [isSetupChecked, setIsSetupChecked] = useState(false);
@@ -398,7 +399,16 @@ export default function App() {
editions={game.editions} editions={game.editions}
/> />
<AchievementToast message={game.error} onClose={clearError} /> <AchievementToast
message={game.gameLog ? null : game.error}
onClose={clearError}
/>
<GameLogModal
isOpen={!!game.gameLog}
log={game.gameLog}
onClose={game.clearGameLog}
playBackSound={audio.playBackSound}
/>
<AchievementToast <AchievementToast
message={updateMessage} message={updateMessage}
@@ -571,7 +581,7 @@ export default function App() {
</div> </div>
</div> </div>
<main className="flex-1 w-full relative"> <main className="flex-1 w-full relative min-h-0">
<div <div
className={`w-full h-full flex flex-col items-center justify-center ${isUiHidden ? "opacity-0 pointer-events-none" : "opacity-100"}`} className={`w-full h-full flex flex-col items-center justify-center ${isUiHidden ? "opacity-0 pointer-events-none" : "opacity-100"}`}
> >
@@ -612,9 +622,7 @@ export default function App() {
{activeView === "devtools" && ( {activeView === "devtools" && (
<DevtoolsView key="devtools-view" /> <DevtoolsView key="devtools-view" />
)} )}
{activeView === "guides" && ( {activeView === "guides" && <GuidesView key="guides-view" />}
<GuidesView key="guides-view" />
)}
{activeView === "pck-editor" && ( {activeView === "pck-editor" && (
<PckEditorView key="pck-editor-view" /> <PckEditorView key="pck-editor-view" />
)} )}
+21 -20
View File
@@ -12,9 +12,22 @@ export interface SessionData {
account: LceOnlineAccount; account: LceOnlineAccount;
} }
export interface FriendRequest { export interface InviteEntry {
inviteid: string;
from: { uuid: string; displayName: string; username: string };
sessionid: string;
}
export interface SocialEntry {
username: string; username: string;
displayName: string; displayName: string;
uuid: string;
};
export interface SocialList {
friends: SocialEntry[];
friendRequests: SocialEntry[];
blocked: SocialEntry[];
} }
export class LceOnlineService { export class LceOnlineService {
@@ -220,25 +233,13 @@ export class LceOnlineService {
} }
} }
async getSocialLists(): Promise<{ async getSocialLists() {
friends: string[]; const res = await this.request<SocialList>("GET", "/getSocialLists", null);
requests: string[]; if (typeof res === "string") throw new Error(res);
blocked: string[];
}> {
const raw: string = await this.request<string>(
"POST",
"/getSocialLists",
null,
);
if (typeof raw !== "string") {
return { friends: [], requests: [], blocked: [] };
}
const withoutPrefix = raw.startsWith("-") ? raw.slice(1) : raw;
const parts = withoutPrefix.split("|");
return { return {
friends: parts[0] ? parts[0].split(",").filter(Boolean) : [], friends: res?.friends ?? [],
requests: parts[1] ? parts[1].split(",").filter(Boolean) : [], requests: res?.friendRequests ?? [],
blocked: parts[2] ? parts[2].split(",").filter(Boolean) : [], blocked: res?.blocked ?? [],
}; };
} }
@@ -295,7 +296,7 @@ export class LceOnlineService {
async getInvites(): Promise< async getInvites(): Promise<
Array<{ Array<{
inviteid: string; inviteid: string;
from: { uuid: string; username: string }; from: { uuid: string; displayName: string; username: string };
sessionid: string; sessionid: string;
}> }>
> { > {
+33
View File
@@ -194,6 +194,12 @@ export class TauriService {
); );
} }
static onWorkshopProgress(callback: (data: { packageId: string; percent: number }) => void) {
return listen<{ instanceId: string; percent: number }>("workshop-progress", (event) =>
callback({ packageId: event.payload.instanceId, percent: event.payload.percent }),
);
}
static onRunnerDownloadProgress(callback: (percent: number) => void) { static onRunnerDownloadProgress(callback: (percent: number) => void) {
return listen<number>("runner-download-progress", (event) => return listen<number>("runner-download-progress", (event) =>
callback(event.payload), callback(event.payload),
@@ -212,6 +218,12 @@ export class TauriService {
); );
} }
static onGameLog(callback: (log: string) => void) {
return listen<string>("game-log", (event) =>
callback(event.payload),
);
}
static onDownloadRetry(callback: (attempt: number) => void) { static onDownloadRetry(callback: (attempt: number) => void) {
return listen<number>("download-retry", (event) => return listen<number>("download-retry", (event) =>
callback(event.payload), callback(event.payload),
@@ -420,4 +432,25 @@ export class TauriService {
): Promise<string> { ): Promise<string> {
return invoke("import_world", { inputPath, outputPath }); return invoke("import_world", { inputPath, outputPath });
} }
static async importLceSave(
inputPath: string,
outputDir: string,
): Promise<string> {
return invoke("import_lce_save", { inputPath, outputDir });
}
static async javaToLce(
javaWorldPath: string,
outputMsPath: string,
): Promise<string> {
return invoke("java_to_lce", { javaWorldPath, outputMsPath });
}
static async lceToJava(
inputMsPath: string,
javaWorldOutput: string,
): Promise<string> {
return invoke("lce_to_java", { inputMsPath, javaWorldOutput });
}
} }