diff --git a/.github/workflows/experimental.yml b/.github/workflows/experimental.yml
index 74b9acd..770daed 100644
--- a/.github/workflows/experimental.yml
+++ b/.github/workflows/experimental.yml
@@ -85,7 +85,7 @@ jobs:
with:
name: ${{ matrix.artifact-name }}
path: |
- src-tauri/target/*/release/bundle/**/*
+ src-tauri/target/**/release/bundle/**/*
build-flatpak:
runs-on: ubuntu-latest
@@ -108,3 +108,62 @@ jobs:
with:
name: 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
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index 7c126de..2845c0d 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -3,7 +3,6 @@ name: Nix
on:
push:
branches: [main]
- pull_request:
workflow_dispatch:
permissions:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 3b4cdc4..866062f 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -80,8 +80,67 @@ jobs:
uploadUpdaterJson: true
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:
- needs: get-version
+ needs: [get-version, build-appimage]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -99,11 +158,17 @@ jobs:
bundle: emerald.flatpak
manifest-path: flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.yml
cache-key: flatpak-builder-${{ github.sha }}
+ - uses: actions/download-artifact@v4
+ with:
+ name: appimage
+ path: appimage
- uses: softprops/action-gh-release@v2
with:
body: ${{ needs.get-version.outputs.body }}
tag_name: v${{ needs.get-version.outputs.version }}
- files: emerald.flatpak
+ files: |
+ emerald.flatpak
+ appimage/*.AppImage*
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/README.md b/README.md
index 7a8945b..edf4f8d 100644
--- a/README.md
+++ b/README.md
@@ -57,16 +57,19 @@ LCE Emerald Launcher is the easiest way to play Minecraft Legacy Console Edition
| 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!) |
| **Lightweight** | Very light RAM usage thanks to Rust backend and Tauri framework |
| **Easy Configuration** | Built-in settings for username, game parameters, and profiles |
| **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) |
| **Discord Rich Presence** | Show your current activity and game status on Discord |
| **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! |
+| **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
- **The LCE Community** - Research and foundations for LCE on PC
- **Str1k3r** - Original creator of LCEOnline
+- **DTention** - Original creator of [LCE-Save-Converter](https://github.com/dtentiion/LCE-Save-Converter)
---
diff --git a/flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.metainfo.xml b/flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.metainfo.xml
index 6098ba7..49353e8 100644
--- a/flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.metainfo.xml
+++ b/flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.metainfo.xml
@@ -12,7 +12,7 @@
https://github.com
Emerald Team
-
+
Latest stable release of the Emerald Legacy Launcher.
diff --git a/package.json b/package.json
index 6afa4c3..86f812c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "emerald-legacy-launcher",
"private": true,
- "version": "1.5.1",
+ "version": "1.6.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/pkg/AppImage/AppImage.sh b/pkg/AppImage/AppImage.sh
new file mode 100755
index 0000000..844c7f0
--- /dev/null
+++ b/pkg/AppImage/AppImage.sh
@@ -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!"
diff --git a/public/Skins/kowhaifan.png b/public/Skins/kowhaifan.png
new file mode 100644
index 0000000..f815414
Binary files /dev/null and b/public/Skins/kowhaifan.png differ
diff --git a/public/Skins/str1k3r.png b/public/Skins/str1k3r.png
new file mode 100644
index 0000000..bb57ed6
Binary files /dev/null and b/public/Skins/str1k3r.png differ
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 0c92e2a..6fa754f 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -1043,7 +1043,7 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "emerald-legacy-launcher"
-version = "1.5.1"
+version = "1.6.0"
dependencies = [
"base64 0.21.7",
"byteorder",
@@ -2027,7 +2027,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2 0.6.3",
+ "socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -4708,9 +4708,9 @@ dependencies = [
[[package]]
name = "tao-macros"
-version = "0.1.3"
+version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
+checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849"
dependencies = [
"proc-macro2",
"quote",
@@ -5402,18 +5402,18 @@ dependencies = [
[[package]]
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"
-checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow 1.0.1",
]
[[package]]
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"
-checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tower"
@@ -6098,7 +6098,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.48.0",
]
[[package]]
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 4948dec..25f4d90 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,9 +1,9 @@
[package]
name = "emerald-legacy-launcher"
-version = "1.5.1"
+version = "1.6.0"
description = "A FOSS, cross-platform launcher for Minecraft Legacy Edition"
authors = ["Emerald Team"]
-edition = "2021"
+edition = "2024"
[lib]
name = "emerald_lib"
@@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
-tauri = { version = "2", features = [ "tray-icon", "image-png"] }
+tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -43,3 +43,8 @@ tauri-plugin-single-instance = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
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
diff --git a/src-tauri/src/commands/console2lce.rs b/src-tauri/src/commands/console2lce.rs
index 503749e..fd5a75f 100644
--- a/src-tauri/src/commands/console2lce.rs
+++ b/src-tauri/src/commands/console2lce.rs
@@ -1,6 +1,1157 @@
-use std::path::Path;
+use std::collections::HashMap;
+use std::collections::HashSet;
use std::fs;
+use std::io::{Read, Write};
+use std::path::Path;
use tauri::AppHandle;
+const STFS_MAGIC: [u8; 4] = *b"CON ";
+const STFS_BASE_OFFSET: usize = 0xA000;
+const STFS_BLOCK_SIZE: usize = 0x1000;
+const STFS_BLOCKS_PER_GRP: usize = 170;
+const FILE_ENTRY_SIZE: usize = 144;
+const REGION_SECT_COUNT: usize = 1024;
+const LZX_BLOCK_SIZE: usize = 0x8000;
+const SFO_FMT_UTF8_RAW: u16 = 0x0004;
+const SFO_FMT_UTF8_STR: u16 = 0x0204;
+const SFO_FMT_INT32: u16 = 0x0404;
+const LEVELNAME_SIG: &[u8] = b"\x08\x00\x09LevelName";
+const PLAYER_POS_SIG: &[u8] = b"\x09\x00\x03Pos\x06\x00\x00\x00\x03";
+struct StfsEntry {
+ name: String,
+ start_block: usize,
+ size: usize,
+}
+
+struct FileEntry {
+ filename: String,
+ length: u32,
+ start_offset: u32,
+ last_mod: i64,
+}
+
+fn s32(buf: &mut [u8], o: usize) {
+ buf[o..o + 4].reverse();
+}
+
+fn read_hdr_be(data: &[u8]) -> (u32, u32, i16, i16) {
+ let ho = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
+ let ne = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
+ let ov = i16::from_be_bytes([data[8], data[9]]);
+ let cv = i16::from_be_bytes([data[10], data[11]]);
+ (ho, ne, ov, cv)
+}
+
+fn parse_ftable_be(data: &[u8], ho: u32, ne: u32) -> Vec {
+ let mut out = Vec::new();
+ for i in 0..ne {
+ let base = ho as usize + i as usize * FILE_ENTRY_SIZE;
+ if base + FILE_ENTRY_SIZE > data.len() {
+ break;
+ }
+ let raw = &data[base..base + FILE_ENTRY_SIZE];
+ let fn_u16s: Vec = (0..64)
+ .map(|j| u16::from_be_bytes([raw[j * 2], raw[j * 2 + 1]]))
+ .collect();
+ let fn_str: String = fn_u16s
+ .iter()
+ .take_while(|&&c| c != 0)
+ .filter_map(|&c| char::from_u32(c as u32))
+ .collect();
+ let length = u32::from_be_bytes([raw[128], raw[129], raw[130], raw[131]]);
+ let start_offset = u32::from_be_bytes([raw[132], raw[133], raw[134], raw[135]]);
+ let last_mod = i64::from_be_bytes([
+ raw[136], raw[137], raw[138], raw[139], raw[140], raw[141], raw[142], raw[143],
+ ]);
+ if !fn_str.is_empty() && length > 0 {
+ out.push(FileEntry {
+ filename: fn_str,
+ length,
+ start_offset,
+ last_mod,
+ });
+ }
+ }
+ out
+}
+
+fn sanitise(name: &str) -> String {
+ let mut s = String::new();
+ let mut prev_underscore = false;
+ for c in name.chars() {
+ let replace = c.is_control()
+ || matches!(
+ c,
+ '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
+ );
+ let c = if replace { '_' } else { c };
+ if c == '_' {
+ if !prev_underscore {
+ s.push(c);
+ }
+ prev_underscore = true;
+ } else {
+ s.push(c);
+ prev_underscore = false;
+ }
+ }
+ let s = s.trim_matches(|c: char| c == '_' || c == '.' || c == ' ');
+ let s = if s.len() > 64 { &s[..64] } else { s };
+ if s.is_empty() {
+ "MinecraftSave".to_string()
+ } else {
+ s.to_string()
+ }
+}
+
+fn stfs_block_offset(block_num: usize, table_shift: usize) -> usize {
+ let g = block_num / STFS_BLOCKS_PER_GRP;
+ let hash_before = if g == 0 {
+ 2
+ } else {
+ 3 + 2 * g + table_shift
+ };
+ STFS_BASE_OFFSET + (block_num + hash_before) * STFS_BLOCK_SIZE
+}
+
+fn stfs_get_hash_entry(raw: &[u8], block_num: usize, table_shift: usize) -> Option {
+ let group = block_num / STFS_BLOCKS_PER_GRP;
+ let first_data = stfs_block_offset(group * STFS_BLOCKS_PER_GRP, table_shift);
+ let idx = (block_num % STFS_BLOCKS_PER_GRP) * 0x18;
+ for gap in &[2, 1] {
+ let hash_off = first_data - STFS_BLOCK_SIZE * gap;
+ let entry_off = hash_off + idx;
+ if entry_off + 0x18 > raw.len() {
+ continue;
+ }
+ let nxt = ((raw[entry_off + 0x15] as usize) << 16)
+ | ((raw[entry_off + 0x16] as usize) << 8)
+ | raw[entry_off + 0x17] as usize;
+ if nxt != 0xFFFFFF {
+ return Some(nxt);
+ }
+ }
+ None
+}
+
+fn stfs_read_file(raw: &[u8], start: usize, size: usize, table_shift: usize) -> Vec {
+ let max_block = (raw.len().saturating_sub(STFS_BASE_OFFSET)) / STFS_BLOCK_SIZE;
+ let mut out = Vec::with_capacity(size);
+ let mut blk = start;
+ let mut rem = size;
+ while rem > 0 {
+ let off = stfs_block_offset(blk, table_shift);
+ let end = (off + STFS_BLOCK_SIZE).min(raw.len());
+ if off >= raw.len() {
+ break;
+ }
+ let chunk = &raw[off..end];
+ let take = rem.min(chunk.len());
+ out.extend_from_slice(&chunk[..take]);
+ rem -= take;
+ match stfs_get_hash_entry(raw, blk, table_shift) {
+ Some(nxt) if nxt > 0 && nxt < max_block => blk = nxt,
+ _ => blk += 1,
+ }
+ }
+ out
+}
+
+struct StfsPackage {
+ raw: Vec,
+ table_shift: usize,
+}
+
+impl StfsPackage {
+ const DISP_OFF: usize = 0x0411;
+ const DISP_LEN: usize = 128;
+ const THUMB_OFF: usize = 0x171A;
+ fn new(raw: Vec) -> Result {
+ if raw.len() < 4 || raw[..4] != STFS_MAGIC {
+ return Err(format!(
+ "Not an STFS CON file (magic={:?})",
+ &raw[..4.min(raw.len())]
+ ));
+ }
+ Ok(Self {
+ raw,
+ table_shift: 1,
+ })
+ }
+
+ fn read_block(&self, b: usize) -> Vec {
+ let o = stfs_block_offset(b, self.table_shift);
+ let end = (o + STFS_BLOCK_SIZE).min(self.raw.len());
+ self.raw[o..end].to_vec()
+ }
+
+ fn display_name(&self) -> String {
+ let start = Self::DISP_OFF;
+ let end = (start + Self::DISP_LEN * 2).min(self.raw.len());
+ let nb = &self.raw[start..end];
+ let mut name = String::new();
+ let mut i = 0;
+ while i + 1 < nb.len() {
+ let c = u16::from_be_bytes([nb[i], nb[i + 1]]);
+ if c == 0 {
+ break;
+ }
+ if let Some(ch) = char::from_u32(c as u32) {
+ name.push(ch);
+ }
+ i += 2;
+ }
+ if name.is_empty() {
+ "Unknown".to_string()
+ } else {
+ name
+ }
+ }
+
+ fn thumbnail(&self) -> Option> {
+ let png_magic = b"\x89PNG\r\n\x1a\n";
+ let search_start = Self::THUMB_OFF;
+ let idx = self.raw[search_start..]
+ .windows(png_magic.len())
+ .position(|w| w == png_magic)?;
+ let abs_idx = search_start + idx;
+ let iend = self.raw[abs_idx..].windows(4).position(|w| w == b"IEND")?;
+ let abs_iend = abs_idx + iend;
+ Some(self.raw[abs_idx..(abs_iend + 12).min(self.raw.len())].to_vec())
+ }
+
+ fn file_table(&self) -> Vec {
+ let ft_block = (self.raw[0x37E] as usize)
+ | ((self.raw[0x37F] as usize) << 8)
+ | ((self.raw[0x380] as usize) << 16);
+ let blk_data = self.read_block(ft_block);
+ let mut entries = Vec::new();
+ for i in 0..64 {
+ let base = i * 64;
+ if base + 64 > blk_data.len() {
+ break;
+ }
+ let e = &blk_data[base..base + 64];
+ let name_len = (e[0x28] & 0x3F) as usize;
+ if name_len == 0 {
+ continue;
+ }
+ if (e[0x28] >> 6) & 0x02 != 0 {
+ continue;
+ }
+ let name: String = e[..name_len].iter().map(|&b| b as char).collect();
+ let start_block = (e[0x2F] as usize)
+ | ((e[0x30] as usize) << 8)
+ | ((e[0x31] as usize) << 16);
+ let file_size =
+ u32::from_be_bytes([e[0x34], e[0x35], e[0x36], e[0x37]]) as usize;
+ if !name.is_empty() && file_size > 0 {
+ entries.push(StfsEntry {
+ name,
+ start_block,
+ size: file_size,
+ });
+ }
+ }
+ entries
+ }
+
+ fn extract_savegame_dat(&self) -> Option> {
+ for cand in &["savegame.dat", "SAVEGAME.DAT"] {
+ for entry in self.file_table() {
+ if entry.name.to_lowercase() == cand.to_lowercase() {
+ return Some(stfs_read_file(
+ &self.raw,
+ entry.start_block,
+ entry.size,
+ self.table_shift,
+ ));
+ }
+ }
+ }
+ None
+ }
+}
+
+fn parse_region_filename(name: &str) -> Option<(&'static str, i32, i32)> {
+ let n = name.to_lowercase().replace('\\', "/");
+ if let Some(rest) = n.strip_prefix("dim1/r.") {
+ if let Some(coords) = rest.strip_suffix(".mcr") {
+ let dot_pos = coords.find('.')?;
+ let x = coords[..dot_pos].parse().ok()?;
+ let z = coords[dot_pos + 1..].parse().ok()?;
+ return Some(("end", x, z));
+ }
+ }
+ if let Some(rest) = n.strip_prefix("dim-1r.") {
+ if let Some(coords) = rest.strip_suffix(".mcr") {
+ let dot_pos = coords.find('.')?;
+ let x = coords[..dot_pos].parse().ok()?;
+ let z = coords[dot_pos + 1..].parse().ok()?;
+ return Some(("nether", x, z));
+ }
+ }
+ if let Some(rest) = n.strip_prefix("r.") {
+ if let Some(coords) = rest.strip_suffix(".mcr") {
+ let dot_pos = coords.find('.')?;
+ let x = coords[..dot_pos].parse().ok()?;
+ let z = coords[dot_pos + 1..].parse().ok()?;
+ return Some(("overworld", x, z));
+ }
+ }
+ None
+}
+
+fn spawn_sig(axis: char) -> [u8; 9] {
+ let mut sig = [0u8; 9];
+ sig[0] = 0x03;
+ sig[1] = 0x00;
+ sig[2] = 0x06;
+ sig[3..9].copy_from_slice(format!("Spawn{}", axis).as_bytes());
+ sig
+}
+
+fn read_spawn(level_dat: &[u8]) -> Option<(i32, i32, i32)> {
+ let mut coords = Vec::new();
+ for axis in ['X', 'Y', 'Z'] {
+ let sig = spawn_sig(axis);
+ let idx = level_dat
+ .windows(sig.len())
+ .position(|w| w == sig)?;
+ let val_off = idx + sig.len();
+ if val_off + 4 > level_dat.len() {
+ return None;
+ }
+ coords.push(i32::from_be_bytes([
+ level_dat[val_off],
+ level_dat[val_off + 1],
+ level_dat[val_off + 2],
+ level_dat[val_off + 3],
+ ]));
+ }
+ Some((coords[0], coords[1], coords[2]))
+}
+
+fn patch_spawn(level_dat: &mut [u8], x: i32, y: i32, z: i32) {
+ for (axis, val) in [('X', x), ('Y', y), ('Z', z)] {
+ let sig = spawn_sig(axis);
+ if let Some(idx) = level_dat.windows(sig.len()).position(|w| w == sig) {
+ let val_off = idx + sig.len();
+ if val_off + 4 <= level_dat.len() {
+ level_dat[val_off..val_off + 4].copy_from_slice(&val.to_be_bytes());
+ }
+ }
+ }
+}
+
+fn read_player_pos(player_dat: &[u8]) -> Option<(f64, f64, f64)> {
+ let idx = player_dat
+ .windows(PLAYER_POS_SIG.len())
+ .position(|w| w == PLAYER_POS_SIG)?;
+ let p = idx + PLAYER_POS_SIG.len();
+ if p + 24 > player_dat.len() {
+ return None;
+ }
+ let x = f64::from_be_bytes(player_dat[p..p + 8].try_into().ok()?);
+ let y = f64::from_be_bytes(player_dat[p + 8..p + 16].try_into().ok()?);
+ let z = f64::from_be_bytes(player_dat[p + 16..p + 24].try_into().ok()?);
+ Some((x, y, z))
+}
+
+fn patch_player_pos(player_dat: &mut [u8], x: f64, y: f64, z: f64) {
+ if let Some(idx) = player_dat
+ .windows(PLAYER_POS_SIG.len())
+ .position(|w| w == PLAYER_POS_SIG)
+ {
+ let p = idx + PLAYER_POS_SIG.len();
+ if p + 24 <= player_dat.len() {
+ player_dat[p..p + 8].copy_from_slice(&x.to_be_bytes());
+ player_dat[p + 8..p + 16].copy_from_slice(&y.to_be_bytes());
+ player_dat[p + 16..p + 24].copy_from_slice(&z.to_be_bytes());
+ }
+ }
+}
+
+fn compress_rle(data: &[u8]) -> Vec {
+ let mut out = Vec::new();
+ let n = data.len();
+ let mut i = 0;
+ while i < n {
+ let b = data[i];
+ let mut run = 1usize;
+ while i + run < n && data[i + run] == b && run < 256 {
+ run += 1;
+ }
+ if b == 0xFF {
+ if run <= 3 {
+ out.push(0xFF);
+ out.push((run - 1) as u8);
+ } else {
+ out.push(0xFF);
+ out.push((run - 1) as u8);
+ out.push(0xFF);
+ }
+ } else if run < 4 {
+ out.extend(std::iter::repeat(b).take(run));
+ } else {
+ out.push(0xFF);
+ out.push((run - 1) as u8);
+ out.push(b);
+ }
+ i += run;
+ }
+ out
+}
+
+fn build_empty_chunk_nbt(chunk_x: i32, chunk_z: i32) -> Vec {
+ let mut out = Vec::new();
+ out.extend_from_slice(b"\x0a\x00\x00");
+ out.extend_from_slice(b"\x0a\x00\x05Level");
+ out.extend_from_slice(b"\x07\x00\x06Blocks");
+ out.extend_from_slice(&32768i32.to_be_bytes());
+ out.extend(std::iter::repeat(0u8).take(32768));
+ out.extend_from_slice(b"\x07\x00\x04Data");
+ out.extend_from_slice(&16384i32.to_be_bytes());
+ out.extend(std::iter::repeat(0u8).take(16384));
+ out.extend_from_slice(b"\x07\x00\x08SkyLight");
+ out.extend_from_slice(&16384i32.to_be_bytes());
+ out.extend(std::iter::repeat(0xffu8).take(16384));
+ out.extend_from_slice(b"\x07\x00\x0aBlockLight");
+ out.extend_from_slice(&16384i32.to_be_bytes());
+ out.extend(std::iter::repeat(0u8).take(16384));
+ out.extend_from_slice(b"\x07\x00\x09HeightMap");
+ out.extend_from_slice(&256i32.to_be_bytes());
+ out.extend(std::iter::repeat(0u8).take(256));
+ out.extend_from_slice(b"\x03\x00\x04xPos");
+ out.extend_from_slice(&chunk_x.to_be_bytes());
+ out.extend_from_slice(b"\x03\x00\x04zPos");
+ out.extend_from_slice(&chunk_z.to_be_bytes());
+ out.extend_from_slice(b"\x04\x00\x0aLastUpdate");
+ out.extend_from_slice(&0i64.to_be_bytes());
+ out.extend_from_slice(b"\x01\x00\x10TerrainPopulated\x01");
+ out.extend_from_slice(b"\x09\x00\x08Entities\x0a\x00\x00\x00\x00");
+ out.extend_from_slice(b"\x09\x00\x0cTileEntities\x0a\x00\x00\x00\x00");
+ out.push(0x00);
+ out.push(0x00);
+ out
+}
+
+fn find_safe_spawn(
+ dropped: &HashSet<(i32, i32)>,
+ orig: (i32, i32, i32),
+) -> Option<(i32, i32, i32)> {
+ if dropped.is_empty() {
+ return None;
+ }
+ let (sx, sy, sz) = orig;
+ let sc_x = sx >> 4;
+ let sc_z = sz >> 4;
+ let too_close = |cx: i32, cz: i32| -> bool {
+ dropped
+ .iter()
+ .any(|&(dx, dz)| (cx - dx).abs().max((cz - dz).abs()) < 12)
+ };
+ if !too_close(sc_x, sc_z) {
+ return None;
+ }
+ for radius in 1..200i32 {
+ for dz in -radius..=radius {
+ for dx in -radius..=radius {
+ if dx.abs().max(dz.abs()) != radius {
+ continue;
+ }
+ let cx = sc_x + dx;
+ let cz = sc_z + dz;
+ if !too_close(cx, cz) {
+ return Some((cx * 16 + 8, sy, cz * 16 + 8));
+ }
+ }
+ }
+ }
+ None
+}
+
+fn lzxd_window_from_bits(bits: usize) -> Option {
+ match bits {
+ 15 => Some(lzxd::WindowSize::KB32),
+ 16 => Some(lzxd::WindowSize::KB64),
+ 17 => Some(lzxd::WindowSize::KB128),
+ 18 => Some(lzxd::WindowSize::KB256),
+ 19 => Some(lzxd::WindowSize::KB512),
+ 20 => Some(lzxd::WindowSize::MB1),
+ 21 => Some(lzxd::WindowSize::MB2),
+ _ => None,
+ }
+}
+
+fn try_lzxd(lzx_raw: &[u8], output_size: usize, window: lzxd::WindowSize) -> Option> {
+ let mut lzxd = lzxd::Lzxd::new(window);
+ lzxd.decompress_next(lzx_raw, output_size)
+ .ok()
+ .map(|slice| slice.to_vec())
+}
+
+fn decompress_region_chunk(xbox_data: &[u8]) -> Result, String> {
+ let hi = xbox_data[0];
+ let (output_size, _src_sz, lzx_raw) = if hi == 0xFF {
+ let output_size =
+ ((xbox_data[1] as usize) << 8) | xbox_data[2] as usize;
+ let src_sz =
+ ((xbox_data[3] as usize) << 8) | xbox_data[4] as usize;
+ let lzx_raw = &xbox_data[5..(5 + src_sz).min(xbox_data.len())];
+ (output_size, src_sz, lzx_raw)
+ } else {
+ let src_sz =
+ ((hi as usize) << 8) | xbox_data[1] as usize;
+ let lzx_raw = &xbox_data[2..(2 + src_sz).min(xbox_data.len())];
+ (LZX_BLOCK_SIZE, src_sz, lzx_raw)
+ };
+
+ if let Some(out) = try_lzxd(lzx_raw, output_size, lzxd::WindowSize::KB128) {
+ return Ok(out);
+ }
+ for w in [16, 15, 18, 19, 20, 21] {
+ if let Some(ws) = lzxd_window_from_bits(w) {
+ if let Some(out) = try_lzxd(lzx_raw, output_size, ws) {
+ return Ok(out);
+ }
+ }
+ }
+ Err("LZX decode failed (all window sizes rejected the chunk)".to_string())
+}
+
+fn convert_region(
+ data: &[u8],
+ dropped_slots: &mut Vec,
+ region_coords: Option<(i32, i32)>,
+) -> Result, String> {
+ let sect = 4096usize;
+ let mut buf = data.to_vec();
+ for i in 0..REGION_SECT_COUNT * 2 {
+ if (i + 1) * 4 <= buf.len() {
+ s32(&mut buf, i * 4);
+ }
+ }
+ let mut chunk_positions: HashMap = HashMap::new();
+ for slot in 0..REGION_SECT_COUNT {
+ if (slot + 1) * 4 > buf.len() {
+ break;
+ }
+ let off = u32::from_le_bytes([
+ buf[slot * 4],
+ buf[slot * 4 + 1],
+ buf[slot * 4 + 2],
+ buf[slot * 4 + 3],
+ ]) as usize;
+ if off == 0 {
+ continue;
+ }
+ let sn = (off >> 8) & 0xFFFFFF;
+ let count = off & 0xFF;
+ if sn < 2 {
+ continue;
+ }
+ let fo = sn * sect;
+ if fo + 8 > data.len() {
+ continue;
+ }
+ chunk_positions.insert(fo, (slot, sn, count));
+ }
+ if chunk_positions.is_empty() {
+ return Ok(buf);
+ }
+ let mut new_buf = vec![0u8; buf.len()];
+ let copy_end = (sect * 2).min(buf.len()).min(new_buf.len());
+ new_buf[..copy_end].copy_from_slice(&buf[..copy_end]);
+ let mut next_sector = 2usize;
+ let mut sorted_fos: Vec = chunk_positions.keys().copied().collect();
+ sorted_fos.sort();
+ for fo in sorted_fos {
+ let (slot, _sn, _count) = chunk_positions[&fo];
+ let raw_comp_len = u32::from_be_bytes([
+ data[fo],
+ data[fo + 1],
+ data[fo + 2],
+ data[fo + 3],
+ ]);
+ let raw_decomp_len = u32::from_be_bytes([
+ data[fo + 4],
+ data[fo + 5],
+ data[fo + 6],
+ data[fo + 7],
+ ]);
+ let use_rle = (raw_comp_len & 0x80000000) != 0;
+ let comp_len = (raw_comp_len & 0x7FFFFFFF) as usize;
+ let decomp_len = raw_decomp_len as usize;
+ if comp_len == 0 || fo + 8 + comp_len > data.len() {
+ continue;
+ }
+ let xbox_data = &data[fo + 8..fo + 8 + comp_len];
+ let rle_data = match decompress_region_chunk(xbox_data) {
+ Ok(d) => d,
+ Err(_) => {
+ dropped_slots.push(slot);
+ if let Some((rx, rz)) = region_coords {
+ let cx = rx * 32 + (slot % 32) as i32;
+ let cz = rz * 32 + (slot / 32) as i32;
+ let synth_nbt = build_empty_chunk_nbt(cx, cz);
+ let synth_rle = compress_rle(&synth_nbt);
+ let mut synth_encoder =
+ flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(6));
+ let _ = synth_encoder.write_all(&synth_rle);
+ let synth_zlib = synth_encoder.finish().unwrap_or_default();
+ let new_comp_len = synth_zlib.len();
+ let needed = (8 + new_comp_len + sect - 1) / sect;
+ let dest_off = next_sector * sect;
+ while dest_off + 8 + new_comp_len > new_buf.len() {
+ new_buf.extend(std::iter::repeat(0u8).take(sect));
+ }
+ new_buf[dest_off..dest_off + 4]
+ .copy_from_slice(&(new_comp_len as u32 | 0x80000000).to_le_bytes());
+ new_buf[dest_off + 4..dest_off + 8]
+ .copy_from_slice(&(synth_nbt.len() as u32).to_le_bytes());
+ new_buf[dest_off + 8..dest_off + 8 + new_comp_len]
+ .copy_from_slice(&synth_zlib);
+ new_buf[slot * 4..slot * 4 + 4]
+ .copy_from_slice(&((next_sector << 8) | needed).to_le_bytes());
+ next_sector += needed;
+ } else {
+ new_buf[slot * 4..slot * 4 + 4].copy_from_slice(&0u32.to_le_bytes());
+ }
+ continue;
+ }
+ };
+ let zlib_data = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(6));
+ let mut encoder = zlib_data;
+ encoder.write_all(&rle_data).map_err(|e| e.to_string())?;
+ let zlib_data = encoder.finish().map_err(|e| e.to_string())?;
+ let new_comp_len = zlib_data.len();
+ let needed = (8 + new_comp_len + sect - 1) / sect;
+ let dest_off = next_sector * sect;
+ while dest_off + 8 + new_comp_len > new_buf.len() {
+ new_buf.extend(std::iter::repeat(0u8).take(sect));
+ }
+ let comp_flag = if use_rle {
+ new_comp_len as u32 | 0x80000000
+ } else {
+ new_comp_len as u32
+ };
+ new_buf[dest_off..dest_off + 4].copy_from_slice(&comp_flag.to_le_bytes());
+ new_buf[dest_off + 4..dest_off + 8].copy_from_slice(&(decomp_len as u32).to_le_bytes());
+ new_buf[dest_off + 8..dest_off + 8 + new_comp_len].copy_from_slice(&zlib_data);
+ let new_off = (next_sector << 8) | needed;
+ new_buf[slot * 4..slot * 4 + 4].copy_from_slice(&(new_off as u32).to_le_bytes());
+ next_sector += needed;
+ }
+ let end = (next_sector * sect).min(new_buf.len());
+ new_buf.truncate(end);
+ Ok(new_buf)
+}
+
+fn convert_region_ps3(data: &[u8]) -> Result, String> {
+ let sect = 4096usize;
+ let mut buf = data.to_vec();
+ for i in 0..REGION_SECT_COUNT * 2 {
+ if (i + 1) * 4 <= buf.len() {
+ s32(&mut buf, i * 4);
+ }
+ }
+ let mut chunk_positions: HashMap = HashMap::new();
+ for slot in 0..REGION_SECT_COUNT {
+ if (slot + 1) * 4 > buf.len() {
+ break;
+ }
+ let off = u32::from_le_bytes([
+ buf[slot * 4],
+ buf[slot * 4 + 1],
+ buf[slot * 4 + 2],
+ buf[slot * 4 + 3],
+ ]) as usize;
+ if off == 0 {
+ continue;
+ }
+ let sn = (off >> 8) & 0xFFFFFF;
+ let count = off & 0xFF;
+ if sn < 2 {
+ continue;
+ }
+ let fo = sn * sect;
+ if fo + 8 > data.len() {
+ continue;
+ }
+ chunk_positions.insert(fo, (slot, sn, count));
+ }
+ if chunk_positions.is_empty() {
+ return Ok(buf);
+ }
+ let mut new_buf = vec![0u8; buf.len()];
+ let copy_end = (sect * 2).min(buf.len()).min(new_buf.len());
+ new_buf[..copy_end].copy_from_slice(&buf[..copy_end]);
+ let mut next_sector = 2usize;
+ let mut sorted_fos: Vec = chunk_positions.keys().copied().collect();
+ sorted_fos.sort();
+ for fo in sorted_fos {
+ let (slot, _sn, _count) = chunk_positions[&fo];
+ let raw_comp_len = u32::from_be_bytes([
+ data[fo],
+ data[fo + 1],
+ data[fo + 2],
+ data[fo + 3],
+ ]);
+ let raw_decomp_len = u32::from_be_bytes([
+ data[fo + 4],
+ data[fo + 5],
+ data[fo + 6],
+ data[fo + 7],
+ ]);
+ let use_rle = (raw_comp_len & 0x80000000) != 0;
+ let comp_len = (raw_comp_len & 0x7FFFFFFF) as usize;
+ let decomp_len = raw_decomp_len as usize;
+ if comp_len == 0 || fo + 8 + comp_len > data.len() {
+ continue;
+ }
+ let chunk_body = &data[fo + 8..fo + 8 + comp_len];
+ if chunk_body.len() < 5 {
+ new_buf[slot * 4..slot * 4 + 4].copy_from_slice(&0u32.to_le_bytes());
+ continue;
+ }
+ let mut decoder = flate2::read::DeflateDecoder::new(&chunk_body[4..]);
+ let mut rle_data = Vec::new();
+ if decoder.read_to_end(&mut rle_data).is_err() {
+ new_buf[slot * 4..slot * 4 + 4].copy_from_slice(&0u32.to_le_bytes());
+ continue;
+ }
+ let mut encoder =
+ flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(6));
+ encoder.write_all(&rle_data).map_err(|e| e.to_string())?;
+ let zlib_data = encoder.finish().map_err(|e| e.to_string())?;
+ let new_comp_len = zlib_data.len();
+ let needed = (8 + new_comp_len + sect - 1) / sect;
+ let dest_off = next_sector * sect;
+ while dest_off + 8 + new_comp_len > new_buf.len() {
+ new_buf.extend(std::iter::repeat(0u8).take(sect));
+ }
+ let comp_flag = if use_rle {
+ new_comp_len as u32 | 0x80000000
+ } else {
+ new_comp_len as u32
+ };
+ new_buf[dest_off..dest_off + 4].copy_from_slice(&comp_flag.to_le_bytes());
+ new_buf[dest_off + 4..dest_off + 8].copy_from_slice(&(decomp_len as u32).to_le_bytes());
+ new_buf[dest_off + 8..dest_off + 8 + new_comp_len].copy_from_slice(&zlib_data);
+ let new_off = (next_sector << 8) | needed;
+ new_buf[slot * 4..slot * 4 + 4].copy_from_slice(&(new_off as u32).to_le_bytes());
+ next_sector += needed;
+ }
+ let end = (next_sector * sect).min(new_buf.len());
+ new_buf.truncate(end);
+ Ok(new_buf)
+}
+
+fn decompress_xmemcompress(dat: &[u8]) -> Result, String> {
+ if dat.len() < 8 {
+ return Err("savegame.dat too small".to_string());
+ }
+ let meta_off = u32::from_be_bytes([dat[0], dat[1], dat[2], dat[3]]) as usize;
+ if meta_off > dat.len() {
+ return Err("invalid metadata offset".to_string());
+ }
+ let lzx_stream = &dat[8..meta_off];
+ if lzx_stream.len() < 4 {
+ return Err("LZX stream too small".to_string());
+ }
+ let uncomp_total = u32::from_be_bytes([
+ lzx_stream[0],
+ lzx_stream[1],
+ lzx_stream[2],
+ lzx_stream[3],
+ ]) as usize;
+ let mut lzxd = lzxd::Lzxd::new(lzxd::WindowSize::KB128);
+ let mut output = Vec::with_capacity(uncomp_total);
+ let mut pos = 4;
+ while pos < lzx_stream.len() && output.len() < uncomp_total {
+ let hi = lzx_stream[pos];
+ let (src_sz, dst_sz, header_len) = if hi == 0xFF {
+ if pos + 5 > lzx_stream.len() {
+ break;
+ }
+ let dst_sz = ((lzx_stream[pos + 1] as usize) << 8) | lzx_stream[pos + 2] as usize;
+ let src_sz = ((lzx_stream[pos + 3] as usize) << 8) | lzx_stream[pos + 4] as usize;
+ (src_sz, dst_sz, 5)
+ } else {
+ if pos + 2 > lzx_stream.len() {
+ break;
+ }
+ let src_sz = ((hi as usize) << 8) | lzx_stream[pos + 1] as usize;
+ (src_sz, LZX_BLOCK_SIZE, 2)
+ };
+ pos += header_len;
+ if src_sz == 0 || dst_sz == 0 {
+ break;
+ }
+ if pos + src_sz > lzx_stream.len() {
+ break;
+ }
+ let lzx_raw = &lzx_stream[pos..pos + src_sz];
+ let remaining = uncomp_total - output.len();
+ let out_sz = dst_sz.min(remaining);
+ match lzxd.decompress_next(lzx_raw, out_sz) {
+ Ok(decompressed) => output.extend_from_slice(decompressed),
+ Err(e) => {
+ return Err(format!("LZX decompression failed at offset {}: {}", pos, e))
+ }
+ }
+ pos += src_sz;
+ }
+ Ok(output)
+}
+
+fn patch_level_name(nbt: &[u8], new_name: &str) -> Vec {
+ let idx = match nbt
+ .windows(LEVELNAME_SIG.len())
+ .position(|w| w == LEVELNAME_SIG)
+ {
+ Some(i) => i,
+ None => return nbt.to_vec(),
+ };
+ let str_off = idx + LEVELNAME_SIG.len();
+ if str_off + 2 > nbt.len() {
+ return nbt.to_vec();
+ }
+ let old_len = u16::from_be_bytes([nbt[str_off], nbt[str_off + 1]]) as usize;
+ let end = str_off + 2 + old_len;
+ let mut new_val = new_name.as_bytes().to_vec();
+ if new_val.len() > 0xFFFF {
+ new_val.truncate(0xFFFF);
+ }
+ let mut payload = Vec::with_capacity(2 + new_val.len());
+ payload.extend_from_slice(&(new_val.len() as u16).to_be_bytes());
+ payload.extend_from_slice(&new_val);
+ let mut out = Vec::with_capacity(str_off + payload.len() + nbt.len().saturating_sub(end));
+ out.extend_from_slice(&nbt[..str_off]);
+ out.extend_from_slice(&payload);
+ if end < nbt.len() {
+ out.extend_from_slice(&nbt[end..]);
+ }
+ out
+}
+
+fn parse_param_sfo(path: &Path) -> HashMap {
+ let data = match fs::read(path) {
+ Ok(d) => d,
+ Err(_) => return HashMap::new(),
+ };
+ if data.len() < 0x14 || &data[..4] != b"\x00PSF" {
+ return HashMap::new();
+ }
+ let key_tbl = u32::from_le_bytes(data[8..12].try_into().unwrap()) as usize;
+ let data_tbl = u32::from_le_bytes(data[12..16].try_into().unwrap()) as usize;
+ let entries = u32::from_le_bytes(data[16..20].try_into().unwrap()) as usize;
+ let mut result = HashMap::new();
+ for i in 0..entries {
+ let e = 0x14 + i * 16;
+ if e + 16 > data.len() {
+ break;
+ }
+ let key_off = u16::from_le_bytes([data[e], data[e + 1]]) as usize;
+ let fmt = u16::from_le_bytes([data[e + 2], data[e + 3]]);
+ let dlen = u16::from_le_bytes([data[e + 4], data[e + 5]]) as usize;
+ let doff = u32::from_le_bytes(data[e + 8..e + 12].try_into().unwrap()) as usize;
+ let ka = key_tbl + key_off;
+ let ke = data[ka..]
+ .iter()
+ .position(|&b| b == 0)
+ .map(|p| ka + p)
+ .unwrap_or(data.len());
+ let key = String::from_utf8_lossy(&data[ka..ke]).to_string();
+ let da = data_tbl + doff;
+ let val = if (fmt == SFO_FMT_UTF8_RAW || fmt == SFO_FMT_UTF8_STR) && da + dlen <= data.len()
+ {
+ let raw = &data[da..da + dlen];
+ let null_pos = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
+ String::from_utf8_lossy(&raw[..null_pos]).to_string()
+ } else if fmt == SFO_FMT_INT32 && da + 4 <= data.len() {
+ u32::from_le_bytes(data[da..da + 4].try_into().unwrap()).to_string()
+ } else {
+ continue;
+ };
+ result.insert(key, val);
+ }
+ result
+}
+
+fn looks_like_4j_header(data: &[u8]) -> bool {
+ if data.len() < 12 {
+ return false;
+ }
+ let (ho, ne, _ov, cv) = read_hdr_be(data);
+ if ho < 12 || ho as usize >= data.len() {
+ return false;
+ }
+ if data.len() - ho as usize != ne as usize * FILE_ENTRY_SIZE {
+ return false;
+ }
+ if ne == 0 || ne > 4096 {
+ return false;
+ }
+ if cv < 0 || cv > 20 {
+ return false;
+ }
+ true
+}
+
+fn convert_bin_to_win64(bin_path: &str, game_dir: &str) -> Result {
+ let raw = fs::read(bin_path).map_err(|e| format!("Failed to read file: {}", e))?;
+ let pkg = StfsPackage::new(raw)?;
+ let name = pkg.display_name();
+ let dat = pkg
+ .extract_savegame_dat()
+ .ok_or("savegame.dat not found in STFS package")?;
+ let decompressed = decompress_xmemcompress(&dat)?;
+ let (ho, ne, ov, cv) = read_hdr_be(&decompressed);
+ let entries = parse_ftable_be(&decompressed, ho, ne);
+ let mut file_blobs: Vec> = Vec::new();
+ let mut dropped_overworld: HashSet<(i32, i32)> = HashSet::new();
+ for e in &entries {
+ let fn_lower = e.filename.to_lowercase();
+ let s = e.start_offset as usize;
+ let l = e.length as usize;
+ let raw_file = if s + l <= decompressed.len() {
+ decompressed[s..s + l].to_vec()
+ } else {
+ Vec::new()
+ };
+ if fn_lower.ends_with(".mcr") && !raw_file.is_empty() {
+ let dim_info = parse_region_filename(&e.filename);
+ let region_xy = dim_info.map(|d| (d.1, d.2));
+ let mut slots = Vec::new();
+ let converted =
+ convert_region(&raw_file, &mut slots, region_xy)?;
+ if let Some(("overworld", rx, rz)) = dim_info {
+ for slot in slots {
+ let cx = rx * 32 + (slot % 32) as i32;
+ let cz = rz * 32 + (slot / 32) as i32;
+ dropped_overworld.insert((cx, cz));
+ }
+ }
+ file_blobs.push(converted);
+ } else {
+ file_blobs.push(raw_file);
+ }
+ }
+ if !dropped_overworld.is_empty() {
+ let mut new_spawn: Option<(i32, i32, i32)> = None;
+ let mut spawn: Option<(i32, i32, i32)> = None;
+ for (i, e) in entries.iter().enumerate() {
+ if e.filename.to_lowercase() != "level.dat" {
+ continue;
+ }
+ spawn = read_spawn(&file_blobs[i]);
+ if let Some(s) = spawn {
+ if let Some(ns) = find_safe_spawn(&dropped_overworld, s) {
+ let blob = &mut file_blobs[i];
+ patch_spawn(blob, ns.0, ns.1, ns.2);
+ new_spawn = Some(ns);
+ }
+ }
+ break;
+ }
+ let safe = new_spawn.or(spawn);
+ if let Some((sx, sy, sz)) = safe {
+ let _sx_chunk = sx >> 4;
+ let _sz_chunk = sz >> 4;
+ let too_close = |cx: i32, cz: i32| -> bool {
+ dropped_overworld
+ .iter()
+ .any(|&(dx, dz)| (cx - dx).abs().max((cz - dz).abs()) < 12)
+ };
+ let mut moved_players = 0i32;
+ for (i, e) in entries.iter().enumerate() {
+ if !e.filename.starts_with("players/") || !e.filename.ends_with(".dat") {
+ continue;
+ }
+ if let Some((px, _py, pz)) = read_player_pos(&file_blobs[i]) {
+ let cx = px as i32 / 16;
+ let cz = pz as i32 / 16;
+ if too_close(cx, cz) {
+ let blob = &mut file_blobs[i];
+ patch_player_pos(blob, sx as f64 + 0.5, sy as f64, sz as f64 + 0.5);
+ moved_players += 1;
+ }
+ }
+ }
+ if moved_players > 0 {
+ eprintln!("Relocated {} player(s) to safe spawn", moved_players);
+ }
+ }
+ }
+ let header_size = 12usize;
+ let mut body = Vec::new();
+ let mut new_entries: Vec = Vec::new();
+ let mut cursor = header_size;
+ for (i, e) in entries.iter().enumerate() {
+ let blob = &file_blobs[i];
+ new_entries.push(FileEntry {
+ filename: e.filename.clone(),
+ length: blob.len() as u32,
+ start_offset: cursor as u32,
+ last_mod: e.last_mod,
+ });
+ body.extend_from_slice(blob);
+ cursor += blob.len();
+ }
+ let new_fto = cursor;
+ let out_cv = if cv <= 9 { cv } else { 9 };
+ let mut header = Vec::with_capacity(header_size);
+ header.extend_from_slice(&(new_fto as u32).to_le_bytes());
+ header.extend_from_slice(&(entries.len() as u32).to_le_bytes());
+ header.extend_from_slice(&ov.to_le_bytes());
+ header.extend_from_slice(&out_cv.to_le_bytes());
+ let mut raw_le = Vec::with_capacity(header.len() + body.len() + ne as usize * FILE_ENTRY_SIZE);
+ raw_le.extend_from_slice(&header);
+ raw_le.extend_from_slice(&body);
+ for ne_entry in &new_entries {
+ let fn_bytes: Vec = ne_entry.filename.encode_utf16().map(|c| c.to_le()).collect();
+ let mut fn_padded = vec![0u16; 64];
+ for (j, &c) in fn_bytes.iter().take(64).enumerate() {
+ fn_padded[j] = c;
+ }
+ for &c in &fn_padded {
+ raw_le.extend_from_slice(&c.to_le_bytes());
+ }
+ raw_le.extend_from_slice(&ne_entry.length.to_le_bytes());
+ raw_le.extend_from_slice(&ne_entry.start_offset.to_le_bytes());
+ raw_le.extend_from_slice(&ne_entry.last_mod.to_le_bytes());
+ }
+ let mut encoder =
+ flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(6));
+ encoder.write_all(&raw_le).map_err(|e| e.to_string())?;
+ let compressed = encoder.finish().map_err(|e| e.to_string())?;
+ let mut win64 = Vec::with_capacity(8 + compressed.len());
+ win64.extend_from_slice(&0u32.to_le_bytes());
+ win64.extend_from_slice(&(raw_le.len() as u32).to_le_bytes());
+ win64.extend_from_slice(&compressed);
+ let folder = sanitise(&name);
+ let dst = Path::new(game_dir).join(&folder);
+ fs::create_dir_all(&dst).map_err(|e| format!("Failed to create output directory: {}", e))?;
+ fs::write(dst.join("saveData.ms"), &win64)
+ .map_err(|e| format!("Failed to write saveData.ms: {}", e))?;
+ if let Some(thumb) = pkg.thumbnail() {
+ let thumb_dir = dst.join("thumbnails");
+ let _ = fs::create_dir_all(&thumb_dir);
+ let _ = fs::write(thumb_dir.join("thumbData.png"), &thumb);
+ }
+ Ok(dst.to_string_lossy().to_string())
+}
+
+fn convert_ps3_to_win64(ps3_save_dir: &str, game_dir: &str) -> Result {
+ let src = Path::new(ps3_save_dir);
+ if !src.is_dir() {
+ return Err(format!(
+ "'{}' is not a folder",
+ src.display()
+ ));
+ }
+ let gamedata_path = src.join("GAMEDATA");
+ if !gamedata_path.exists() {
+ return Err(format!(
+ "GAMEDATA not found in {}",
+ src.file_name().unwrap_or_default().to_string_lossy()
+ ));
+ }
+ let sfo = parse_param_sfo(&src.join("PARAM.SFO"));
+ let save_title = sfo
+ .get("SUB_TITLE")
+ .cloned()
+ .unwrap_or_else(|| {
+ src.file_name()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .to_string()
+ });
+ let decompressed =
+ fs::read(&gamedata_path).map_err(|e| format!("Failed to read GAMEDATA: {}", e))?;
+ if !looks_like_4j_header(&decompressed) {
+ return Err("GAMEDATA does not look like a valid Minecraft PS3 save".to_string());
+ }
+ let (ho, ne, ov, _cv) = read_hdr_be(&decompressed);
+ let entries = parse_ftable_be(&decompressed, ho, ne);
+ let mut file_blobs: Vec> = Vec::new();
+ for e in &entries {
+ let fn_lower = e.filename.to_lowercase();
+ let s = e.start_offset as usize;
+ let l = e.length as usize;
+ let mut raw_file = if s + l <= decompressed.len() {
+ decompressed[s..s + l].to_vec()
+ } else {
+ Vec::new()
+ };
+ if fn_lower.ends_with(".mcr") && !raw_file.is_empty() {
+ raw_file = convert_region_ps3(&raw_file)?;
+ } else if fn_lower == "level.dat" && !save_title.is_empty() && !raw_file.is_empty() {
+ raw_file = patch_level_name(&raw_file, &save_title);
+ }
+ file_blobs.push(raw_file);
+ }
+ let header_size = 12usize;
+ let mut body = Vec::new();
+ let mut new_entries: Vec = Vec::new();
+ let mut cursor = header_size;
+ for (i, e) in entries.iter().enumerate() {
+ let blob = &file_blobs[i];
+ new_entries.push(FileEntry {
+ filename: e.filename.clone(),
+ length: blob.len() as u32,
+ start_offset: cursor as u32,
+ last_mod: e.last_mod,
+ });
+ body.extend_from_slice(blob);
+ cursor += blob.len();
+ }
+ let new_fto = cursor;
+ let mut raw_le = Vec::with_capacity(header_size + body.len() + ne as usize * FILE_ENTRY_SIZE);
+ raw_le.extend_from_slice(&(new_fto as u32).to_le_bytes());
+ raw_le.extend_from_slice(&ne.to_le_bytes());
+ raw_le.extend_from_slice(&ov.to_le_bytes());
+ raw_le.extend_from_slice(&9i16.to_le_bytes());
+ raw_le.extend_from_slice(&body);
+ for ne_entry in &new_entries {
+ let fn_bytes: Vec = ne_entry.filename.encode_utf16().map(|c| c.to_le()).collect();
+ let mut fn_padded = vec![0u16; 64];
+ for (j, &c) in fn_bytes.iter().take(64).enumerate() {
+ fn_padded[j] = c;
+ }
+ for &c in &fn_padded {
+ raw_le.extend_from_slice(&c.to_le_bytes());
+ }
+ raw_le.extend_from_slice(&ne_entry.length.to_le_bytes());
+ raw_le.extend_from_slice(&ne_entry.start_offset.to_le_bytes());
+ raw_le.extend_from_slice(&ne_entry.last_mod.to_le_bytes());
+ }
+ let mut encoder =
+ flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(6));
+ encoder.write_all(&raw_le).map_err(|e| e.to_string())?;
+ let compressed = encoder.finish().map_err(|e| e.to_string())?;
+ let mut win64 = Vec::with_capacity(8 + compressed.len());
+ win64.extend_from_slice(&0u32.to_le_bytes());
+ win64.extend_from_slice(&(raw_le.len() as u32).to_le_bytes());
+ win64.extend_from_slice(&compressed);
+ let folder = sanitise(&save_title);
+ let dst = Path::new(game_dir).join(&folder);
+ fs::create_dir_all(&dst).map_err(|e| format!("Failed to create output directory: {}", e))?;
+ fs::write(dst.join("saveData.ms"), &win64)
+ .map_err(|e| format!("Failed to write saveData.ms: {}", e))?;
+ let thumb_path = src.join("THUMB");
+ if thumb_path.exists() {
+ if let Ok(thumb_bytes) = fs::read(&thumb_path) {
+ if thumb_bytes.len() >= 8 && thumb_bytes[..8] == *b"\x89PNG\r\n\x1a\n" {
+ let thumb_dir = dst.join("thumbnails");
+ let _ = fs::create_dir_all(&thumb_dir);
+ let _ = fs::write(thumb_dir.join("thumbData.png"), &thumb_bytes);
+ }
+ }
+ }
+ Ok(dst.to_string_lossy().to_string())
+}
#[tauri::command]
#[allow(non_snake_case)]
@@ -16,9 +1167,43 @@ pub async fn import_world(
.map_err(|e| format!("Failed to create output directory {:?}: {}", parent, e))?;
}
}
-
fs::copy(&input_path, &output_path)
.map_err(|e| format!("Failed to copy .ms file: {}", e))?;
-
- Ok(format!("World imported successfully!\nOutput: {}", output_path))
+ Ok(format!(
+ "World imported successfully!\nOutput: {}",
+ output_path
+ ))
+}
+
+#[tauri::command]
+#[allow(non_snake_case)]
+pub async fn import_lce_save(
+ _app: AppHandle,
+ input_path: String,
+ output_dir: String,
+) -> Result {
+ let path = Path::new(&input_path);
+ if path.is_file() {
+ let ext = path
+ .extension()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .to_lowercase();
+ if ext == "ms" {
+ let dst = Path::new(&output_dir);
+ fs::create_dir_all(dst)
+ .map_err(|e| format!("Failed to create output directory: {}", e))?;
+ fs::copy(&input_path, dst.join("saveData.ms"))
+ .map_err(|e| format!("Failed to copy .ms file: {}", e))?;
+ return Ok(dst.to_string_lossy().to_string());
+ }
+ if ext == "bin" || ext == "" {
+ return convert_bin_to_win64(&input_path, &output_dir);
+ }
+ return Err(format!("Unrecognised file extension: .{}", ext));
+ }
+ if path.is_dir() && path.join("GAMEDATA").is_file() {
+ return convert_ps3_to_win64(&input_path, &output_dir);
+ }
+ Err("Input is not a valid LCE save (expected .bin file, .ms file, or PS3 folder with GAMEDATA)".to_string())
}
diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs
index 15627da..a8d7b64 100644
--- a/src-tauri/src/commands/game.rs
+++ b/src-tauri/src/commands/game.rs
@@ -1,8 +1,14 @@
use std::fs;
use std::path::PathBuf;
+use std::process::Stdio;
+use std::sync::atomic::Ordering;
+use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter, Manager, State};
use tauri_plugin_opener::OpenerExt;
+use tokio::io::AsyncRead;
+use tokio::io::AsyncReadExt;
+use tokio::sync::Mutex;
use crate::commands::runners;
use crate::config;
use crate::types::AppConfig;
@@ -24,6 +30,7 @@ pub async fn launch_game(
mut servers: Vec,
extra_args: Vec,
) -> Result<(), String> {
+ state.manual_stop.store(false, Ordering::SeqCst);
perform_instance_sync(&app, &instance_id).await?;
let working_dir = util::get_instance_working_dir(&app, &instance_id);
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);
cmd.current_dir(&working_dir);
+ cmd.env("WINEDEBUG", "+debugstr");
let playtime_start = std::time::Instant::now();
- let child = cmd.spawn().map_err(|e| e.to_string())?;
- {
- 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 Some(result) = run_game_and_capture(&state, cmd).await? else {
+ return Ok(());
};
- {
- let mut lock = state.child.lock().await;
- *lock = None;
- }
-
let duration = playtime_start.elapsed();
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let start = now - duration.as_secs();
playtime::record_session(&app, &instance_id, start, now);
- return if status.success() || status.code() == Some(253) || status.code() == Some(96) {
- Ok(())
- } else {
- Err(format!("Game exited with status: {}", status))
- };
+ return handle_game_exit(&app, &state, result);
}
}
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);
cmd.current_dir(&working_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);
if perf_boost {
#[cfg(target_arch = "aarch64")]
@@ -233,46 +216,19 @@ pub async fn launch_game(
std::env::var("PATH").unwrap_or_default()
),
);
- cmd.stdin(std::process::Stdio::null())
- .stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null());
+ cmd.stdin(std::process::Stdio::null());
let playtime_start = std::time::Instant::now();
- let child = cmd.spawn().map_err(|e| e.to_string())?;
- {
- 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 Some(result) = run_game_and_capture(&state, cmd).await? else {
+ return Ok(());
};
- {
- let mut lock = state.child.lock().await;
- *lock = None;
- }
-
let duration = playtime_start.elapsed();
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let start = now - duration.as_secs();
playtime::record_session(&app, &instance_id, start, now);
- return if status.success() || status.code() == Some(253) || status.code() == Some(96) {
- Ok(())
- } else {
- Err(format!("Game exited with status: {}", status))
- };
+ return handle_game_exit(&app, &state, result);
}
#[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);
cmd.current_dir(&working_dir);
let playtime_start = std::time::Instant::now();
- let child = cmd.spawn().map_err(|e| e.to_string())?;
- {
- 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 Some(result) = run_game_and_capture(&state, cmd).await? else {
+ return Ok(());
};
- {
- let mut lock = state.child.lock().await;
- *lock = None;
- }
let duration = playtime_start.elapsed();
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let start = now - duration.as_secs();
playtime::record_session(&app, &instance_id, start, now);
- return if status.success() || status.code() == Some(253) || status.code() == Some(96) {
- Ok(())
- } else {
- Err(format!("Game exited with status: {}", status))
- };
+ return handle_game_exit(&app, &state, result);
}
}
}
@@ -330,6 +263,7 @@ pub async fn stop_game(
instance_id: String,
state: State<'_, GameState>,
) -> Result<(), String> {
+ state.manual_stop.store(true, Ordering::SeqCst);
let mut lock = state.child.lock().await;
if let Some(mut child) = lock.take() {
#[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(mut reader: R, log: Arc>>) -> 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
-
- Select an existing LCE save (.ms file or GameHDD folder)
+
+
+ Import an existing .ms save file:
+
+
+
+ Convert an Xbox 360 or PS3 save:
+
+
+
+
+
+
+
+ Java Edition world conversion:
+
+
+
+
+
{error && (
@@ -114,33 +302,20 @@ export default function ImportWorldModal({
)}
-
-
-
-
+
>
) : (
<>
diff --git a/src/components/views/ArcEditorView.tsx b/src/components/views/ArcEditorView.tsx
index eedaf6d..2b1f7de 100644
--- a/src/components/views/ArcEditorView.tsx
+++ b/src/components/views/ArcEditorView.tsx
@@ -319,7 +319,7 @@ export const ArcEditorView: React.FC = () => {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
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"
>
@@ -567,10 +567,10 @@ export const ArcEditorView: React.FC = () => {
)}
-
+