mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-09-24 09:03:44 +00:00
feat: xbox360+ps3+java->windows64 world conversion (#145)
Co-authored-by: str1k3r <[email protected]>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ name: Nix
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Executable
+48
@@ -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 |
Generated
+9
-9
@@ -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]]
|
||||||
|
|||||||
@@ -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
+106
-86
@@ -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 {
|
||||||
{
|
return Ok(());
|
||||||
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(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 {
|
||||||
{
|
return Ok(());
|
||||||
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(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 {
|
||||||
{
|
return Ok(());
|
||||||
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(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
||||||
|
}
|
||||||
@@ -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(®ion_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(§ion_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(®ion_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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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(®ion_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, ®ion_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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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,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",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,33 +302,20 @@ 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 mt-2"
|
||||||
className="w-32 h-10 flex items-center justify-center text-xl text-white mc-text-shadow"
|
style={{
|
||||||
style={{
|
backgroundImage: "url('/images/Button_Background.png')",
|
||||||
backgroundImage: "url('/images/Button_Background.png')",
|
backgroundSize: "100% 100%",
|
||||||
backgroundSize: "100% 100%",
|
imageRendering: "pixelated",
|
||||||
imageRendering: "pixelated",
|
}}
|
||||||
}}
|
>
|
||||||
>
|
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>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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%",
|
||||||
|
|||||||
@@ -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,78 +471,69 @@ 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={{
|
<button
|
||||||
backgroundImage: "url('/images/frame_background.png')",
|
data-index="0"
|
||||||
backgroundSize: "100% 100%",
|
onMouseEnter={() => setFocusIndex(0)}
|
||||||
imageRendering: "pixelated",
|
onClick={() => {
|
||||||
}}
|
playPressSound();
|
||||||
>
|
if (viewMode === "skin") handleImportClick();
|
||||||
<div className="w-full flex items-center border-b-2 border-[#373737] pb-4 mb-4 relative min-h-10">
|
else capeFileInputRef.current?.click();
|
||||||
<div className="absolute left-0 right-0 flex justify-center gap-4 items-center">
|
}}
|
||||||
<button
|
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === 0 ? "text-[#FFFF55]" : "text-white"}`}
|
||||||
data-index="0"
|
style={{
|
||||||
onMouseEnter={() => setFocusIndex(0)}
|
backgroundImage:
|
||||||
onClick={() => {
|
focusIndex === 0
|
||||||
playPressSound();
|
? "url('/images/button_highlighted.png')"
|
||||||
if (viewMode === "skin") handleImportClick();
|
: "url('/images/Button_Background.png')",
|
||||||
else capeFileInputRef.current?.click();
|
backgroundSize: "100% 100%",
|
||||||
}}
|
imageRendering: "pixelated",
|
||||||
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === 0 ? "text-[#FFFF55]" : "text-white"}`}
|
}}
|
||||||
style={{
|
>
|
||||||
backgroundImage:
|
{viewMode === "skin" ? "Import Skin" : "Import Cape"}
|
||||||
focusIndex === 0
|
</button>
|
||||||
? "url('/images/button_highlighted.png')"
|
|
||||||
: "url('/images/Button_Background.png')",
|
|
||||||
backgroundSize: "100% 100%",
|
|
||||||
imageRendering: "pixelated",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{viewMode === "skin" ? "Import Skin" : "Import Cape"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
data-index="1"
|
data-index="1"
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (viewMode === "skin" && !isActiveDefault) setFocusIndex(1);
|
if (viewMode === "skin" && !isActiveDefault) setFocusIndex(1);
|
||||||
else if (viewMode === "cape" && !isActiveCapeDefault)
|
else if (viewMode === "cape" && !isActiveCapeDefault)
|
||||||
setFocusIndex(1);
|
setFocusIndex(1);
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
playPressSound();
|
playPressSound();
|
||||||
if (viewMode === "skin") handleDeleteActive();
|
if (viewMode === "skin") handleDeleteActive();
|
||||||
else handleDeleteActiveCape();
|
else handleDeleteActiveCape();
|
||||||
}}
|
}}
|
||||||
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${
|
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${
|
||||||
|
(viewMode === "skin" && isActiveDefault) ||
|
||||||
|
(viewMode === "cape" && isActiveCapeDefault)
|
||||||
|
? "text-gray-400 opacity-80 cursor-not-allowed"
|
||||||
|
: focusIndex === 1
|
||||||
|
? "text-[#FFFF55]"
|
||||||
|
: "text-white"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
(viewMode === "skin" && isActiveDefault) ||
|
(viewMode === "skin" && isActiveDefault) ||
|
||||||
(viewMode === "cape" && isActiveCapeDefault)
|
(viewMode === "cape" && isActiveCapeDefault)
|
||||||
? "text-gray-400 opacity-80 cursor-not-allowed"
|
? "url('/images/Button_Background2.png')"
|
||||||
: focusIndex === 1
|
: focusIndex === 1
|
||||||
? "text-[#FFFF55]"
|
? "url('/images/button_highlighted.png')"
|
||||||
: "text-white"
|
: "url('/images/Button_Background.png')",
|
||||||
}`}
|
backgroundSize: "100% 100%",
|
||||||
style={{
|
imageRendering: "pixelated",
|
||||||
backgroundImage:
|
}}
|
||||||
(viewMode === "skin" && isActiveDefault) ||
|
>
|
||||||
(viewMode === "cape" && isActiveCapeDefault)
|
{viewMode === "skin" ? "Delete Skin" : "Delete Cape"}
|
||||||
? "url('/images/Button_Background2.png')"
|
</button>
|
||||||
: focusIndex === 1
|
|
||||||
? "url('/images/button_highlighted.png')"
|
|
||||||
: "url('/images/Button_Background.png')",
|
|
||||||
backgroundSize: "100% 100%",
|
|
||||||
imageRendering: "pixelated",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{viewMode === "skin" ? "Delete Skin" : "Delete Cape"}
|
|
||||||
</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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
<div
|
const isSplit = group.parts.length > 1;
|
||||||
key={file}
|
return (
|
||||||
className="flex items-center justify-between gap-4 bg-black/20 p-2 rounded-sm border border-[#222]"
|
<div
|
||||||
>
|
key={group.main}
|
||||||
<span className="text-xs text-[#A0A0A0] mc-text-shadow font-mono">
|
className="flex items-center justify-between gap-4 bg-black/20 p-2 rounded-sm border border-[#222]"
|
||||||
{file}
|
>
|
||||||
</span>
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
{dest && (
|
<span className="text-xs text-[#A0A0A0] mc-text-shadow font-mono">
|
||||||
<span className="text-[9px] text-[#fff] mc-text-shadow truncate uppercase tracking-tighter">
|
{group.main}
|
||||||
{dest}
|
{isSplit && (
|
||||||
</span>
|
<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
|
||||||
</div>
|
</span>
|
||||||
))}
|
)}
|
||||||
|
</span>
|
||||||
|
{isSplit && (
|
||||||
|
<span className="text-[9px] text-[#666] mc-text-shadow font-mono">
|
||||||
|
{group.parts.slice(1).join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+32
-10
@@ -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,13 +525,15 @@ export function useGameManager({
|
|||||||
);
|
);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError(
|
if (!gameLogRef.current) {
|
||||||
e instanceof Error
|
setError(
|
||||||
? e.message
|
e instanceof Error
|
||||||
: typeof e === "string"
|
? e.message
|
||||||
? e
|
: typeof e === "string"
|
||||||
: "Failed to launch game",
|
? e
|
||||||
);
|
: "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,
|
||||||
@@ -660,4 +682,4 @@ export function useGameManager({
|
|||||||
updateCustomization,
|
updateCustomization,
|
||||||
saveCustomPath,
|
saveCustomPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,9 +103,10 @@ 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",
|
||||||
type: PCKAssetType.INFO,
|
type: PCKAssetType.INFO,
|
||||||
@@ -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`,
|
||||||
|
|||||||
+30
-22
@@ -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);
|
||||||
@@ -325,9 +326,9 @@ export default function App() {
|
|||||||
<div
|
<div
|
||||||
className={`w-screen h-screen overflow-hidden select-none flex flex-col relative bg-black text-white font-['Mojangles'] outline-none focus:outline-none ${!config.animationsEnabled ? "no-animations" : ""}`}
|
className={`w-screen h-screen overflow-hidden select-none flex flex-col relative bg-black text-white font-['Mojangles'] outline-none focus:outline-none ${!config.animationsEnabled ? "no-animations" : ""}`}
|
||||||
>
|
>
|
||||||
{showHeader && (
|
{showHeader && (
|
||||||
<AppHeader playPressSound={audio.playPressSound} uiFade={uiFade} />
|
<AppHeader playPressSound={audio.playPressSound} uiFade={uiFade} />
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -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,25 +581,25 @@ 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"}`}
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{activeView === "main" && (
|
{activeView === "main" && (
|
||||||
<SkinViewer
|
<SkinViewer
|
||||||
key="skin-viewer"
|
key="skin-viewer"
|
||||||
username={config.username}
|
username={config.username}
|
||||||
setUsername={config.setUsername}
|
setUsername={config.setUsername}
|
||||||
playPressSound={audio.playPressSound}
|
playPressSound={audio.playPressSound}
|
||||||
skinUrl={skinUrl}
|
skinUrl={skinUrl}
|
||||||
capeUrl={config.legacyMode ? null : capeUrl}
|
capeUrl={config.legacyMode ? null : capeUrl}
|
||||||
setSkinUrl={setSkinUrl}
|
setSkinUrl={setSkinUrl}
|
||||||
setActiveView={setActiveView}
|
setActiveView={setActiveView}
|
||||||
setIsUiHidden={setIsUiHidden}
|
setIsUiHidden={setIsUiHidden}
|
||||||
isFocusedSection={focusSection === "skin"}
|
isFocusedSection={focusSection === "skin"}
|
||||||
onNavigateRight={onNavigateToMenu}
|
onNavigateRight={onNavigateToMenu}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
@@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user