From d536a6590b34724ecbfea983fa0828364e58f4de Mon Sep 17 00:00:00 2001 From: /home/neo <158327205+neoapps-dev@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:01:24 +0300 Subject: [PATCH] feat: xbox360+ps3+java->windows64 world conversion (#145) Co-authored-by: str1k3r <115313679+S1l3ntStr1ke87@users.noreply.github.com> --- .github/workflows/experimental.yml | 61 +- .github/workflows/nix.yml | 1 - .github/workflows/publish.yml | 69 +- README.md | 8 +- ...ncher.Emerald_Legacy_Launcher.metainfo.xml | 2 +- package.json | 2 +- pkg/AppImage/AppImage.sh | 48 + public/Skins/kowhaifan.png | Bin 0 -> 3285 bytes public/Skins/str1k3r.png | Bin 0 -> 638 bytes src-tauri/Cargo.lock | 18 +- src-tauri/Cargo.toml | 11 +- src-tauri/src/commands/console2lce.rs | 1193 +++++++++++- src-tauri/src/commands/game.rs | 192 +- .../src/commands/java2lce/block_mapping.rs | 1353 +++++++++++++ src-tauri/src/commands/java2lce/chunk.rs | 1703 +++++++++++++++++ src-tauri/src/commands/java2lce/level_dat.rs | 261 +++ src-tauri/src/commands/java2lce/mod.rs | 404 ++++ src-tauri/src/commands/java2lce/nbt.rs | 496 +++++ src-tauri/src/commands/java2lce/payload.rs | 683 +++++++ src-tauri/src/commands/java2lce/region.rs | 802 ++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/workshop.rs | 270 ++- src-tauri/src/lib.rs | 5 + src-tauri/src/networking/relay.rs | 2 - src-tauri/src/state.rs | 2 + src-tauri/tauri.conf.json | 2 +- src-tauri/tauri.nightly.conf.json | 2 +- src/components/common/CapePreview.tsx | 181 ++ src/components/modals/GameLogModal.tsx | 187 ++ src/components/modals/ImportWorldModal.tsx | 251 ++- src/components/views/ArcEditorView.tsx | 6 +- src/components/views/ColEditorView.tsx | 6 +- src/components/views/DevtoolsView.tsx | 6 +- src/components/views/GrfEditorView.tsx | 6 +- src/components/views/GuidesView.tsx | 6 +- src/components/views/LceOnlineView.tsx | 158 +- src/components/views/LocEditorView.tsx | 6 +- src/components/views/ModelEditorView.tsx | 2 +- src/components/views/OptionsEditorView.tsx | 6 +- src/components/views/PckEditorView.tsx | 4 +- src/components/views/SkinsView.tsx | 198 +- src/components/views/SwfView.tsx | 4 +- src/components/views/WorkshopView.tsx | 156 +- src/context/LauncherContext.tsx | 6 + src/css/index.css | 8 + src/hooks/useAppConfig.ts | 7 +- src/hooks/useGameManager.ts | 42 +- src/hooks/useLceOnlineNotifications.ts | 36 +- src/hooks/useSkinSync.ts | 7 +- src/pages/App.tsx | 52 +- src/services/LceOnlineService.ts | 41 +- src/services/TauriService.ts | 33 + 52 files changed, 8530 insertions(+), 476 deletions(-) create mode 100755 pkg/AppImage/AppImage.sh create mode 100644 public/Skins/kowhaifan.png create mode 100644 public/Skins/str1k3r.png create mode 100644 src-tauri/src/commands/java2lce/block_mapping.rs create mode 100644 src-tauri/src/commands/java2lce/chunk.rs create mode 100644 src-tauri/src/commands/java2lce/level_dat.rs create mode 100644 src-tauri/src/commands/java2lce/mod.rs create mode 100644 src-tauri/src/commands/java2lce/nbt.rs create mode 100644 src-tauri/src/commands/java2lce/payload.rs create mode 100644 src-tauri/src/commands/java2lce/region.rs create mode 100644 src/components/common/CapePreview.tsx create mode 100644 src/components/modals/GameLogModal.tsx 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 0000000000000000000000000000000000000000..f81541471285c3d1fe7fcb3ed4cbb52f51bdc467 GIT binary patch literal 3285 zcmV;`3@Y=9P)Mmp))nNE6fwv%3-@1p1Dy6EYd4q6#*qaxaWGjfEMaeQg8 zjr{(0W+}g7_ib*dljh-br`g+R<;)S<0N592kI?H&Ipr@eWNB@rLy=Gf=!Mf61-^*V z@9)3PSOJT}06*A4g~2qf0r<;H$LQ6?9R2(9uav*Mc$C(mf>kJB8A@3?jdK8=-S4kH zwuee6LiWWWL{mw+oz~B00sa_BI7+YJuRq52=rLM94;7s0qGhySLVE^3KadU+;Eg*8 zM$++Wt)~87K;q8FmND`11URq2v;5OgPRF(i zsL2xhNNiA@Z9(RnkooJ2S>$?_O33w<5%FaZp=NvB8I~Y@2EH&T&e7*G)LFvV5)M~w zas5@Tu2&bj5d9oTIEwNZJwqC-!B;^5N0~u#)E5U$N7jVyCM9F!~CFgtz_x(Ta-%lH7JE(-5zt-GLPeB3$F5=e6K<5Wq zL;*|!Qi0`P$hXjJzLjSBGc?uTLQl|c7VV#)?`d3TDqm)fh#BG7#ndeqyZ%Bg%CJ?xKDNYqxZd`N3o581YB*K;)fm!DO1uC;O`sc5Sl z?o^Rm*ynQe-F?*eled)u<~X+z z;Q~sI{xrzo_Xm~hh(1SslI4FY0|GKMf&KeXz<31;nE&_y6|Wwq;*~?Rc=-^me1Qk9 z>&J*ya0g`g?Sqy8U95=b{Dsr}5(CFW3`dR&``Tr)BqHFt`kqRmL!!O=d zinxYjOxSGRL>^HGP!oA3;t`^)A`c=s`b@$E)bIf1U6lTKe48IVK%aNNPtQL6fQp|S zq}9LUIRxN8>wZtE!Ef(d^lsp#zyhSN2*7hZKgAU>aq*HZTL;COS!{paYOL!7g6Ca#O zm|@w&r!XH(;coB{InLWW`%fa;445arNsu#v$?yTnTPF@y3}SZlmq>wwwDM&Z;DdJn ziDyM}ugFxCCE^_ITsBMDTvkfB-`uyRrY5S?8-v@JT$^Nq-PVF_d7Ix>&F!1h&^D}< z5eDDDGFP6WT#kX~l;_}nd*2KoChwnZDf&md5b1*tlF-=1fCQM|pKncQtBBJCa0>I= zs=R^LVF88*Yw{ht0=xzVFp*O|4RGnl2ku+|84~>deB(BJjvfU>JG+lpXE<2V;pf4b?uS{DLZI2FEAl!XXK~mi5O`!pj)?Z)7It|P4w^`pp`Z^ zH&J&hl=IGw0<;QrbQMt?ZJip9I6Zgh6!6{j6H?T+q?mlylb+n>q)vzt@RAZjifPNG zKL9@Oc=vHUDZWuI#T)AG?p7rG?L`4Xg!6EnC`7l_kvAR$F56C3daMd$S(AL1d%Gcm z-73No;)sw^>U==lmItx5wIyxgtbo7i{`~eq`Ag(92o*sM)U-2%s5-3>)0ZXEDT#>) zVl8J@-ZuIw>Oz9657g;;M}Qf)M2YKfBMmt6{KxVa^~A~6Mcz$@9zeZsqAF=2YH)p+ z>uUw*V?Nl3MZg6nKvk+V5U@%mRvE#0A}Ga1)&4jnuLwX2bR#8{{A2Pql>auO0>T2< zDzNiFObS@%I^B-X4TBBsi8;^)sFu4awcIUHs$RDF9De{ZP|j=_4hBkBgRZ z8_htQ00=*-MoDpf%C*e4Q$Q?uF)eL9VKrzKxOqVOpM$+E%awu^LdmUpDcakXvteFDM*v4aUlHsS`fj;NNt zNd=kwcw0wZxhBFRQ;r)lh1>v8w;3`WN&kLtz=n=#@Gfnlx4~e3S`ZqP-!?R01J)=) zpHtc>snw^A(uUd|yKw{CT=zzf!N$hM)O^FnPoTVgW9)`1J+x8FW%Z+OE*p69F>iqK z0u=A^ejrf}5?~^x!PzJ|$@=HmG*Ru1#O2sDFQDS}db*!ilUnIfo)=f~ip?uac*VYl zC(XNkX_V|48b>+a(?a9DX}Z%}!J=y}Cx93<6QF=G5t@u3Xt&SDW#0h_L|6e0f)|gI ztjlXTM}gN$V1F{uMl))436AZ@0~va(R+!=WHoDIQ^k(S(sWt`p&dF9OdzpyjCAa@u z$jZ$VWvt5P0jvGYkkzT18f=>@Z*!e;FyGhNlO%Np#vy}Gl#8b{B|6}%jBVaeiQ+q$PLlqNrX4R$vatyC5B8-NQl$@nFhScJip*Cd{ozEuDDmeovq~(V$P9S-VTH7{E5JblZ2mjYA=h(* zY*+H-$Fq8~;s7~t=F2hzk*`boYm@o0W%?{eIvh23Xz^js0A05`)2a^Kzo3@L7+^ar!c7DaC+Jr!vS+RXzF zbU+1$0IPs7*wDjJfB<(_s89iWWPykpHMo2?B&cmoi~-N3uK+7?Cc+Y<6<|m)1T|Hs zfN1#YV~zl=08>iDojQWQL6EtE|MTPxl}a0QV{A-++uu+>tm5AQjNRbx{`|Xu4fW-p zOZDxaeqa0C=;9y@l=o=xvmDLC9VHr>ed*echW;J^=#wWr+WieRfF7g^}c!t0emmUE~ZK T&_UKA00000NkvXXu0mjf+k;PX literal 0 HcmV?d00001 diff --git a/public/Skins/str1k3r.png b/public/Skins/str1k3r.png new file mode 100644 index 0000000000000000000000000000000000000000..bb57ed631ff6f74ff061b9994cde45370f0e0cb4 GIT binary patch literal 638 zcmV-^0)hRBP)jK~z}7?UsRZ!XONWYa1pZ2;%!c>=GcgwYU?V zZr=Hy2W^1Q5CUZws?cSjwKf;Sh0saqaL6s2W^Ura0@LbTejMpJTxL#!=+|KJ8=NpV*I>H&eDt-ClLITYlmlVBt>;tV!)`8mF-VYXDMe1Ex6L1V|yIHXu9**iIpcU{)AK6;PgQ zJW5qfmw*rsS^Q9Yq@(sO;Ol@?bj(T;JC^v(KYcE*xvHksIk0MhZovQb06u^XU@Un& zZqoT-z_XOngX8n>tpUUNc>3t~R5E(L9K53ke?Ns@cY_=*i_d-EA<_V}?*%=8FuiID zdQZ@M!{UxG-2~Blg5Ddt0eZaaalbg)7nhwMv>LGfHrxhy6A$$LDN}fj2R(r9&w4N5 Y8wL$C{%!cKNB{r;07*qoM6N<$f`6MKwEzGB literal 0 HcmV?d00001 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, 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> { let target_dir = util::get_instance_working_dir(app, instance_id); if !target_dir.exists() { diff --git a/src-tauri/src/commands/java2lce/block_mapping.rs b/src-tauri/src/commands/java2lce/block_mapping.rs new file mode 100644 index 0000000..cee48fc --- /dev/null +++ b/src-tauri/src/commands/java2lce/block_mapping.rs @@ -0,0 +1,1353 @@ +use std::collections::HashMap; +use super::nbt::NbtCompound; +#[derive(Clone, Copy, Default)] +pub struct LegacyBlockState { + pub id: u8, + pub data: u8, +} + +pub fn map_modern_block_state(compound: &NbtCompound) -> LegacyBlockState { + map_modern_block_state_inner(compound, None) +} + +pub fn map_modern_block_state_with_context( + compound: &NbtCompound, + unknown_blocks: Option<&mut Vec>, +) -> LegacyBlockState { + map_modern_block_state_inner(compound, unknown_blocks) +} + +fn map_modern_block_state_inner( + compound: &NbtCompound, + mut unknown_blocks: Option<&mut Vec>, +) -> LegacyBlockState { + let name_raw = compound.string("Name").unwrap_or("minecraft:air"); + let name = name_raw.strip_prefix("minecraft:").unwrap_or(name_raw); + let properties = compound.compound("Properties"); + if let Some(fluid) = try_map_fluid(name, properties) { + return fluid; + } + if let Some(slab) = try_map_slab(name, properties) { + return slab; + } + if let Some(directional) = try_map_directional(name, properties) { + return directional; + } + if let Some(flattened) = try_map_flattened_colored(name) { + return flattened; + } + if let Some(direct) = MODERN_DIRECT_MAP.get(name) { + return *direct; + } + if let Some(colored) = try_map_colored(name, properties) { + return colored; + } + if let Some(wood) = try_map_wood(name, properties) { + return wood; + } + if let Some(variant) = try_map_variant(name, properties) { + return variant; + } + + if let Some(ref mut blocks) = unknown_blocks { + if !name.is_empty() && name != "air" && name != "cave_air" && name != "void_air" { + blocks.push(name.to_string()); + } + } + + LegacyBlockState { id: 0, data: 0 } +} + +fn get_property<'a>(properties: Option<&'a NbtCompound>, name: &str) -> String { + properties + .and_then(|p| p.string(name)) + .unwrap_or("") + .to_string() +} + +fn get_bool_property(properties: Option<&NbtCompound>, name: &str) -> bool { + get_property(properties, name).to_lowercase() == "true" +} + +fn get_int_property(properties: Option<&NbtCompound>, name: &str, default: i32) -> i32 { + get_property(properties, name) + .parse() + .unwrap_or(default) +} + +fn try_map_fluid(name: &str, properties: Option<&NbtCompound>) -> Option { + if name != "water" && name != "lava" { + return None; + } + let level = get_int_property(properties, "level", 0).clamp(0, 15); + let is_source = level == 0; + let data = level as u8; + Some(if name == "water" { + LegacyBlockState { + id: if is_source { 9 } else { 8 }, + data, + } + } else { + LegacyBlockState { + id: if is_source { 11 } else { 10 }, + data, + } + }) +} + +fn try_map_directional(name: &str, properties: Option<&NbtCompound>) -> Option { + match name { + "ladder" => Some(LegacyBlockState { + id: 65, + data: map_ladder_facing(&get_property(properties, "facing")), + }), + "vine" => Some(LegacyBlockState { + id: 106, + data: map_vine_faces(properties), + }), + "lever" => { + let mut data = + map_lever_data(&get_property(properties, "face"), &get_property(properties, "facing")); + if get_bool_property(properties, "powered") { + data |= 8; + } + Some(LegacyBlockState { id: 69, data }) + } + _ if name.ends_with("_button") => { + let id = if is_wood_family(name) { 143 } else { 77 }; + let mut data = map_button_facing(&get_property(properties, "facing")); + if get_bool_property(properties, "powered") { + data |= 8; + } + Some(LegacyBlockState { id, data }) + } + _ if name.ends_with("_fence_gate") => { + let mut data = map_fence_gate_facing(&get_property(properties, "facing")); + if get_bool_property(properties, "open") { + data |= 4; + } + Some(LegacyBlockState { id: 107, data }) + } + _ if name.ends_with("_pressure_plate") => { + let id = match name { + "light_weighted_pressure_plate" => 147, + "heavy_weighted_pressure_plate" => 148, + _ if is_wood_family(name) => 72, + _ => 70, + }; + let powered = + get_bool_property(properties, "powered") || get_int_property(properties, "power", 0) > 0; + Some(LegacyBlockState { + id, + data: if powered { 1 } else { 0 }, + }) + } + _ if name.ends_with("_door") || name == "iron_door" => { + let id = if name == "iron_door" { 71 } else { 64 }; + let half = get_property(properties, "half"); + let is_upper = half == "upper"; + if is_upper { + let mut data = 8; + if get_property(properties, "hinge") == "right" { + data |= 1; + } + if get_bool_property(properties, "powered") { + data |= 2; + } + Some(LegacyBlockState { id, data }) + } else { + let mut data = map_door_facing(&get_property(properties, "facing")); + if get_bool_property(properties, "open") { + data |= 4; + } + Some(LegacyBlockState { id, data }) + } + } + _ if try_get_stairs_id(name).is_some() => { + let id = try_get_stairs_id(name).unwrap(); + let mut data = map_stairs_facing(&get_property(properties, "facing")); + if get_property(properties, "half") == "top" { + data |= 4; + } + Some(LegacyBlockState { id, data }) + } + _ if name.ends_with("trapdoor") => { + let mut data = map_trapdoor_facing(&get_property(properties, "facing")); + if get_bool_property(properties, "open") { + data |= 4; + } + if get_property(properties, "half") == "top" { + data |= 8; + } + Some(LegacyBlockState { id: 96, data }) + } + "dispenser" | "dropper" => { + let id = if name == "dropper" { 158 } else { 23 }; + let mut data = map_facing_data(&get_property(properties, "facing")); + if get_bool_property(properties, "triggered") { + data |= 8; + } + Some(LegacyBlockState { id, data }) + } + "piston" | "sticky_piston" => { + let id = if name == "sticky_piston" { 29 } else { 33 }; + let mut data = map_facing_data(&get_property(properties, "facing")); + if get_bool_property(properties, "extended") { + data |= 8; + } + Some(LegacyBlockState { id, data }) + } + "piston_head" => { + let mut data = map_facing_data(&get_property(properties, "facing")); + if get_property(properties, "type") == "sticky" { + data |= 8; + } + Some(LegacyBlockState { id: 34, data }) + } + "redstone_wire" => { + let data = get_int_property(properties, "power", 0).clamp(0, 15) as u8; + Some(LegacyBlockState { id: 55, data }) + } + "repeater" => { + let id = if get_bool_property(properties, "powered") { 94 } else { 93 }; + let dir = map_repeater_direction(&get_property(properties, "facing")); + let delay = get_int_property(properties, "delay", 1).clamp(1, 4); + Some(LegacyBlockState { + id, + data: dir | (((delay - 1) as u8) << 2), + }) + } + "comparator" => { + let id = if get_bool_property(properties, "powered") { 150 } else { 149 }; + let dir = map_repeater_direction(&get_property(properties, "facing")); + let mode = if get_property(properties, "mode") == "subtract" { 4 } else { 0 }; + Some(LegacyBlockState { + id, + data: dir | mode, + }) + } + "wall_torch" => Some(LegacyBlockState { + id: 50, + data: map_wall_torch_facing(&get_property(properties, "facing")), + }), + "redstone_wall_torch" => { + let id = if get_bool_property(properties, "lit") { 76 } else { 75 }; + Some(LegacyBlockState { + id, + data: map_wall_torch_facing(&get_property(properties, "facing")), + }) + } + "nether_wart" => Some(LegacyBlockState { + id: 115, + data: get_int_property(properties, "age", 0).clamp(0, 3) as u8, + }), + "wheat" => Some(LegacyBlockState { + id: 59, + data: get_int_property(properties, "age", 0).clamp(0, 7) as u8, + }), + "pumpkin_stem" | "attached_pumpkin_stem" => Some(LegacyBlockState { + id: 104, + data: if name == "attached_pumpkin_stem" { + 7 + } else { + get_int_property(properties, "age", 0).clamp(0, 7) as u8 + }, + }), + "melon_stem" | "attached_melon_stem" => Some(LegacyBlockState { + id: 105, + data: if name == "attached_melon_stem" { + 7 + } else { + get_int_property(properties, "age", 0).clamp(0, 7) as u8 + }, + }), + "cocoa" => { + let age = get_int_property(properties, "age", 0).clamp(0, 2) as u8; + let dir = map_repeater_direction(&get_property(properties, "facing")); + Some(LegacyBlockState { + id: 127, + data: (age << 2) | dir, + }) + } + "hay_block" => Some(LegacyBlockState { + id: 170, + data: match get_property(properties, "axis").as_str() { + "x" => 4, + "z" => 8, + _ => 0, + }, + }), + "quartz_pillar" => Some(LegacyBlockState { + id: 155, + data: match get_property(properties, "axis").as_str() { + "x" => 3, + "z" => 4, + _ => 2, + }, + }), + "nether_portal" => Some(LegacyBlockState { + id: 90, + data: if get_property(properties, "axis") == "z" { 2 } else { 1 }, + }), + _ => None, + } +} + +fn try_map_slab(name: &str, properties: Option<&NbtCompound>) -> Option { + if !name.ends_with("_slab") { + return None; + } + + let slab_type = get_property(properties, "type"); + let is_top = slab_type == "top"; + let is_double = slab_type == "double"; + if let Some(wood_variant) = get_wood_slab_variant(name) { + return Some(if is_double { + LegacyBlockState { + id: 125, + data: wood_variant, + } + } else { + LegacyBlockState { + id: 126, + data: wood_variant | if is_top { 8 } else { 0 }, + } + }); + } + + if let Some(slab_variant) = get_stone_slab_variant(name) { + return Some(if is_double { + LegacyBlockState { + id: 43, + data: slab_variant, + } + } else { + LegacyBlockState { + id: 44, + data: slab_variant | if is_top { 8 } else { 0 }, + } + }); + } + + Some(if is_double { + LegacyBlockState { id: 43, data: 0 } + } else { + LegacyBlockState { + id: 44, + data: if is_top { 8 } else { 0 }, + } + }) +} + +fn get_wood_slab_variant(name: &str) -> Option { + match name { + "oak_slab" => Some(0), + "spruce_slab" => Some(1), + "birch_slab" => Some(2), + "jungle_slab" => Some(3), + "acacia_slab" => Some(4), + "dark_oak_slab" => Some(5), + _ => None, + } +} + +fn get_stone_slab_variant(name: &str) -> Option { + match name { + "stone_slab" | "smooth_stone_slab" | "andesite_slab" | "polished_andesite_slab" + | "diorite_slab" | "polished_diorite_slab" | "granite_slab" | "polished_granite_slab" => { + Some(0) + } + "sandstone_slab" | "smooth_sandstone_slab" | "cut_sandstone_slab" + | "red_sandstone_slab" | "smooth_red_sandstone_slab" | "cut_red_sandstone_slab" => Some(1), + "cobblestone_slab" | "mossy_cobblestone_slab" | "cobbled_deepslate_slab" + | "deepslate_brick_slab" | "deepslate_tile_slab" => Some(3), + "brick_slab" => Some(4), + "stone_brick_slab" | "mossy_stone_brick_slab" => Some(5), + "nether_brick_slab" | "red_nether_brick_slab" => Some(6), + "quartz_slab" | "smooth_quartz_slab" | "purpur_slab" | "prismarine_slab" + | "prismarine_brick_slab" | "dark_prismarine_slab" => Some(7), + _ => None, + } +} + +fn try_get_stairs_id(name: &str) -> Option { + match name { + "oak_stairs" | "spruce_stairs" | "birch_stairs" | "jungle_stairs" | "acacia_stairs" + | "dark_oak_stairs" => Some(53), + "cobblestone_stairs" | "stone_stairs" | "mossy_cobblestone_stairs" + | "cobbled_deepslate_stairs" | "blackstone_stairs" => Some(67), + "brick_stairs" => Some(108), + "stone_brick_stairs" | "dark_prismarine_stairs" | "prismarine_stairs" + | "prismarine_brick_stairs" | "polished_blackstone_brick_stairs" + | "mossy_stone_brick_stairs" | "deepslate_brick_stairs" | "deepslate_tile_stairs" => { + Some(109) + } + "nether_brick_stairs" | "crimson_stairs" | "warped_stairs" => Some(114), + "sandstone_stairs" | "red_sandstone_stairs" => Some(128), + "quartz_stairs" | "purpur_stairs" => Some(156), + "spruce_stairs" => Some(134), + "birch_stairs" => Some(135), + "jungle_stairs" => Some(136), + "acacia_stairs" => Some(163), + "dark_oak_stairs" => Some(164), + _ => None, + } +} + +fn map_stairs_facing(facing: &str) -> u8 { + match facing { + "east" => 0, + "west" => 1, + "south" => 2, + "north" => 3, + _ => 0, + } +} + +fn map_ladder_facing(facing: &str) -> u8 { + match facing { + "north" => 2, + "south" => 3, + "west" => 4, + "east" => 5, + _ => 2, + } +} + +fn map_button_facing(facing: &str) -> u8 { + match facing { + "east" => 1, + "west" => 2, + "south" => 3, + "north" => 4, + _ => 1, + } +} + +fn map_fence_gate_facing(facing: &str) -> u8 { + match facing { + "south" => 0, + "west" => 1, + "north" => 2, + "east" => 3, + _ => 0, + } +} + +fn map_vine_faces(properties: Option<&NbtCompound>) -> u8 { + let mut data = 0; + if get_bool_property(properties, "south") { + data |= 1; + } + if get_bool_property(properties, "west") { + data |= 2; + } + if get_bool_property(properties, "north") { + data |= 4; + } + if get_bool_property(properties, "east") { + data |= 8; + } + data +} + +fn map_lever_data(face: &str, facing: &str) -> u8 { + match face { + "floor" => { + if facing == "east" || facing == "west" { + 6 + } else { + 5 + } + } + "ceiling" => { + if facing == "east" || facing == "west" { + 7 + } else { + 0 + } + } + _ => map_button_facing(facing), + } +} + +fn map_door_facing(facing: &str) -> u8 { + match facing { + "east" => 0, + "south" => 1, + "west" => 2, + "north" => 3, + _ => 0, + } +} + +fn map_trapdoor_facing(facing: &str) -> u8 { + match facing { + "north" => 0, + "south" => 1, + "west" => 2, + "east" => 3, + _ => 0, + } +} + +fn map_facing_data(facing: &str) -> u8 { + match facing { + "down" => 0, + "up" => 1, + "north" => 2, + "south" => 3, + "west" => 4, + "east" => 5, + _ => 3, + } +} + +fn map_repeater_direction(facing: &str) -> u8 { + match facing { + "south" => 0, + "west" => 1, + "north" => 2, + "east" => 3, + _ => 0, + } +} + +fn map_wall_torch_facing(facing: &str) -> u8 { + match facing { + "east" => 1, + "west" => 2, + "south" => 3, + "north" => 4, + _ => 1, + } +} + +fn try_map_colored(name: &str, properties: Option<&NbtCompound>) -> Option { + let color = get_color_data(&get_property(properties, "color")); + match name { + "wool" => Some(LegacyBlockState { id: 35, data: color }), + "stained_glass" => Some(LegacyBlockState { id: 95, data: color }), + "stained_glass_pane" => Some(LegacyBlockState { id: 160, data: color }), + "stained_hardened_clay" | "terracotta" => { + Some(LegacyBlockState { id: 159, data: color }) + } + "concrete" => Some(LegacyBlockState { + id: 172, + data: color, + }), + "concrete_powder" => Some(LegacyBlockState { id: 12, data: color }), + "glazed_terracotta" => Some(LegacyBlockState { id: 159, data: color }), + _ => None, + } +} + +fn try_map_flattened_colored(name: &str) -> Option { + let (color_data, suffix) = split_color_prefix(name)?; + let data = color_data; + match suffix.as_str() { + "wool" => Some(LegacyBlockState { id: 35, data }), + "stained_glass" | "glass" => Some(LegacyBlockState { id: 95, data }), + "stained_glass_pane" | "glass_pane" => Some(LegacyBlockState { id: 160, data }), + "stained_hardened_clay" | "terracotta" => Some(LegacyBlockState { id: 159, data }), + "concrete" => Some(LegacyBlockState { id: 172, data }), + "concrete_powder" => Some(LegacyBlockState { id: 12, data }), + "glazed_terracotta" => Some(LegacyBlockState { id: 159, data }), + "carpet" => Some(LegacyBlockState { id: 171, data }), + _ => None, + } +} + +fn split_color_prefix(name: &str) -> Option<(u8, String)> { + for color_name in COLOR_NAMES { + let prefix = format!("{}_", color_name); + if name.starts_with(&prefix) { + let suffix = name[prefix.len()..].to_string(); + return Some((get_color_data(color_name), suffix)); + } + } + None +} + +const COLOR_NAMES: &[&str] = &[ + "white", "orange", "magenta", "light_blue", "yellow", "lime", "pink", "gray", "silver", + "light_gray", "cyan", "purple", "blue", "brown", "green", "red", "black", +]; + +fn get_color_data(color: &str) -> u8 { + match color { + "white" => 0, + "orange" => 1, + "magenta" => 2, + "light_blue" => 3, + "yellow" => 4, + "lime" => 5, + "pink" => 6, + "gray" => 7, + "light_gray" => 8, + "cyan" => 9, + "purple" => 10, + "blue" => 11, + "brown" => 12, + "green" => 13, + "red" => 14, + "black" => 15, + _ => 0, + } +} + +fn try_map_wood(name: &str, properties: Option<&NbtCompound>) -> Option { + let wood_type = get_property(properties, "variant"); + let wood_type = if wood_type.is_empty() { + get_prefix_before_underscore(name) + } else { + wood_type + }; + + let data_value = match wood_type.as_str() { + "spruce" => 1, + "birch" => 2, + "jungle" => 3, + "acacia" => 0, + "dark_oak" => 1, + _ => 0, + }; + + if name.ends_with("_planks") || name == "planks" { + return Some(LegacyBlockState { id: 5, data: data_value }); + } + if name.ends_with("_sapling") || name == "sapling" { + return Some(LegacyBlockState { id: 6, data: data_value }); + } + if name.ends_with("_log") || name == "log" { + return Some(LegacyBlockState { + id: 17, + data: data_value.min(3), + }); + } + if name.ends_with("_leaves") || name == "leaves" { + return Some(LegacyBlockState { + id: 18, + data: data_value.min(3), + }); + } + if name.ends_with("_stairs") && name.contains("wood") { + return Some(LegacyBlockState { id: 53, data: 0 }); + } + if name.ends_with("_door") { + return Some(LegacyBlockState { id: 64, data: 0 }); + } + if name.ends_with("_fence") { + return Some(LegacyBlockState { id: 85, data: 0 }); + } + if name.ends_with("_fence_gate") { + return Some(LegacyBlockState { id: 107, data: 0 }); + } + if name.ends_with("_pressure_plate") && is_wood_family(name) { + return Some(LegacyBlockState { id: 72, data: 0 }); + } + + None +} + +fn try_map_variant(name: &str, properties: Option<&NbtCompound>) -> Option { + if let Some(fallback) = try_map_common_fallback(name, properties) { + return Some(fallback); + } + + if name == "redstone_lamp" { + let lit = get_bool_property(properties, "lit"); + return Some(LegacyBlockState { + id: if lit { 124 } else { 123 }, + data: 0, + }); + } + + let result = match name { + "deepslate" | "polished_deepslate" | "tuff" | "calcite" | "dripstone_block" => { + Some(LegacyBlockState { id: 1, data: 0 }) + } + "cobbled_deepslate" => Some(LegacyBlockState { id: 4, data: 0 }), + "deepslate_bricks" | "deepslate_tiles" => Some(LegacyBlockState { id: 98, data: 0 }), + "cracked_deepslate_bricks" | "cracked_deepslate_tiles" => { + Some(LegacyBlockState { id: 98, data: 2 }) + } + "chiseled_deepslate" => Some(LegacyBlockState { id: 98, data: 3 }), + "deepslate_coal_ore" => Some(LegacyBlockState { id: 16, data: 0 }), + "deepslate_iron_ore" | "deepslate_copper_ore" => { + Some(LegacyBlockState { id: 15, data: 0 }) + } + "deepslate_gold_ore" => Some(LegacyBlockState { id: 14, data: 0 }), + "deepslate_redstone_ore" => Some(LegacyBlockState { id: 73, data: 0 }), + "deepslate_lapis_ore" => Some(LegacyBlockState { id: 21, data: 0 }), + "deepslate_diamond_ore" => Some(LegacyBlockState { id: 56, data: 0 }), + "deepslate_emerald_ore" => Some(LegacyBlockState { id: 129, data: 0 }), + "deepslate_tile_stairs" | "deepslate_brick_stairs" | "cobbled_deepslate_stairs" => { + Some(LegacyBlockState { id: 67, data: 0 }) + } + "deepslate_tile_slab" | "deepslate_brick_slab" | "cobbled_deepslate_slab" => { + Some(LegacyBlockState { id: 44, data: 3 }) + } + "deepslate_tile_wall" | "deepslate_brick_wall" | "cobbled_deepslate_wall" => { + Some(LegacyBlockState { id: 139, data: 0 }) + } + "grass_block" => Some(LegacyBlockState { id: 2, data: 0 }), + "coarse_dirt" | "podzol" => Some(LegacyBlockState { id: 3, data: 0 }), + "grass_path" | "dirt_path" | "moss_carpet" => Some(LegacyBlockState { id: 2, data: 0 }), + "cobblestone_wall" => Some(LegacyBlockState { id: 139, data: 0 }), + "mossy_cobblestone_wall" => Some(LegacyBlockState { id: 139, data: 1 }), + "smooth_stone_slab" => Some(LegacyBlockState { id: 44, data: 0 }), + "stone_brick_stairs" => Some(LegacyBlockState { id: 109, data: 0 }), + "mossy_stone_bricks" => Some(LegacyBlockState { id: 98, data: 1 }), + "cracked_stone_bricks" => Some(LegacyBlockState { id: 98, data: 2 }), + "chiseled_stone_bricks" => Some(LegacyBlockState { id: 98, data: 3 }), + "red_sand" => Some(LegacyBlockState { id: 12, data: 1 }), + "red_sandstone" => Some(LegacyBlockState { id: 24, data: 0 }), + "smooth_sandstone" => Some(LegacyBlockState { id: 24, data: 2 }), + "chiseled_sandstone" | "cut_sandstone" => Some(LegacyBlockState { id: 24, data: 1 }), + "smooth_red_sandstone" | "chiseled_red_sandstone" | "cut_red_sandstone" => { + Some(LegacyBlockState { id: 24, data: 0 }) + } + "red_sandstone_stairs" => Some(LegacyBlockState { id: 128, data: 0 }), + "red_sandstone_slab" => Some(LegacyBlockState { id: 44, data: 1 }), + "prismarine" | "prismarine_bricks" | "dark_prismarine" => { + Some(LegacyBlockState { id: 1, data: 0 }) + } + "sea_lantern" => Some(LegacyBlockState { id: 89, data: 0 }), + "purpur_block" | "purpur_pillar" => Some(LegacyBlockState { id: 155, data: 0 }), + "purpur_stairs" => Some(LegacyBlockState { id: 156, data: 0 }), + "purpur_slab" => Some(LegacyBlockState { id: 44, data: 0 }), + "end_stone_bricks" => Some(LegacyBlockState { id: 121, data: 0 }), + "magma_block" => Some(LegacyBlockState { id: 87, data: 0 }), + "nether_wart_block" | "red_nether_bricks" => Some(LegacyBlockState { id: 112, data: 0 }), + "bone_block" => Some(LegacyBlockState { id: 1, data: 0 }), + "packed_ice" | "blue_ice" => Some(LegacyBlockState { id: 79, data: 0 }), + "observer" | "beetroots" | "kelp" | "kelp_plant" | "seagrass" | "tall_seagrass" + | "coral_block" | "tube_coral_block" | "brain_coral_block" | "bubble_coral_block" + | "fire_coral_block" | "horn_coral_block" | "end_rod" | "chorus_plant" + | "chorus_flower" | "end_gateway" | "structure_void" => { + Some(LegacyBlockState { id: 0, data: 0 }) + } + "sunflower" | "lilac" | "rose_bush" | "peony" => { + if get_property(properties, "half") == "upper" { + Some(LegacyBlockState { id: 0, data: 0 }) + } else { + Some(LegacyBlockState { id: 38, data: 0 }) + } + } + "tall_grass" => { + if get_property(properties, "half") == "upper" { + Some(LegacyBlockState { id: 0, data: 0 }) + } else { + let grass_type = get_property(properties, "type"); + Some(LegacyBlockState { + id: 31, + data: if grass_type == "fern" { 2 } else { 1 }, + }) + } + } + "large_fern" => { + if get_property(properties, "half") == "upper" { + Some(LegacyBlockState { id: 0, data: 0 }) + } else { + Some(LegacyBlockState { id: 31, data: 2 }) + } + } + "stone" => { + let _type = get_property(properties, "type"); + Some(LegacyBlockState { id: 1, data: 0 }) + } + "sandstone" => { + let stype = get_property(properties, "type"); + Some(LegacyBlockState { + id: 24, + data: match stype.as_str() { + "chiseled" => 1, + "smooth" => 2, + _ => 0, + }, + }) + } + "stone_bricks" => { + let stype = get_property(properties, "type"); + Some(LegacyBlockState { + id: 98, + data: match stype.as_str() { + "mossy" => 1, + "cracked" => 2, + "chiseled" => 3, + _ => 0, + }, + }) + } + _ => None, + }; + + result +} + +fn try_map_common_fallback(name: &str, properties: Option<&NbtCompound>) -> Option { + if name.ends_with("_button") { + return Some(LegacyBlockState { + id: if is_wood_family(name) { 143 } else { 77 }, + data: 0, + }); + } + if name.ends_with("_wall_sign") { + return Some(LegacyBlockState { + id: 68, + data: map_wall_sign_facing(&get_property(properties, "facing")), + }); + } + if name.ends_with("_sign") { + return Some(LegacyBlockState { + id: 63, + data: (get_int_property(properties, "rotation", 0) & 0x0F) as u8, + }); + } + if name.ends_with("_wall") { + return Some(LegacyBlockState { id: 139, data: 0 }); + } + if name.ends_with("_bed") { + let mut data = map_bed_facing(&get_property(properties, "facing")); + if get_property(properties, "part") == "head" { + data |= 8; + } + return Some(LegacyBlockState { id: 26, data }); + } + if name.ends_with("_banner") { + return Some(LegacyBlockState { id: 0, data: 0 }); + } + + match name { + "barrier" | "structure_void" => Some(LegacyBlockState { id: 0, data: 0 }), + "bamboo" => Some(LegacyBlockState { id: 83, data: 0 }), + "cake" => Some(LegacyBlockState { id: 92, data: 0 }), + "brewing_stand" => Some(LegacyBlockState { id: 117, data: 0 }), + "barrel" => Some(LegacyBlockState { id: 54, data: 0 }), + "blast_furnace" => Some(LegacyBlockState { id: 61, data: 0 }), + "campfire" => Some(LegacyBlockState { id: 50, data: 0 }), + "azure_bluet" | "blue_orchid" => Some(LegacyBlockState { id: 38, data: 0 }), + "attached_melon_stem" => Some(LegacyBlockState { id: 105, data: 7 }), + "attached_pumpkin_stem" => Some(LegacyBlockState { id: 104, data: 7 }), + "andesite" | "diorite" | "granite" | "polished_andesite" | "polished_diorite" + | "polished_granite" => Some(LegacyBlockState { id: 1, data: 0 }), + "andesite_stairs" | "diorite_stairs" | "granite_stairs" | "polished_andesite_stairs" + | "polished_diorite_stairs" | "polished_granite_stairs" => { + Some(LegacyBlockState { id: 109, data: 0 }) + } + "bubble_column" => Some(LegacyBlockState { id: 9, data: 0 }), + "amethyst_block" | "budding_amethyst" | "amethyst_cluster" => { + Some(LegacyBlockState { id: 20, data: 0 }) + } + _ => None, + } +} + +fn map_bed_facing(facing: &str) -> u8 { + match facing { + "south" => 0, + "west" => 1, + "north" => 2, + "east" => 3, + _ => 0, + } +} + +fn map_wall_sign_facing(facing: &str) -> u8 { + match facing { + "north" => 2, + "south" => 3, + "west" => 4, + "east" => 5, + _ => 2, + } +} + +fn get_prefix_before_underscore(value: &str) -> String { + let index = value.find('_'); + match index { + Some(i) if i > 0 => value[..i].to_string(), + _ => value.to_string(), + } +} + +fn is_wood_family(name: &str) -> bool { + name.contains("oak") + || name.contains("spruce") + || name.contains("birch") + || name.contains("jungle") + || name.contains("acacia") + || name.contains("dark_oak") + || name.contains("mangrove") + || name.contains("cherry") + || name.contains("bamboo") + || name.contains("crimson") + || name.contains("warped") +} + +pub fn is_valid_lce_tile_id(id: u8) -> bool { + id <= 160 || (id >= 170 && id <= 173) +} + +pub fn remap_blocks(blocks: &mut [u8], _data: &mut [u8]) { + for id in blocks.iter_mut() { + let mut bid = *id; + if bid == 137 { + bid = 1; + } + if bid >= 165 { + if let Some(&replacement) = BLOCK_REMAP_TABLE.get(&bid) { + bid = replacement; + } + } + if !is_valid_lce_tile_id(bid) { + bid = 0; + } + *id = bid; + } +} + +static MODERN_DIRECT_MAP: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| { + let mut m = HashMap::new(); + m.insert("air", LegacyBlockState { id: 0, data: 0 }); + m.insert("cave_air", LegacyBlockState { id: 0, data: 0 }); + m.insert("void_air", LegacyBlockState { id: 0, data: 0 }); + m.insert("stone", LegacyBlockState { id: 1, data: 0 }); + m.insert("grass", LegacyBlockState { id: 31, data: 1 }); + m.insert("short_grass", LegacyBlockState { id: 31, data: 1 }); + m.insert("short_dry_grass", LegacyBlockState { id: 31, data: 1 }); + m.insert("dirt", LegacyBlockState { id: 3, data: 0 }); + m.insert("cobblestone", LegacyBlockState { id: 4, data: 0 }); + m.insert("bedrock", LegacyBlockState { id: 7, data: 0 }); + m.insert("water", LegacyBlockState { id: 8, data: 0 }); + m.insert("lava", LegacyBlockState { id: 10, data: 0 }); + m.insert("sand", LegacyBlockState { id: 12, data: 0 }); + m.insert("gravel", LegacyBlockState { id: 13, data: 0 }); + m.insert("gold_ore", LegacyBlockState { id: 14, data: 0 }); + m.insert("iron_ore", LegacyBlockState { id: 15, data: 0 }); + m.insert("coal_ore", LegacyBlockState { id: 16, data: 0 }); + m.insert("oak_log", LegacyBlockState { id: 17, data: 0 }); + m.insert("oak_leaves", LegacyBlockState { id: 18, data: 0 }); + m.insert("glass", LegacyBlockState { id: 20, data: 0 }); + m.insert("lapis_ore", LegacyBlockState { id: 21, data: 0 }); + m.insert("lapis_block", LegacyBlockState { id: 22, data: 0 }); + m.insert("dispenser", LegacyBlockState { id: 23, data: 0 }); + m.insert("sandstone", LegacyBlockState { id: 24, data: 0 }); + m.insert("note_block", LegacyBlockState { id: 25, data: 0 }); + m.insert("powered_rail", LegacyBlockState { id: 27, data: 0 }); + m.insert("detector_rail", LegacyBlockState { id: 28, data: 0 }); + m.insert("sticky_piston", LegacyBlockState { id: 29, data: 0 }); + m.insert("cobweb", LegacyBlockState { id: 30, data: 0 }); + m.insert("dead_bush", LegacyBlockState { id: 32, data: 0 }); + m.insert("piston", LegacyBlockState { id: 33, data: 0 }); + m.insert("wool", LegacyBlockState { id: 35, data: 0 }); + m.insert("dandelion", LegacyBlockState { id: 37, data: 0 }); + m.insert("poppy", LegacyBlockState { id: 38, data: 0 }); + m.insert("brown_mushroom", LegacyBlockState { id: 39, data: 0 }); + m.insert("red_mushroom", LegacyBlockState { id: 40, data: 0 }); + m.insert("gold_block", LegacyBlockState { id: 41, data: 0 }); + m.insert("iron_block", LegacyBlockState { id: 42, data: 0 }); + m.insert("stone_slab", LegacyBlockState { id: 44, data: 0 }); + m.insert("bricks", LegacyBlockState { id: 45, data: 0 }); + m.insert("tnt", LegacyBlockState { id: 46, data: 0 }); + m.insert("bookshelf", LegacyBlockState { id: 47, data: 0 }); + m.insert("mossy_cobblestone", LegacyBlockState { id: 48, data: 0 }); + m.insert("obsidian", LegacyBlockState { id: 49, data: 0 }); + m.insert("torch", LegacyBlockState { id: 50, data: 0 }); + m.insert("fire", LegacyBlockState { id: 51, data: 0 }); + m.insert("mob_spawner", LegacyBlockState { id: 52, data: 0 }); + m.insert("oak_stairs", LegacyBlockState { id: 53, data: 0 }); + m.insert("chest", LegacyBlockState { id: 54, data: 0 }); + m.insert("diamond_ore", LegacyBlockState { id: 56, data: 0 }); + m.insert("diamond_block", LegacyBlockState { id: 57, data: 0 }); + m.insert("crafting_table", LegacyBlockState { id: 58, data: 0 }); + m.insert("farmland", LegacyBlockState { id: 60, data: 0 }); + m.insert("furnace", LegacyBlockState { id: 61, data: 0 }); + m.insert("ladder", LegacyBlockState { id: 65, data: 0 }); + m.insert("rail", LegacyBlockState { id: 66, data: 0 }); + m.insert("lever", LegacyBlockState { id: 69, data: 0 }); + m.insert("stone_pressure_plate", LegacyBlockState { id: 70, data: 0 }); + m.insert("oak_pressure_plate", LegacyBlockState { id: 72, data: 0 }); + m.insert("redstone_ore", LegacyBlockState { id: 73, data: 0 }); + m.insert("redstone_torch", LegacyBlockState { id: 76, data: 0 }); + m.insert("stone_button", LegacyBlockState { id: 77, data: 0 }); + m.insert("snow", LegacyBlockState { id: 78, data: 0 }); + m.insert("ice", LegacyBlockState { id: 79, data: 0 }); + m.insert("snow_block", LegacyBlockState { id: 80, data: 0 }); + m.insert("cactus", LegacyBlockState { id: 81, data: 0 }); + m.insert("clay", LegacyBlockState { id: 82, data: 0 }); + m.insert("jukebox", LegacyBlockState { id: 84, data: 0 }); + m.insert("oak_fence", LegacyBlockState { id: 85, data: 0 }); + m.insert("netherrack", LegacyBlockState { id: 87, data: 0 }); + m.insert("soul_sand", LegacyBlockState { id: 88, data: 0 }); + m.insert("glowstone", LegacyBlockState { id: 89, data: 0 }); + m.insert("jack_o_lantern", LegacyBlockState { id: 91, data: 0 }); + m.insert("stone_bricks", LegacyBlockState { id: 98, data: 0 }); + m.insert("brown_mushroom_block", LegacyBlockState { id: 99, data: 0 }); + m.insert("red_mushroom_block", LegacyBlockState { id: 100, data: 0 }); + m.insert("iron_bars", LegacyBlockState { id: 101, data: 0 }); + m.insert("glass_pane", LegacyBlockState { id: 102, data: 0 }); + m.insert("melon", LegacyBlockState { id: 103, data: 0 }); + m.insert("vine", LegacyBlockState { id: 106, data: 0 }); + m.insert("oak_fence_gate", LegacyBlockState { id: 107, data: 0 }); + m.insert("brick_stairs", LegacyBlockState { id: 108, data: 0 }); + m.insert("stone_brick_stairs", LegacyBlockState { id: 109, data: 0 }); + m.insert("mycelium", LegacyBlockState { id: 110, data: 0 }); + m.insert("lily_pad", LegacyBlockState { id: 111, data: 0 }); + m.insert("nether_bricks", LegacyBlockState { id: 112, data: 0 }); + m.insert("nether_brick_fence", LegacyBlockState { id: 113, data: 0 }); + m.insert("nether_brick_stairs", LegacyBlockState { id: 114, data: 0 }); + m.insert("enchanting_table", LegacyBlockState { id: 116, data: 0 }); + m.insert("end_stone", LegacyBlockState { id: 121, data: 0 }); + m.insert("sandstone_stairs", LegacyBlockState { id: 128, data: 0 }); + m.insert("emerald_ore", LegacyBlockState { id: 129, data: 0 }); + m.insert("ender_chest", LegacyBlockState { id: 130, data: 0 }); + m.insert("tripwire_hook", LegacyBlockState { id: 131, data: 0 }); + m.insert("emerald_block", LegacyBlockState { id: 133, data: 0 }); + m.insert("spruce_stairs", LegacyBlockState { id: 134, data: 0 }); + m.insert("birch_stairs", LegacyBlockState { id: 135, data: 0 }); + m.insert("jungle_stairs", LegacyBlockState { id: 136, data: 0 }); + m.insert("command_block", LegacyBlockState { id: 1, data: 0 }); + m.insert("beacon", LegacyBlockState { id: 138, data: 0 }); + m.insert("cobblestone_wall", LegacyBlockState { id: 139, data: 0 }); + m.insert("flower_pot", LegacyBlockState { id: 140, data: 0 }); + m.insert("carrots", LegacyBlockState { id: 141, data: 0 }); + m.insert("potatoes", LegacyBlockState { id: 142, data: 0 }); + m.insert("oak_button", LegacyBlockState { id: 143, data: 0 }); + m.insert("anvil", LegacyBlockState { id: 145, data: 0 }); + m.insert("trapped_chest", LegacyBlockState { id: 146, data: 0 }); + m.insert("light_weighted_pressure_plate", LegacyBlockState { id: 147, data: 0 }); + m.insert("heavy_weighted_pressure_plate", LegacyBlockState { id: 148, data: 0 }); + m.insert("daylight_detector", LegacyBlockState { id: 151, data: 0 }); + m.insert("redstone_block", LegacyBlockState { id: 152, data: 0 }); + m.insert("quartz_ore", LegacyBlockState { id: 153, data: 0 }); + m.insert("hopper", LegacyBlockState { id: 154, data: 0 }); + m.insert("quartz_block", LegacyBlockState { id: 155, data: 0 }); + m.insert("quartz_stairs", LegacyBlockState { id: 156, data: 0 }); + m.insert("activator_rail", LegacyBlockState { id: 157, data: 0 }); + m.insert("dropper", LegacyBlockState { id: 158, data: 0 }); + m.insert("stained_hardened_clay", LegacyBlockState { id: 159, data: 0 }); + m.insert("stained_glass", LegacyBlockState { id: 95, data: 0 }); + m.insert("stained_glass_pane", LegacyBlockState { id: 160, data: 0 }); + m.insert("leaves2", LegacyBlockState { id: 161, data: 0 }); + m.insert("log2", LegacyBlockState { id: 162, data: 0 }); + m.insert("acacia_stairs", LegacyBlockState { id: 163, data: 0 }); + m.insert("dark_oak_stairs", LegacyBlockState { id: 164, data: 0 }); + m.insert("fern", LegacyBlockState { id: 31, data: 2 }); + m.insert("sugar_cane", LegacyBlockState { id: 83, data: 0 }); + m.insert("pumpkin", LegacyBlockState { id: 86, data: 0 }); + m.insert("carved_pumpkin", LegacyBlockState { id: 86, data: 0 }); + m.insert("nether_quartz_ore", LegacyBlockState { id: 153, data: 0 }); + m.insert("dragon_egg", LegacyBlockState { id: 122, data: 0 }); + m.insert("spawner", LegacyBlockState { id: 52, data: 0 }); + m.insert("tripwire", LegacyBlockState { id: 132, data: 0 }); + m.insert("sponge", LegacyBlockState { id: 19, data: 0 }); + m.insert("wet_sponge", LegacyBlockState { id: 19, data: 0 }); + m.insert("cauldron", LegacyBlockState { id: 118, data: 0 }); + m.insert("water_cauldron", LegacyBlockState { id: 118, data: 3 }); + m.insert("coal_block", LegacyBlockState { id: 173, data: 0 }); + m.insert("mushroom_stem", LegacyBlockState { id: 99, data: 10 }); + m.insert("smooth_stone", LegacyBlockState { id: 1, data: 0 }); + m.insert("infested_stone", LegacyBlockState { id: 1, data: 0 }); + m.insert("infested_stone_bricks", LegacyBlockState { id: 98, data: 0 }); + m.insert("infested_deepslate", LegacyBlockState { id: 1, data: 0 }); + m.insert( + "polished_blackstone_bricks", + LegacyBlockState { id: 98, data: 0 }, + ); + m.insert( + "cracked_polished_blackstone_bricks", + LegacyBlockState { id: 98, data: 2 }, + ); + m.insert("blackstone", LegacyBlockState { id: 4, data: 0 }); + m.insert("quartz_bricks", LegacyBlockState { id: 155, data: 0 }); + m.insert( + "chiseled_quartz_block", + LegacyBlockState { id: 155, data: 1 }, + ); + m.insert( + "chiseled_nether_bricks", + LegacyBlockState { id: 112, data: 0 }, + ); + m.insert("smooth_basalt", LegacyBlockState { id: 1, data: 0 }); + m.insert("pointed_dripstone", LegacyBlockState { id: 1, data: 0 }); + m.insert("lodestone", LegacyBlockState { id: 1, data: 0 }); + m.insert("structure_block", LegacyBlockState { id: 1, data: 0 }); + m.insert("stonecutter", LegacyBlockState { id: 4, data: 0 }); + m.insert("rooted_dirt", LegacyBlockState { id: 3, data: 0 }); + m.insert("moss_block", LegacyBlockState { id: 48, data: 0 }); + m.insert( + "dried_kelp_block", + LegacyBlockState { id: 170, data: 0 }, + ); + m.insert("crimson_stem", LegacyBlockState { id: 17, data: 0 }); + m.insert("warped_stem", LegacyBlockState { id: 17, data: 0 }); + m.insert( + "stripped_crimson_stem", + LegacyBlockState { id: 17, data: 0 }, + ); + m.insert( + "stripped_warped_stem", + LegacyBlockState { id: 17, data: 0 }, + ); + m.insert("azalea", LegacyBlockState { id: 18, data: 0 }); + m.insert("flowering_azalea", LegacyBlockState { id: 18, data: 0 }); + m.insert( + "big_dripleaf", + LegacyBlockState { id: 111, data: 0 }, + ); + m.insert( + "big_dripleaf_stem", + LegacyBlockState { id: 17, data: 0 }, + ); + m.insert("small_dripleaf", LegacyBlockState { id: 0, data: 0 }); + m.insert("hanging_roots", LegacyBlockState { id: 0, data: 0 }); + m.insert("spore_blossom", LegacyBlockState { id: 38, data: 0 }); + m.insert( + "twisting_vines", + LegacyBlockState { id: 106, data: 0 }, + ); + m.insert( + "twisting_vines_plant", + LegacyBlockState { id: 106, data: 0 }, + ); + m.insert("cave_vines", LegacyBlockState { id: 0, data: 0 }); + m.insert( + "cave_vines_plant", + LegacyBlockState { id: 0, data: 0 }, + ); + m.insert("glow_lichen", LegacyBlockState { id: 0, data: 0 }); + m.insert("cornflower", LegacyBlockState { id: 38, data: 0 }); + m.insert( + "lily_of_the_valley", + LegacyBlockState { id: 38, data: 0 }, + ); + m.insert("oxeye_daisy", LegacyBlockState { id: 38, data: 0 }); + m.insert("pink_tulip", LegacyBlockState { id: 38, data: 0 }); + m.insert("brain_coral", LegacyBlockState { id: 38, data: 0 }); + m.insert("bubble_coral", LegacyBlockState { id: 38, data: 0 }); + m.insert("fire_coral", LegacyBlockState { id: 38, data: 0 }); + m.insert("horn_coral", LegacyBlockState { id: 38, data: 0 }); + m.insert("tube_coral", LegacyBlockState { id: 38, data: 0 }); + m.insert( + "horn_coral_fan", + LegacyBlockState { id: 38, data: 0 }, + ); + m.insert( + "tube_coral_fan", + LegacyBlockState { id: 38, data: 0 }, + ); + m.insert("smoker", LegacyBlockState { id: 61, data: 0 }); + m.insert( + "grindstone", + LegacyBlockState { id: 145, data: 0 }, + ); + m.insert("bell", LegacyBlockState { id: 145, data: 0 }); + m.insert("loom", LegacyBlockState { id: 58, data: 0 }); + m.insert( + "smithing_table", + LegacyBlockState { id: 58, data: 0 }, + ); + m.insert( + "target", + LegacyBlockState { id: 70, data: 0 }, + ); + m.insert("chain", LegacyBlockState { id: 101, data: 0 }); + m.insert( + "lightning_rod", + LegacyBlockState { id: 101, data: 0 }, + ); + m.insert("lantern", LegacyBlockState { id: 50, data: 0 }); + m.insert( + "shroomlight", + LegacyBlockState { id: 89, data: 0 }, + ); + m.insert( + "soul_campfire", + LegacyBlockState { id: 51, data: 0 }, + ); + m.insert( + "bee_nest", + LegacyBlockState { id: 54, data: 0 }, + ); + m.insert( + "wither_skeleton_skull", + LegacyBlockState { id: 144, data: 0 }, + ); + m.insert( + "dragon_wall_head", + LegacyBlockState { id: 144, data: 0 }, + ); + m.insert( + "nether_portal", + LegacyBlockState { id: 90, data: 0 }, + ); + m.insert( + "white_candle", + LegacyBlockState { id: 50, data: 0 }, + ); + m.insert( + "orange_candle", + LegacyBlockState { id: 50, data: 0 }, + ); + m.insert( + "gray_candle", + LegacyBlockState { id: 50, data: 0 }, + ); + m.insert( + "cyan_candle", + LegacyBlockState { id: 50, data: 0 }, + ); + m.insert( + "lime_candle", + LegacyBlockState { id: 50, data: 0 }, + ); + m.insert( + "potted_blue_orchid", + LegacyBlockState { id: 140, data: 0 }, + ); + m.insert( + "potted_brown_mushroom", + LegacyBlockState { id: 140, data: 0 }, + ); + m.insert( + "potted_cactus", + LegacyBlockState { id: 140, data: 0 }, + ); + m.insert( + "potted_dandelion", + LegacyBlockState { id: 140, data: 0 }, + ); + m.insert( + "potted_oxeye_daisy", + LegacyBlockState { id: 140, data: 0 }, + ); + m.insert( + "potted_poppy", + LegacyBlockState { id: 140, data: 0 }, + ); + m.insert( + "potted_red_mushroom", + LegacyBlockState { id: 140, data: 0 }, + ); + m.insert( + "powder_snow", + LegacyBlockState { id: 80, data: 0 }, + ); + m.insert( + "copper_ore", + LegacyBlockState { id: 15, data: 0 }, + ); + m.insert( + "oxidized_copper", + LegacyBlockState { id: 15, data: 0 }, + ); + m.insert( + "weathered_copper", + LegacyBlockState { id: 15, data: 0 }, + ); + m.insert( + "raw_copper_block", + LegacyBlockState { id: 42, data: 0 }, + ); + m.insert( + "raw_iron_block", + LegacyBlockState { id: 42, data: 0 }, + ); + m.insert( + "small_amethyst_bud", + LegacyBlockState { id: 20, data: 0 }, + ); + m.insert( + "medium_amethyst_bud", + LegacyBlockState { id: 20, data: 0 }, + ); + m.insert( + "large_amethyst_bud", + LegacyBlockState { id: 20, data: 0 }, + ); + m.insert( + "slime_block", + LegacyBlockState { id: 0, data: 0 }, + ); + m.insert("light", LegacyBlockState { id: 0, data: 0 }); + m.insert( + "scaffolding", + LegacyBlockState { id: 0, data: 0 }, + ); + m.insert( + "powder_snow_cauldron", + LegacyBlockState { id: 118, data: 0 }, + ); + m.insert( + "lava_cauldron", + LegacyBlockState { id: 118, data: 0 }, + ); + m + }); + +static BLOCK_REMAP_TABLE: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| { + let mut m = HashMap::new(); + m.insert(174, 79); + m.insert(175, 0); + m.insert(165, 0); + m.insert(166, 0); + m.insert(167, 96); + m.insert(168, 1); + m.insert(169, 89); + m.insert(176, 0); + m.insert(177, 0); + m.insert(178, 151); + m.insert(179, 24); + m.insert(180, 128); + m.insert(181, 43); + m.insert(182, 44); + m.insert(183, 107); + m.insert(184, 107); + m.insert(185, 107); + m.insert(186, 107); + m.insert(187, 107); + m.insert(188, 85); + m.insert(189, 85); + m.insert(190, 85); + m.insert(191, 85); + m.insert(192, 85); + m.insert(193, 64); + m.insert(194, 64); + m.insert(195, 64); + m.insert(196, 64); + m.insert(197, 64); + m.insert(198, 0); + m.insert(199, 0); + m.insert(200, 0); + m.insert(201, 155); + m.insert(202, 155); + m.insert(203, 156); + m.insert(204, 43); + m.insert(205, 44); + m.insert(206, 121); + m.insert(207, 0); + m.insert(208, 2); + m.insert(209, 0); + m.insert(210, 1); + m.insert(211, 1); + m.insert(212, 79); + m.insert(213, 87); + m.insert(214, 112); + m.insert(215, 112); + m.insert(216, 1); + m.insert(217, 0); + m.insert(218, 0); + for i in 219..=234 { + m.insert(i, 0); + } + for i in 235..=250 { + m.insert(i, 159); + } + m.insert(251, 172); + m.insert(252, 12); + m + }); diff --git a/src-tauri/src/commands/java2lce/chunk.rs b/src-tauri/src/commands/java2lce/chunk.rs new file mode 100644 index 0000000..6582ebb --- /dev/null +++ b/src-tauri/src/commands/java2lce/chunk.rs @@ -0,0 +1,1703 @@ +use std::collections::HashMap; +use super::block_mapping::{self, LegacyBlockState}; +use super::nbt; +use super::payload; +const CHUNK_BLOCKS: usize = 32768; +const CHUNK_NIBBLES: usize = 16384; +const HEIGHTMAP_SIZE: usize = 256; +const BIOMES_SIZE: usize = 256; +const STAIR_UPSIDE_DOWN_BIT: u8 = 4; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JavaChunkFormat { + Unknown, + LegacyBlockArray, + LegacyAnvil, + ModernPalette, + ModernExtendedHeight, +} + +#[derive(Debug, Clone, Copy)] +pub struct JavaChunkFormatInfo { + pub format: JavaChunkFormat, + pub has_level_wrapper: bool, + pub data_version: i32, + pub min_section_y: Option, + pub max_section_y: Option, + pub has_modern_entity_tags: bool, +} + +impl JavaChunkFormatInfo { + pub fn is_section_based(&self) -> bool { + matches!( + self.format, + JavaChunkFormat::LegacyAnvil + | JavaChunkFormat::ModernPalette + | JavaChunkFormat::ModernExtendedHeight + ) + } + + pub fn uses_palette_sections(&self) -> bool { + matches!( + self.format, + JavaChunkFormat::ModernPalette | JavaChunkFormat::ModernExtendedHeight + ) + } + + pub fn uses_modern_content_schema(&self) -> bool { + self.uses_palette_sections() || self.has_modern_entity_tags + } + + pub fn requires_section_shift(&self) -> bool { + self.format == JavaChunkFormat::ModernExtendedHeight + } +} + +pub fn inspect_chunk(root_tag: &nbt::NbtCompound) -> JavaChunkFormatInfo { + let has_level_wrapper = root_tag.get("Level").is_some(); + let source_level = root_tag + .compound("Level") + .unwrap_or(root_tag); + + let data_version = root_tag + .int("DataVersion") + .or_else(|| { + root_tag + .compound("Level") + .and_then(|l| l.int("DataVersion")) + }) + .unwrap_or(0); + + let has_modern_entity_tags = source_level.contains("block_entities") || source_level.contains("entities"); + let has_legacy_block_arrays = source_level.contains("Blocks"); + let sections = source_level + .list("Sections") + .or_else(|| source_level.list("sections")); + + let mut has_sections = false; + let mut has_palette_sections = false; + let mut min_section_y: Option = None; + let mut max_section_y: Option = None; + if let Some(sects) = sections { + has_sections = !sects.is_empty(); + for tag in sects { + if let nbt::NbtValue::Compound(section) = tag { + if let Some(sy) = read_section_y(section) { + min_section_y = Some(min_section_y.map_or(sy, |v| v.min(sy))); + max_section_y = Some(max_section_y.map_or(sy, |v| v.max(sy))); + } + if section_uses_palette_storage(section) { + has_palette_sections = true; + } + } + } + } + + let format = if has_palette_sections { + let has_extended = min_section_y.map_or(false, |v| v < 0) + || max_section_y.map_or(false, |v| v > 15); + if has_extended { + JavaChunkFormat::ModernExtendedHeight + } else { + JavaChunkFormat::ModernPalette + } + } else if has_sections { + JavaChunkFormat::LegacyAnvil + } else if has_legacy_block_arrays { + JavaChunkFormat::LegacyBlockArray + } else { + JavaChunkFormat::Unknown + }; + + JavaChunkFormatInfo { + format, + has_level_wrapper, + data_version, + min_section_y, + max_section_y, + has_modern_entity_tags, + } +} + +pub fn read_section_y(section: &nbt::NbtCompound) -> Option { + if let Some(b) = section.byte("Y") { + return Some(b as i8 as i32); + } + if let Some(i) = section.int("Y") { + return Some(i); + } + if let Some(s) = section.short("Y") { + return Some(s as i32); + } + None +} + +fn section_uses_palette_storage(section: &nbt::NbtCompound) -> bool { + section.contains("block_states") + || section.contains("Palette") + || section.contains("BlockStates") +} + +fn get_byte_array_or_default(compound: &nbt::NbtCompound, name: &str, default_size: usize) -> Vec { + if let Some(bytes) = compound.byte_array(name) { + let mut v = bytes.to_vec(); + if v.len() < default_size { + v.resize(default_size, 0); + } else { + v.truncate(default_size); + } + return v; + } + + if let Some(ints) = compound.int_array(name) { + let mut v = vec![0u8; default_size]; + let len = ints.len().min(default_size); + for (i, &val) in ints.iter().take(len).enumerate() { + v[i] = val.clamp(0, 255) as u8; + } + return v; + } + + vec![0u8; default_size] +} + +struct DecodedSection { + section_y: i32, + blocks: Vec, + data: Vec, + sky_light: Option>, + block_light: Option>, + non_air_count: u32, +} + +fn count_non_air(blocks: &[u8]) -> u32 { + blocks.iter().filter(|&&b| b != 0).count() as u32 +} + +fn try_decode_section_blocks( + section: &nbt::NbtCompound, +) -> Option<(Vec, Vec)> { + if let Some(old_blocks) = section.byte_array("Blocks") { + if old_blocks.len() >= 4096 { + let blocks = old_blocks[..4096].to_vec(); + let data = section + .byte_array("Data") + .map(|d| d.to_vec()) + .unwrap_or_else(|| vec![0u8; CHUNK_NIBBLES]); + return Some((blocks, data)); + } + } + try_decode_palette_section(section) +} + +fn try_decode_palette_section(section: &nbt::NbtCompound) -> Option<(Vec, Vec)> { + let mut blocks = vec![0u8; 4096]; + let mut data = vec![0u8; 2048]; + let block_states_container = section.compound("block_states"); + let palette = section + .list("Palette") + .or_else(|| { + block_states_container + .and_then(|bs| bs.list("palette")) + }); + + let palette = palette?; + if palette.is_empty() { + return None; + } + + if palette.len() == 1 { + if let nbt::NbtValue::Compound(entry) = &palette[0] { + let lb = block_mapping::map_modern_block_state(entry); + blocks.fill(lb.id); + if lb.data != 0 { + for i in 0..4096 { + nbt::set_nibble(&mut data, i, lb.data); + } + } + return Some((blocks, data)); + } + return None; + } + + let block_states_tag = section + .long_array("BlockStates") + .or_else(|| { + block_states_container + .and_then(|bs| bs.long_array("data")) + })?; + + if block_states_tag.is_empty() { + return None; + } + + let bits_per_block = get_bits_required(palette.len() - 1).max(4); + let values_per_long = (64 / bits_per_block).max(1); + let expected_long_count = (4096 + values_per_long - 1) / values_per_long; + let use_padded = block_states_tag.len() == expected_long_count; + for i in 0..4096 { + let palette_index = if use_padded { + read_padded_block_state(block_states_tag, bits_per_block, i) + } else { + read_compact_block_state(block_states_tag, bits_per_block, i) + }; + + if (palette_index as usize) >= palette.len() { + continue; + } + + if let nbt::NbtValue::Compound(entry) = &palette[palette_index] { + let legacy = block_mapping::map_modern_block_state(entry); + blocks[i] = legacy.id; + if legacy.data != 0 { + nbt::set_nibble(&mut data, i, legacy.data); + } + } + } + + Some((blocks, data)) +} + +fn get_bits_required(value: usize) -> usize { + let mut bits = 0; + let mut v = value; + while v > 0 { + bits += 1; + v >>= 1; + } + bits.max(1) +} + +fn read_padded_block_state(block_states: &[i64], bits_per_block: usize, index: usize) -> usize { + let values_per_long = (64 / bits_per_block).max(1); + let long_index = index / values_per_long; + let bit_offset = (index % values_per_long) * bits_per_block; + let mask = (1u64 << bits_per_block) - 1; + ((block_states[long_index] as u64 >> bit_offset) & mask) as usize +} + +fn read_compact_block_state(block_states: &[i64], bits_per_block: usize, index: usize) -> usize { + let start_bit = index * bits_per_block; + let long_index = start_bit >> 6; + let bit_offset = start_bit & 63; + let mask = (1u64 << bits_per_block) - 1; + let mut value = block_states[long_index] as u64 >> bit_offset; + let bits_read = 64 - bit_offset; + if bits_read < bits_per_block && long_index + 1 < block_states.len() { + value |= (block_states[long_index + 1] as u64) << bits_read; + } + (value & mask) as usize +} + +fn flatten_anvil_sections( + level: &nbt::NbtCompound, + format_info: &JavaChunkFormatInfo, + global_section_shift: &mut Option, +) -> (Vec, Vec, Vec, Vec) { + let mut blocks = vec![0u8; CHUNK_BLOCKS]; + let mut data = vec![0u8; CHUNK_NIBBLES]; + let mut sky_light = vec![0xFFu8; CHUNK_NIBBLES]; + let mut block_light = vec![0u8; CHUNK_NIBBLES]; + let sections = level + .list("Sections") + .or_else(|| level.list("sections")); + + let sections = match sections { + Some(s) => s, + None => return (blocks, data, sky_light, block_light), + }; + + let mut decoded_sections: Vec = Vec::new(); + for tag in sections { + if let nbt::NbtValue::Compound(section) = tag { + let section_y = match read_section_y(section) { + Some(y) => y, + None => continue, + }; + + let (s_blocks, s_data) = match try_decode_section_blocks(section) { + Some(v) => v, + None => continue, + }; + + let s_sky = section + .byte_array("SkyLight") + .or_else(|| section.byte_array("sky_light")) + .map(|b| b.to_vec()); + let s_block = section + .byte_array("BlockLight") + .or_else(|| section.byte_array("block_light")) + .map(|b| b.to_vec()); + + let non_air = count_non_air(&s_blocks); + decoded_sections.push(DecodedSection { + section_y, + blocks: s_blocks, + data: s_data, + sky_light: s_sky, + block_light: s_block, + non_air_count: non_air, + }); + } + } + + if decoded_sections.is_empty() { + return (blocks, data, sky_light, block_light); + } + + let mut section_shift = 0; + if format_info.requires_section_shift() { + if global_section_shift.is_none() { + let anchor = decoded_sections + .iter() + .max_by_key(|s| (s.non_air_count, -(s.section_y - 4).abs())) + .map(|s| s.section_y) + .unwrap_or(4); + *global_section_shift = Some(anchor - 4); + } + section_shift = global_section_shift.unwrap_or(0); + } + + for section in &decoded_sections { + let remapped_y = section.section_y - section_shift; + if remapped_y < 0 || remapped_y > 7 { + continue; + } + + let base_y = remapped_y * 16; + for i in 0..4096 { + let x = i & 0x0F; + let z = (i >> 4) & 0x0F; + let y = (i >> 8) & 0x0F; + let global_y = base_y + y as i32; + if global_y < 0 || global_y >= 128 { + continue; + } + let flat_index = ((x * 16) + z) * 128 + global_y as usize; + if flat_index < CHUNK_BLOCKS { + blocks[flat_index] = section.blocks[i]; + if section.data.len() > i { + nbt::set_nibble(&mut data, flat_index, nbt::get_nibble(§ion.data, i)); + } + if let Some(ref sky) = section.sky_light { + if sky.len() > i { + nbt::set_nibble(&mut sky_light, flat_index, nbt::get_nibble(sky, i)); + } + } + if let Some(ref bl) = section.block_light { + if bl.len() > i { + nbt::set_nibble(&mut block_light, flat_index, nbt::get_nibble(bl, i)); + } + } + } + } + } + + (blocks, data, sky_light, block_light) +} + +pub fn convert_chunk( + root_tag: &nbt::NbtCompound, + new_chunk_x: i32, + new_chunk_z: i32, + preserve_dynamic: bool, + global_section_shift: &mut Option, +) -> Vec { + let level = + build_legacy_chunk_level(root_tag, new_chunk_x, new_chunk_z, preserve_dynamic, global_section_shift); + payload::encode_legacy_nbt(&level) +} + +pub fn convert_chunk_for_save( + root_tag: &nbt::NbtCompound, + new_chunk_x: i32, + new_chunk_z: i32, + preserve_dynamic: bool, + global_section_shift: &mut Option, +) -> Vec { + let level = build_legacy_chunk_level(root_tag, new_chunk_x, new_chunk_z, preserve_dynamic, global_section_shift); + payload::encode_compressed_storage(&level) +} + +fn build_legacy_chunk_level( + root_tag: &nbt::NbtCompound, + new_chunk_x: i32, + new_chunk_z: i32, + preserve_dynamic: bool, + global_section_shift: &mut Option, +) -> nbt::NbtCompound { + let format_info = inspect_chunk(root_tag); + let source_level = root_tag + .compound("Level") + .unwrap_or(root_tag); + let is_modern = format_info.uses_modern_content_schema(); + let (mut blocks, mut data, mut sky_light, mut block_light) = + if format_info.is_section_based() { + flatten_anvil_sections(source_level, &format_info, global_section_shift) + } else { + let b = get_byte_array_or_default(source_level, "Blocks", CHUNK_BLOCKS); + let d = get_byte_array_or_default(source_level, "Data", CHUNK_NIBBLES); + let s = get_byte_array_or_default(source_level, "SkyLight", CHUNK_NIBBLES); + let bl = get_byte_array_or_default(source_level, "BlockLight", CHUNK_NIBBLES); + (b, d, s, bl) + }; + + let height_map = get_byte_array_or_default(source_level, "HeightMap", HEIGHTMAP_SIZE); + let mut biomes = get_byte_array_or_default(source_level, "Biomes", BIOMES_SIZE); + if is_modern { + biomes.fill(1); + } + + block_mapping::remap_blocks(&mut blocks, &mut data); + let last_update = source_level.long("LastUpdate").unwrap_or(0); + let inhabited_time = source_level.long("InhabitedTime").unwrap_or(0); + const TERRAIN_POPULATED_FLAGS: i16 = 0; + if sky_light.iter().all(|&b| b == 0) { + sky_light.fill(0xFF); + } + let mut level = nbt::NbtCompound::new("Level"); + level.insert("xPos", nbt::NbtValue::Int(new_chunk_x)); + level.insert("zPos", nbt::NbtValue::Int(new_chunk_z)); + level.insert("LastUpdate", nbt::NbtValue::Long(last_update)); + level.insert("InhabitedTime", nbt::NbtValue::Long(inhabited_time)); + level.insert("Blocks", nbt::NbtValue::ByteArray(blocks)); + level.insert("Data", nbt::NbtValue::ByteArray(data)); + level.insert("SkyLight", nbt::NbtValue::ByteArray(sky_light)); + level.insert("BlockLight", nbt::NbtValue::ByteArray(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 source_chunk_x = source_level.int("xPos").unwrap_or(new_chunk_x); + let source_chunk_z = source_level.int("zPos").unwrap_or(new_chunk_z); + let block_offset_x = (source_chunk_x - new_chunk_x) * 16; + let block_offset_z = (source_chunk_z - new_chunk_z) * 16; + if preserve_dynamic && !is_modern { + let mut entities: Vec = source_level + .list("Entities") + .map(|l| l.to_vec()) + .unwrap_or_default(); + remap_entity_positions(&mut entities, block_offset_x, block_offset_z); + level.insert("Entities", nbt::NbtValue::List(entities)); + } else { + level.insert("Entities", nbt::NbtValue::List(Vec::new())); + } + + let mut tile_entities = if preserve_dynamic && !is_modern { + build_compatible_tile_entities(source_level, false) + } else { + build_safe_sign_tile_entities(source_level, is_modern) + }; + remove_unsupported_tile_entities(&mut tile_entities); + remap_tile_entity_positions(&mut tile_entities, block_offset_x, block_offset_z); + level.insert("TileEntities", nbt::NbtValue::List(tile_entities)); + if preserve_dynamic && !is_modern { + if let Some(ticks) = source_level.list("TileTicks") { + let remapped = remap_tile_tick_positions(ticks, block_offset_x, block_offset_z); + level.insert("TileTicks", nbt::NbtValue::List(remapped)); + } else if let Some(ticks) = source_level.list("block_ticks") { + let remapped = remap_tile_tick_positions(ticks, block_offset_x, block_offset_z); + level.insert("TileTicks", nbt::NbtValue::List(remapped)); + } + } + + level +} + +fn build_compatible_tile_entities( + source_level: &nbt::NbtCompound, + is_modern: bool, +) -> Vec { + if !is_modern { + let tiles = source_level.list("TileEntities"); + if let Some(t) = tiles { + return t.to_vec(); + } + let tiles2 = source_level.list("block_entities"); + if let Some(t) = tiles2 { + return t.to_vec(); + } + return Vec::new(); + } + build_safe_sign_tile_entities(source_level, true) +} + +fn build_safe_sign_tile_entities( + source_level: &nbt::NbtCompound, + is_modern: bool, +) -> Vec { + let mut result = Vec::new(); + let source = if is_modern { + source_level + .list("block_entities") + .or_else(|| source_level.list("TileEntities")) + } else { + source_level + .list("TileEntities") + .or_else(|| source_level.list("block_entities")) + }; + + let source = match source { + Some(s) => s, + None => return result, + }; + + for tag in source { + if let nbt::NbtValue::Compound(block_entity) = tag { + let id = block_entity.string("id").unwrap_or(""); + let is_sign = if is_modern { + is_modern_sign_tile_entity(id) + } else { + id == "Sign" || id == "minecraft:sign" + }; + if !is_sign { + continue; + } + let sign = build_safe_legacy_sign_tile_entity(block_entity, is_modern); + result.push(nbt::NbtValue::Compound(sign)); + } + } + + result +} + +fn is_modern_sign_tile_entity(id: &str) -> bool { + let stripped = id.strip_prefix("minecraft:").unwrap_or(id); + matches!( + stripped, + "sign" + | "hanging_sign" + | "oak_sign" + | "spruce_sign" + | "birch_sign" + | "jungle_sign" + | "acacia_sign" + | "dark_oak_sign" + | "mangrove_sign" + | "cherry_sign" + | "bamboo_sign" + | "crimson_sign" + | "warped_sign" + | "oak_hanging_sign" + | "spruce_hanging_sign" + | "birch_hanging_sign" + | "jungle_hanging_sign" + | "acacia_hanging_sign" + | "dark_oak_hanging_sign" + | "mangrove_hanging_sign" + | "cherry_hanging_sign" + | "bamboo_hanging_sign" + | "crimson_hanging_sign" + | "warped_hanging_sign" + ) +} + +fn read_tile_entity_coord(compound: &nbt::NbtCompound, name: &str) -> i32 { + compound.int(name) + .map(|v| v as i32) + .or_else(|| compound.short(name).map(|v| v as i32)) + .or_else(|| compound.byte(name).map(|v| v as i32)) + .unwrap_or(0) +} + +fn extract_modern_sign_lines(block_entity: &nbt::NbtCompound) -> [String; 4] { + let mut lines: [String; 4] = Default::default(); + if let Some(front_text) = block_entity.compound("front_text") { + if let Some(messages) = front_text.list("messages") { + for i in 0..4.min(messages.len()) { + if let nbt::NbtValue::String(s) = &messages[i] { + lines[i] = sanitize_legacy_sign_line(&simplify_sign_text(s)); + } + } + return lines; + } + } + + for i in 0..4 { + let key = format!("Text{}", i + 1); + let value = block_entity.string(&key).unwrap_or(""); + lines[i] = sanitize_legacy_sign_line(&simplify_sign_text(value)); + } + + lines +} + +fn extract_legacy_sign_lines(block_entity: &nbt::NbtCompound) -> [String; 4] { + let mut lines: [String; 4] = Default::default(); + for i in 0..4 { + let key = format!("Text{}", i + 1); + let value = block_entity.string(&key).unwrap_or(""); + lines[i] = sanitize_legacy_sign_line(&simplify_sign_text(value)); + } + lines +} + +fn sanitize_legacy_sign_line(raw: &str) -> String { + let mut builder = String::new(); + for ch in raw.chars() { + if ch == '\r' || ch == '\n' || ch == '\t' || ch == '\0' { + continue; + } + if ch.is_control() { + continue; + } + builder.push(ch); + if builder.len() >= 15 { + break; + } + } + builder +} + +fn simplify_sign_text(raw: &str) -> String { + let raw = raw.trim(); + if !(raw.starts_with('{') || raw.starts_with('[') || raw.starts_with('"')) { + return raw.to_string(); + } + + if let Ok(doc) = serde_json::from_str::(raw) { + let mut builder = String::new(); + append_json_text(&doc, &mut builder); + return builder; + } + + raw.trim_matches('"').to_string() +} + +fn append_json_text(element: &serde_json::Value, builder: &mut String) { + match element { + serde_json::Value::String(s) => builder.push_str(s), + serde_json::Value::Array(arr) => { + for item in arr { + append_json_text(item, builder); + } + } + serde_json::Value::Object(obj) => { + if let Some(serde_json::Value::String(text)) = obj.get("text") { + builder.push_str(text); + } + if let Some(extra) = obj.get("extra") { + append_json_text(extra, builder); + } + } + _ => {} + } +} + +fn build_safe_legacy_sign_tile_entity( + block_entity: &nbt::NbtCompound, + is_modern: bool, +) -> nbt::NbtCompound { + let x = read_tile_entity_coord(block_entity, "x"); + let y = read_tile_entity_coord(block_entity, "y"); + let z = read_tile_entity_coord(block_entity, "z"); + let lines = if is_modern { + extract_modern_sign_lines(block_entity) + } else { + extract_legacy_sign_lines(block_entity) + }; + + let mut sign = nbt::NbtCompound::new(""); + sign.insert("id", nbt::NbtValue::String("Sign".to_string())); + sign.insert("x", nbt::NbtValue::Int(x)); + sign.insert("y", nbt::NbtValue::Int(y)); + sign.insert("z", nbt::NbtValue::Int(z)); + sign.insert("Text1", nbt::NbtValue::String(lines[0].clone())); + sign.insert("Text2", nbt::NbtValue::String(lines[1].clone())); + sign.insert("Text3", nbt::NbtValue::String(lines[2].clone())); + sign.insert("Text4", nbt::NbtValue::String(lines[3].clone())); + sign +} + +fn remap_tile_tick_positions( + ticks: &[nbt::NbtValue], + block_offset_x: i32, + block_offset_z: i32, +) -> Vec { + if block_offset_x == 0 && block_offset_z == 0 { + return ticks.to_vec(); + } + let mut result = Vec::new(); + for tag in ticks { + if let nbt::NbtValue::Compound(mut tick) = tag.clone() { + if let Some(nbt::NbtValue::Int(x)) = tick.get_mut("x") { + *x -= block_offset_x; + } + if let Some(nbt::NbtValue::Int(z)) = tick.get_mut("z") { + *z -= block_offset_z; + } + result.push(nbt::NbtValue::Compound(tick)); + } + } + result +} + +fn remap_entity_positions(entities: &mut Vec, block_offset_x: i32, block_offset_z: i32) { + if block_offset_x == 0 && block_offset_z == 0 { + return; + } + for tag in entities.iter_mut() { + if let nbt::NbtValue::Compound(entity) = tag { + if let Some(nbt::NbtValue::List(pos)) = entity.get_mut("Pos") { + if pos.len() >= 3 { + if let nbt::NbtValue::Double(x) = &mut pos[0] { + *x -= block_offset_x as f64; + } + if let nbt::NbtValue::Double(z) = &mut pos[2] { + *z -= block_offset_z as f64; + } + } + } + if let Some(nbt::NbtValue::Compound(riding)) = entity.get_mut("Riding") { + let mut inner = vec![nbt::NbtValue::Compound(riding.clone())]; + remap_entity_positions(&mut inner, block_offset_x, block_offset_z); + if let Some(nbt::NbtValue::Compound(updated)) = inner.into_iter().next() { + *riding = updated; + } + } + } + } +} + +fn remove_unsupported_tile_entities(tile_entities: &mut Vec) { + tile_entities.retain(|tag| { + if let nbt::NbtValue::Compound(te) = tag { + let id = te.string("id").unwrap_or(""); + return id != "Control" && id != "minecraft:command_block" && id != "CommandBlock"; + } + false + }); +} + +fn remap_tile_entity_positions(tile_entities: &mut Vec, block_offset_x: i32, block_offset_z: i32) { + if block_offset_x == 0 && block_offset_z == 0 { + return; + } + for tag in tile_entities.iter_mut() { + if let nbt::NbtValue::Compound(te) = tag { + if let Some(nbt::NbtValue::Int(x)) = te.get_mut("x") { + *x -= block_offset_x; + } + if let Some(nbt::NbtValue::Int(z)) = te.get_mut("z") { + *z -= block_offset_z; + } + } + } +} + +pub fn build_modern_anvil_level( + legacy_level: &nbt::NbtCompound, + chunk_x: i32, + chunk_z: i32, +) -> nbt::NbtCompound { + let default_blocks = vec![0u8; 32768]; + let default_data = vec![0u8; 16384]; + let old_blocks_ref = legacy_level + .byte_array("Blocks") + .unwrap_or(&default_blocks); + let old_data_ref = legacy_level + .byte_array("Data") + .unwrap_or(&default_data); + + let mut section_tags: Vec = Vec::new(); + for section_y in 0..8 { + let mut palette_list: Vec = Vec::new(); + let mut palette_dict: HashMap = HashMap::new(); + let mut indices = [0i32; 4096]; + let mut has_non_air = false; + for y in 0..16 { + for z in 0..16 { + for x in 0..16 { + let global_y = section_y * 16 + y; + let legacy_flat_index = ((x * 16) + z) * 128 + global_y; + if legacy_flat_index >= old_blocks_ref.len() { + continue; + } + let block_id = old_blocks_ref[legacy_flat_index]; + let meta = nbt::get_nibble(old_data_ref, legacy_flat_index); + let modern_state = get_contextual_block_state(block_id, meta, x, global_y, z, old_blocks_ref, old_data_ref); + if modern_state != "minecraft:air" { + has_non_air = true; + } + + let idx = *palette_dict + .entry(modern_state.clone()) + .or_insert_with(|| { + let idx = palette_list.len(); + palette_list.push(modern_state); + idx + }); + + indices[y * 256 + z * 16 + x] = idx as i32; + } + } + } + + if !has_non_air && section_y > 0 { + continue; + } + + let mut section_tag = nbt::NbtCompound::new(""); + section_tag.insert("Y", nbt::NbtValue::Byte(section_y as i8)); + let mut block_states_tag = nbt::NbtCompound::new("block_states"); + let mut nbt_palette: Vec = Vec::new(); + for state in &palette_list { + let mut p_tag = nbt::NbtCompound::new(""); + if let Some(bracket_idx) = state.find('[') { + let name = &state[..bracket_idx]; + let props_str = &state[bracket_idx + 1..state.len() - 1]; + p_tag.insert("Name", nbt::NbtValue::String(name.to_string())); + let mut props_compound = nbt::NbtCompound::new("Properties"); + for prop in props_str.split(',') { + let kv: Vec<&str> = prop.splitn(2, '=').collect(); + if kv.len() == 2 { + props_compound + .insert(kv[0], nbt::NbtValue::String(kv[1].to_string())); + } + } + p_tag.insert("Properties", nbt::NbtValue::Compound(props_compound)); + } else { + p_tag.insert("Name", nbt::NbtValue::String(state.to_string())); + } + nbt_palette.push(nbt::NbtValue::Compound(p_tag)); + } + block_states_tag.insert("palette", nbt::NbtValue::List(nbt_palette)); + if palette_list.len() > 1 { + let bits_per_block = (64.0 / (palette_list.len() as f64).log2().ceil()).floor().max(4.0) as usize; + let values_per_long = 64 / bits_per_block; + let long_count = ((4096.0 / values_per_long as f64).ceil()) as usize; + let mut data_array = vec![0i64; long_count]; + let mut current_long: u64 = 0; + let mut blocks_in_current_long = 0; + let mut long_index = 0; + for i in 0..4096 { + let val = indices[i] as u64; + current_long |= val << (blocks_in_current_long * bits_per_block); + blocks_in_current_long += 1; + if blocks_in_current_long >= values_per_long { + data_array[long_index] = current_long as i64; + long_index += 1; + current_long = 0; + blocks_in_current_long = 0; + } + } + + if blocks_in_current_long > 0 { + data_array[long_index] = current_long as i64; + } + + block_states_tag.insert("data", nbt::NbtValue::LongArray(data_array)); + } + + section_tag.insert("block_states", nbt::NbtValue::Compound(block_states_tag)); + let mut biomes_tag = nbt::NbtCompound::new("biomes"); + let biome_palette = nbt::NbtValue::List(vec![nbt::NbtValue::String( + "minecraft:plains".to_string(), + )]); + biomes_tag.insert("palette", biome_palette); + section_tag.insert("biomes", nbt::NbtValue::Compound(biomes_tag)); + section_tags.push(nbt::NbtValue::Compound(section_tag)); + } + + let mut root = nbt::NbtCompound::new(""); + root.insert("xPos", nbt::NbtValue::Int(chunk_x)); + root.insert("zPos", nbt::NbtValue::Int(chunk_z)); + root.insert("yPos", nbt::NbtValue::Int(0)); + root.insert("Status", nbt::NbtValue::String("full".to_string())); + let mut block_entities: Vec = Vec::new(); + if let Some(old_te_list) = legacy_level.list("TileEntities") { + for old_te in old_te_list { + if let nbt::NbtValue::Compound(old_te_comp) = old_te { + let mut new_te = old_te_comp.clone(); + let id = new_te.string("id").unwrap_or("").to_string(); + let new_id = match id.as_str() { + "Chest" => Some("minecraft:chest"), + "Furnace" => Some("minecraft:furnace"), + "BrewingStand" => Some("minecraft:brewing_stand"), + "EnchantTable" => Some("minecraft:enchanting_table"), + "Trap" => Some("minecraft:dispenser"), + "MobSpawner" => Some("minecraft:mob_spawner"), + "Control" => Some("minecraft:command_block"), + "Beacon" => Some("minecraft:beacon"), + "Skull" => Some("minecraft:skull"), + "Sign" => Some("minecraft:sign"), + "Cauldron" => Some("minecraft:cauldron"), + "Dropper" => Some("minecraft:dropper"), + "Hopper" => Some("minecraft:hopper"), + "Comparator" => Some("minecraft:comparator"), + "RecordPlayer" => Some("minecraft:jukebox"), + "Banner" => Some("minecraft:banner"), + _ if id.starts_with("minecraft:") => Some(id.as_str()), + _ => None, + }; + + if let Some(nid) = new_id { + new_te.insert("id", nbt::NbtValue::String(nid.to_string())); + block_entities.push(nbt::NbtValue::Compound(new_te)); + } + } + } + } + + for i in 0..old_blocks_ref.len().min(32768) { + if old_blocks_ref[i] == 26 { + let y = i % 128; + let z = (i / 128) % 16; + let x = i / 2048; + let mut bed_te = nbt::NbtCompound::new(""); + bed_te.insert("id", nbt::NbtValue::String("minecraft:bed".to_string())); + bed_te.insert("color", nbt::NbtValue::Int(14)); + bed_te.insert("x", nbt::NbtValue::Int(chunk_x * 16 + x as i32)); + bed_te.insert("y", nbt::NbtValue::Int(y as i32)); + bed_te.insert("z", nbt::NbtValue::Int(chunk_z * 16 + z as i32)); + block_entities.push(nbt::NbtValue::Compound(bed_te)); + } + } + + root.insert( + "block_entities", + nbt::NbtValue::List(block_entities), + ); + root.insert("sections", nbt::NbtValue::List(section_tags)); + root +} + +fn get_contextual_block_state( + block_id: u8, + meta: u8, + x: usize, + global_y: usize, + z: usize, + old_blocks: &[u8], + old_data: &[u8], +) -> String { + let modern_state = get_modern_block_name(block_id, meta); + let get_neighbor = |nx: i32, ny: i32, nz: i32| -> (u8, u8) { + if nx < 0 || nx > 15 || ny < 0 || ny > 127 || nz < 0 || nz > 15 { + return (0, 0); + } + let idx = ((nx as usize * 16) + nz as usize) * 128 + ny as usize; + if idx >= old_blocks.len() { + return (0, 0); + } + let n_id = old_blocks[idx]; + let n_meta = nbt::get_nibble(old_data, idx); + (n_id, n_meta) + }; + + let bracket_index = modern_state.find('['); + let name = if let Some(bi) = bracket_index { + &modern_state[..bi] + } else { + &modern_state + }; + let mut properties: HashMap = HashMap::new(); + if let Some(bi) = bracket_index { + let props_str = &modern_state[bi + 1..modern_state.len() - 1]; + for prop in props_str.split(',') { + let kv: Vec<&str> = prop.splitn(2, '=').collect(); + if kv.len() == 2 { + properties.insert(kv[0].to_string(), kv[1].to_string()); + } + } + } + + let mut properties_changed = false; + if block_id == 64 || block_id == 71 || (block_id >= 193 && block_id <= 197) { + let is_top = (meta & 8) == 8; + properties.insert("half".to_string(), if is_top { "upper" } else { "lower" }.to_string()); + if is_top { + properties.insert( + "hinge".to_string(), + if (meta & 1) == 1 { "right" } else { "left" }.to_string(), + ); + let (bottom_id, bottom_meta) = get_neighbor(x as i32, global_y as i32 - 1, z as i32); + if bottom_id == block_id { + let facing_meta = bottom_meta & 3; + properties.insert( + "facing".to_string(), + match facing_meta { + 0 => "east", + 1 => "south", + 2 => "west", + _ => "north", + } + .to_string(), + ); + properties.insert( + "open".to_string(), + if (bottom_meta & 4) == 4 { "true" } else { "false" }.to_string(), + ); + } else { + properties.insert("facing".to_string(), "east".to_string()); + properties.insert("open".to_string(), "false".to_string()); + } + } else { + let facing_meta = meta & 3; + properties.insert( + "facing".to_string(), + match facing_meta { + 0 => "east", + 1 => "south", + 2 => "west", + _ => "north", + } + .to_string(), + ); + properties.insert( + "open".to_string(), + if (meta & 4) == 4 { "true" } else { "false" }.to_string(), + ); + let (top_id, top_meta) = get_neighbor(x as i32, global_y as i32 + 1, z as i32); + properties.insert( + "hinge".to_string(), + if top_id == block_id { + if (top_meta & 1) == 1 { "right" } else { "left" } + } else { + "left" + } + .to_string(), + ); + } + properties_changed = true; + } else if block_id == 63 || block_id == 68 { + if block_id == 68 { + properties.insert( + "facing".to_string(), + match meta { + 2 => "north", + 3 => "south", + 4 => "west", + _ => "east", + } + .to_string(), + ); + } else { + properties.insert("rotation".to_string(), meta.to_string()); + } + properties.remove("waterlogged"); + properties_changed = true; + } else if block_id == 50 || block_id == 75 || block_id == 76 { + if meta >= 1 && meta <= 4 { + properties.insert( + "facing".to_string(), + match meta { + 1 => "east", + 2 => "west", + 3 => "south", + _ => "north", + } + .to_string(), + ); + } + if block_id == 76 { + properties.insert("lit".to_string(), "true".to_string()); + } else if block_id == 75 { + properties.insert("lit".to_string(), "false".to_string()); + } + properties_changed = true; + } else if block_id == 54 || block_id == 146 { + let facing = match meta { + 2 => "north", + 3 => "south", + 4 => "west", + 5 => "east", + _ => "south", + }; + properties.insert("type".to_string(), "single".to_string()); + properties.insert("facing".to_string(), facing.to_string()); + let dirs = [(-1i32, 0i32), (1, 0), (0, -1), (0, 1)]; + for (dx, dz) in dirs { + let (nid, _) = get_neighbor(x as i32 + dx, global_y as i32, z as i32 + dz); + if nid == block_id { + let is_left = match facing { + "north" => dx == -1, + "south" => dx == 1, + "west" => dz == 1, + "east" => dz == -1, + _ => false, + }; + properties.insert( + "type".to_string(), + if is_left { "right" } else { "left" }.to_string(), + ); + break; + } + } + properties_changed = true; + } else if is_stair_legacy(block_id) { + let facing = get_stair_facing(meta); + let half = if (meta & STAIR_UPSIDE_DOWN_BIT) != 0 { + "top" + } else { + "bottom" + }; + properties.insert("facing".to_string(), facing.to_string()); + properties.insert("half".to_string(), half.to_string()); + properties.insert("shape".to_string(), "straight".to_string()); + let (front_dx, front_dz) = match facing { + "north" => (0i32, 1i32), + "south" => (0, -1), + "west" => (1, 0), + "east" => (-1, 0), + _ => (0, 0), + }; + let (back_dx, back_dz) = match facing { + "north" => (0i32, -1i32), + "south" => (0, 1), + "west" => (-1, 0), + "east" => (1, 0), + _ => (0, 0), + }; + + let back_neighbor = get_neighbor(x as i32 + back_dx, global_y as i32, z as i32 + back_dz); + if is_stair_legacy(back_neighbor.0) { + let back_facing = get_stair_facing(back_neighbor.1); + let back_half = if (back_neighbor.1 & STAIR_UPSIDE_DOWN_BIT) != 0 { + "top" + } else { + "bottom" + }; + if back_half == half { + if let Some(side) = get_relative_side(facing, &back_facing) { + properties.insert("shape".to_string(), format!("outer_{}", side)); + } + } + } else { + let front_neighbor = get_neighbor(x as i32 + front_dx, global_y as i32, z as i32 + front_dz); + if is_stair_legacy(front_neighbor.0) { + let front_facing = get_stair_facing(front_neighbor.1); + let front_half = if (front_neighbor.1 & STAIR_UPSIDE_DOWN_BIT) != 0 { + "top" + } else { + "bottom" + }; + if front_half == half { + if let Some(side) = get_relative_side(facing, &front_facing) { + properties.insert("shape".to_string(), format!("inner_{}", side)); + } + } + } + } + properties_changed = true; + } else if is_fence_legacy(block_id) { + let (n_id, _) = get_neighbor(x as i32, global_y as i32, z as i32 - 1); + let (e_id, _) = get_neighbor(x as i32 + 1, global_y as i32, z as i32); + let (s_id, _) = get_neighbor(x as i32, global_y as i32, z as i32 + 1); + let (w_id, _) = get_neighbor(x as i32 - 1, global_y as i32, z as i32); + properties.insert( + "north".to_string(), + (is_fence_legacy(n_id) || is_fence_gate_legacy(n_id) || is_likely_solid_attachment(n_id)) + .to_string(), + ); + properties.insert( + "east".to_string(), + (is_fence_legacy(e_id) || is_fence_gate_legacy(e_id) || is_likely_solid_attachment(e_id)) + .to_string(), + ); + properties.insert( + "south".to_string(), + (is_fence_legacy(s_id) || is_fence_gate_legacy(s_id) || is_likely_solid_attachment(s_id)) + .to_string(), + ); + properties.insert( + "west".to_string(), + (is_fence_legacy(w_id) || is_fence_gate_legacy(w_id) || is_likely_solid_attachment(w_id)) + .to_string(), + ); + properties_changed = true; + } else if is_pane_legacy(block_id) { + let (n_id, _) = get_neighbor(x as i32, global_y as i32, z as i32 - 1); + let (e_id, _) = get_neighbor(x as i32 + 1, global_y as i32, z as i32); + let (s_id, _) = get_neighbor(x as i32, global_y as i32, z as i32 + 1); + let (w_id, _) = get_neighbor(x as i32 - 1, global_y as i32, z as i32); + properties.insert( + "north".to_string(), + (is_pane_legacy(n_id) || is_glass_legacy(n_id) || is_likely_solid_attachment(n_id)) + .to_string(), + ); + properties.insert( + "east".to_string(), + (is_pane_legacy(e_id) || is_glass_legacy(e_id) || is_likely_solid_attachment(e_id)) + .to_string(), + ); + properties.insert( + "south".to_string(), + (is_pane_legacy(s_id) || is_glass_legacy(s_id) || is_likely_solid_attachment(s_id)) + .to_string(), + ); + properties.insert( + "west".to_string(), + (is_pane_legacy(w_id) || is_glass_legacy(w_id) || is_likely_solid_attachment(w_id)) + .to_string(), + ); + properties_changed = true; + } + + if properties_changed || name == "minecraft:red_bed" { + if !properties.is_empty() { + let parts: Vec = properties.iter().map(|(k, v)| format!("{}={}", k, v)).collect(); + return format!("{}[{}]", name, parts.join(",")); + } + return name.to_string(); + } + + modern_state +} + +fn get_modern_block_name(block_id: u8, meta: u8) -> String { + let name = get_contextual_modern_name(block_id, meta); + if let Some(props) = get_contextual_properties(block_id, meta) { + format!("{}[{}]", name, props) + } else { + name + } +} + +fn get_contextual_modern_name(block_id: u8, meta: u8) -> String { + match block_id { + 0 => "minecraft:air".into(), + 1 => "minecraft:stone".into(), + 2 => "minecraft:grass_block".into(), + 3 => "minecraft:dirt".into(), + 4 => "minecraft:cobblestone".into(), + 5 => match meta { + 1 => "minecraft:spruce_planks", + 2 => "minecraft:birch_planks", + 3 => "minecraft:jungle_planks", + _ => "minecraft:oak_planks", + } + .into(), + 6 => match meta { + 1 => "minecraft:spruce_sapling", + 2 => "minecraft:birch_sapling", + 3 => "minecraft:jungle_sapling", + _ => "minecraft:oak_sapling", + } + .into(), + 7 => "minecraft:bedrock".into(), + 8 | 9 => "minecraft:water".into(), + 10 | 11 => "minecraft:lava".into(), + 12 => "minecraft:sand".into(), + 13 => "minecraft:gravel".into(), + 14 => "minecraft:gold_ore".into(), + 15 => "minecraft:iron_ore".into(), + 16 => "minecraft:coal_ore".into(), + 17 => match meta & 3 { + 1 => "minecraft:spruce_log", + 2 => "minecraft:birch_log", + 3 => "minecraft:jungle_log", + _ => "minecraft:oak_log", + } + .into(), + 18 => match meta & 3 { + 1 => "minecraft:spruce_leaves", + 2 => "minecraft:birch_leaves", + 3 => "minecraft:jungle_leaves", + _ => "minecraft:oak_leaves", + } + .into(), + 19 => "minecraft:sponge".into(), + 20 => "minecraft:glass".into(), + 21 => "minecraft:lapis_ore".into(), + 22 => "minecraft:lapis_block".into(), + 23 => "minecraft:dispenser".into(), + 24 => match meta { + 1 => "minecraft:chiseled_sandstone", + 2 => "minecraft:smooth_sandstone", + _ => "minecraft:sandstone", + } + .into(), + 25 => "minecraft:note_block".into(), + 26 => "minecraft:red_bed".into(), + 27 => "minecraft:powered_rail".into(), + 28 => "minecraft:detector_rail".into(), + 29 => "minecraft:sticky_piston".into(), + 30 => "minecraft:cobweb".into(), + 31 => "minecraft:short_grass".into(), + 32 => "minecraft:dead_bush".into(), + 33 => "minecraft:piston".into(), + 35 => match meta { + 1 => "minecraft:orange_wool", + 2 => "minecraft:magenta_wool", + 3 => "minecraft:light_blue_wool", + 4 => "minecraft:yellow_wool", + 5 => "minecraft:lime_wool", + 6 => "minecraft:pink_wool", + 7 => "minecraft:gray_wool", + 8 => "minecraft:light_gray_wool", + 9 => "minecraft:cyan_wool", + 10 => "minecraft:purple_wool", + 11 => "minecraft:blue_wool", + 12 => "minecraft:brown_wool", + 13 => "minecraft:green_wool", + 14 => "minecraft:red_wool", + 15 => "minecraft:black_wool", + _ => "minecraft:white_wool", + } + .into(), + 37 => "minecraft:dandelion".into(), + 38 => "minecraft:poppy".into(), + 39 => "minecraft:brown_mushroom".into(), + 40 => "minecraft:red_mushroom".into(), + 41 => "minecraft:gold_block".into(), + 42 => "minecraft:iron_block".into(), + 44 => { + let variant = meta & 7; + let name = match variant { + 1 => "minecraft:sandstone_slab", + 2 => "minecraft:stone_slab", + 3 => "minecraft:cobblestone_slab", + 4 => "minecraft:brick_slab", + 5 => "minecraft:stone_brick_slab", + 6 => "minecraft:nether_brick_slab", + 7 => "minecraft:quartz_slab", + _ => "minecraft:stone_slab", + }; + return name.to_string(); + } + 45 => "minecraft:bricks".into(), + 46 => "minecraft:tnt".into(), + 47 => "minecraft:bookshelf".into(), + 48 => "minecraft:mossy_cobblestone".into(), + 49 => "minecraft:obsidian".into(), + 50 => "minecraft:torch".into(), + 51 => "minecraft:fire".into(), + 52 => "minecraft:mob_spawner".into(), + 53 => "minecraft:oak_stairs".into(), + 54 => "minecraft:chest".into(), + 55 => "minecraft:redstone_wire".into(), + 56 => "minecraft:diamond_ore".into(), + 57 => "minecraft:diamond_block".into(), + 58 => "minecraft:crafting_table".into(), + 59 => "minecraft:wheat".into(), + 60 => "minecraft:farmland".into(), + 61 => "minecraft:furnace".into(), + 63 => "minecraft:oak_sign".into(), + 64 => "minecraft:oak_door".into(), + 65 => "minecraft:ladder".into(), + 66 => "minecraft:rail".into(), + 67 => "minecraft:cobblestone_stairs".into(), + 68 => "minecraft:oak_wall_sign".into(), + 69 => "minecraft:lever".into(), + 70 => "minecraft:stone_pressure_plate".into(), + 71 => "minecraft:iron_door".into(), + 72 => "minecraft:oak_pressure_plate".into(), + 73 => "minecraft:redstone_ore".into(), + 75 => "minecraft:redstone_wall_torch".into(), + 76 => "minecraft:redstone_torch".into(), + 77 => "minecraft:stone_button".into(), + 78 => "minecraft:snow".into(), + 79 => "minecraft:ice".into(), + 80 => "minecraft:snow_block".into(), + 81 => "minecraft:cactus".into(), + 82 => "minecraft:clay".into(), + 83 => "minecraft:sugar_cane".into(), + 84 => "minecraft:jukebox".into(), + 85 => "minecraft:oak_fence".into(), + 86 => "minecraft:carved_pumpkin".into(), + 87 => "minecraft:netherrack".into(), + 88 => "minecraft:soul_sand".into(), + 89 => "minecraft:glowstone".into(), + 90 => "minecraft:nether_portal".into(), + 91 => "minecraft:jack_o_lantern".into(), + 92 => "minecraft:cake".into(), + 93 => "minecraft:unpowered_repeater".into(), + 94 => "minecraft:powered_repeater".into(), + 96 => "minecraft:trapdoor".into(), + 98 => "minecraft:stone_bricks".into(), + 101 => "minecraft:iron_bars".into(), + 102 => "minecraft:glass_pane".into(), + 103 => "minecraft:melon".into(), + 104 => "minecraft:pumpkin_stem".into(), + 105 => "minecraft:melon_stem".into(), + 106 => "minecraft:vine".into(), + 107 => "minecraft:oak_fence_gate".into(), + 108 => "minecraft:brick_stairs".into(), + 109 => "minecraft:stone_brick_stairs".into(), + 110 => "minecraft:mycelium".into(), + 111 => "minecraft:lily_pad".into(), + 112 => "minecraft:nether_bricks".into(), + 113 => "minecraft:nether_brick_fence".into(), + 114 => "minecraft:nether_brick_stairs".into(), + 115 => "minecraft:nether_wart".into(), + 116 => "minecraft:enchanting_table".into(), + 117 => "minecraft:brewing_stand".into(), + 118 => "minecraft:cauldron".into(), + 121 => "minecraft:end_stone".into(), + 122 => "minecraft:dragon_egg".into(), + 123 => "minecraft:redstone_lamp".into(), + 124 => "minecraft:redstone_lamp".into(), + 125 => "minecraft:double_wooden_slab".into(), + 126 => "minecraft:wooden_slab".into(), + 127 => "minecraft:cocoa".into(), + 128 => "minecraft:sandstone_stairs".into(), + 129 => "minecraft:emerald_ore".into(), + 130 => "minecraft:ender_chest".into(), + 131 => "minecraft:tripwire_hook".into(), + 132 => "minecraft:tripwire".into(), + 133 => "minecraft:emerald_block".into(), + 134 => "minecraft:spruce_stairs".into(), + 135 => "minecraft:birch_stairs".into(), + 136 => "minecraft:jungle_stairs".into(), + 138 => "minecraft:beacon".into(), + 139 => "minecraft:cobblestone_wall".into(), + 140 => "minecraft:flower_pot".into(), + 141 => "minecraft:carrots".into(), + 142 => "minecraft:potatoes".into(), + 143 => "minecraft:wooden_button".into(), + 144 => "minecraft:skull".into(), + 145 => "minecraft:anvil".into(), + 146 => "minecraft:trapped_chest".into(), + 147 => "minecraft:light_weighted_pressure_plate".into(), + 148 => "minecraft:heavy_weighted_pressure_plate".into(), + 149 => "minecraft:unpowered_comparator".into(), + 150 => "minecraft:powered_comparator".into(), + 151 => "minecraft:daylight_detector".into(), + 152 => "minecraft:redstone_block".into(), + 153 => "minecraft:nether_quartz_ore".into(), + 154 => "minecraft:hopper".into(), + 155 => match meta { + 1 => "minecraft:chiseled_quartz_block", + 2 => "minecraft:quartz_pillar", + 3 => "minecraft:quartz_pillar", + _ => "minecraft:quartz_block", + } + .into(), + 156 => "minecraft:quartz_stairs".into(), + 157 => "minecraft:activator_rail".into(), + 158 => "minecraft:dropper".into(), + 159 => match meta { + 0 => "minecraft:white_stained_hardened_clay", + 1 => "minecraft:orange_stained_hardened_clay", + 2 => "minecraft:magenta_stained_hardened_clay", + 3 => "minecraft:light_blue_stained_hardened_clay", + 4 => "minecraft:yellow_stained_hardened_clay", + 5 => "minecraft:lime_stained_hardened_clay", + 6 => "minecraft:pink_stained_hardened_clay", + 7 => "minecraft:gray_stained_hardened_clay", + 8 => "minecraft:light_gray_stained_hardened_clay", + 9 => "minecraft:cyan_stained_hardened_clay", + 10 => "minecraft:purple_stained_hardened_clay", + 11 => "minecraft:blue_stained_hardened_clay", + 12 => "minecraft:brown_stained_hardened_clay", + 13 => "minecraft:green_stained_hardened_clay", + 14 => "minecraft:red_stained_hardened_clay", + 15 => "minecraft:black_stained_hardened_clay", + _ => "minecraft:white_stained_hardened_clay", + } + .into(), + 160 => match meta { + 0 => "minecraft:white_stained_glass_pane", + 1 => "minecraft:orange_stained_glass_pane", + 2 => "minecraft:magenta_stained_glass_pane", + 3 => "minecraft:light_blue_stained_glass_pane", + 4 => "minecraft:yellow_stained_glass_pane", + 5 => "minecraft:lime_stained_glass_pane", + 6 => "minecraft:pink_stained_glass_pane", + 7 => "minecraft:gray_stained_glass_pane", + 8 => "minecraft:light_gray_stained_glass_pane", + 9 => "minecraft:cyan_stained_glass_pane", + 10 => "minecraft:purple_stained_glass_pane", + 11 => "minecraft:blue_stained_glass_pane", + 12 => "minecraft:brown_stained_glass_pane", + 13 => "minecraft:green_stained_glass_pane", + 14 => "minecraft:red_stained_glass_pane", + 15 => "minecraft:black_stained_glass_pane", + _ => "minecraft:white_stained_glass_pane", + } + .into(), + 161 => match meta & 3 { + 1 => "minecraft:acacia_leaves", + _ => "minecraft:dark_oak_leaves", + } + .into(), + 162 => match meta & 3 { + 1 => "minecraft:acacia_log", + _ => "minecraft:dark_oak_log", + } + .into(), + 163 => "minecraft:acacia_stairs".into(), + 164 => "minecraft:dark_oak_stairs".into(), + 170 => "minecraft:hay_block".into(), + 171 => match meta { + 0 => "minecraft:white_carpet", + 1 => "minecraft:orange_carpet", + 2 => "minecraft:magenta_carpet", + 3 => "minecraft:light_blue_carpet", + 4 => "minecraft:yellow_carpet", + 5 => "minecraft:lime_carpet", + 6 => "minecraft:pink_carpet", + 7 => "minecraft:gray_carpet", + 8 => "minecraft:light_gray_carpet", + 9 => "minecraft:cyan_carpet", + 10 => "minecraft:purple_carpet", + 11 => "minecraft:blue_carpet", + 12 => "minecraft:brown_carpet", + 13 => "minecraft:green_carpet", + 14 => "minecraft:red_carpet", + 15 => "minecraft:black_carpet", + _ => "minecraft:white_carpet", + } + .into(), + 172 => match meta { + 0 => "minecraft:white_concrete", + 1 => "minecraft:orange_concrete", + 2 => "minecraft:magenta_concrete", + 3 => "minecraft:light_blue_concrete", + 4 => "minecraft:yellow_concrete", + 5 => "minecraft:lime_concrete", + 6 => "minecraft:pink_concrete", + 7 => "minecraft:gray_concrete", + 8 => "minecraft:light_gray_concrete", + 9 => "minecraft:cyan_concrete", + 10 => "minecraft:purple_concrete", + 11 => "minecraft:blue_concrete", + 12 => "minecraft:brown_concrete", + 13 => "minecraft:green_concrete", + 14 => "minecraft:red_concrete", + 15 => "minecraft:black_concrete", + _ => "minecraft:white_concrete", + } + .into(), + 173 => "minecraft:coal_block".into(), + 95 => match meta { + 0 => "minecraft:white_stained_glass", + 1 => "minecraft:orange_stained_glass", + 2 => "minecraft:magenta_stained_glass", + 3 => "minecraft:light_blue_stained_glass", + 4 => "minecraft:yellow_stained_glass", + 5 => "minecraft:lime_stained_glass", + 6 => "minecraft:pink_stained_glass", + 7 => "minecraft:gray_stained_glass", + 8 => "minecraft:light_gray_stained_glass", + 9 => "minecraft:cyan_stained_glass", + 10 => "minecraft:purple_stained_glass", + 11 => "minecraft:blue_stained_glass", + 12 => "minecraft:brown_stained_glass", + 13 => "minecraft:green_stained_glass", + 14 => "minecraft:red_stained_glass", + 15 => "minecraft:black_stained_glass", + _ => "minecraft:white_stained_glass", + } + .into(), + _ => "minecraft:air".into(), + } +} + +fn get_contextual_properties(block_id: u8, meta: u8) -> Option { + match block_id { + 63 => Some(format!("rotation={}", meta & 0x0F)), + 68 => Some(format!( + "facing={}", + match meta { + 2 => "north", + 3 => "south", + 4 => "west", + _ => "east", + } + )), + 50 | 75 | 76 => { + if meta >= 1 && meta <= 4 { + Some(format!( + "facing={}", + match meta { + 1 => "east", + 2 => "west", + 3 => "south", + _ => "north", + } + )) + } else { + None + } + } + _ => None, + } +} + +fn is_stair_legacy(id: u8) -> bool { + matches!( + id, + 53 | 67 | 108 | 109 | 114 | 128 | 134 | 135 | 136 | 156 | 163 | 164 | 180 + ) +} + +fn get_stair_facing(meta: u8) -> &'static str { + match meta & 0x3 { + 0 => "east", + 1 => "west", + 2 => "south", + 3 => "north", + _ => "north", + } +} + +fn get_relative_side(current_facing: &str, neighbor_facing: &str) -> Option<&'static str> { + match current_facing { + "north" => { + if neighbor_facing == "west" { + return Some("left"); + } + if neighbor_facing == "east" { + return Some("right"); + } + } + "south" => { + if neighbor_facing == "east" { + return Some("left"); + } + if neighbor_facing == "west" { + return Some("right"); + } + } + "west" => { + if neighbor_facing == "south" { + return Some("left"); + } + if neighbor_facing == "north" { + return Some("right"); + } + } + "east" => { + if neighbor_facing == "north" { + return Some("left"); + } + if neighbor_facing == "south" { + return Some("right"); + } + } + _ => {} + } + None +} + +fn is_fence_legacy(id: u8) -> bool { + matches!(id, 85 | 113 | 188 | 189 | 190 | 191 | 192) +} + +fn is_fence_gate_legacy(id: u8) -> bool { + matches!(id, 107 | 183 | 184 | 185 | 186 | 187) +} + +fn is_pane_legacy(id: u8) -> bool { + matches!(id, 101 | 102 | 160) +} + +fn is_glass_legacy(id: u8) -> bool { + matches!(id, 20 | 95) +} + +fn is_likely_solid_attachment(id: u8) -> bool { + if id == 0 { + return false; + } + if id >= 8 && id <= 11 { + return false; + } + !matches!( + id, + 6 | 27 | 28 | 30 | 31 | 32 | 37 | 38 | 39 | 40 | 50 | 51 | 55 | 59 | 63 | 65 | 66 + | 68 | 69 | 70 | 71 | 72 | 75 | 76 | 77 | 78 | 83 | 90 | 92 | 93 | 94 | 96 | 101 + | 102 | 104 | 105 | 106 | 107 | 111 | 115 | 119 | 127 | 131 | 132 | 141 | 142 | 143 + | 147 | 148 | 149 | 150 | 157 | 160 | 171 | 175 + ) +} diff --git a/src-tauri/src/commands/java2lce/level_dat.rs b/src-tauri/src/commands/java2lce/level_dat.rs new file mode 100644 index 0000000..2455b74 --- /dev/null +++ b/src-tauri/src/commands/java2lce/level_dat.rs @@ -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, +) -> Vec { + 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, + override_spawn_y: Option, + override_spawn_z: Option, + embedded_player: Option<&NbtCompound>, +) -> Vec { + 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 +} diff --git a/src-tauri/src/commands/java2lce/mod.rs b/src-tauri/src/commands/java2lce/mod.rs new file mode 100644 index 0000000..b64c5fd --- /dev/null +++ b/src-tauri/src/commands/java2lce/mod.rs @@ -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 { + 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, + errors: &mut Vec, +) -> 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 { + 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 = None; + let mut errors: Vec = 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 { + 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)> = 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 = Vec::new(); + let mut region_writers: HashMap = 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) +} diff --git a/src-tauri/src/commands/java2lce/nbt.rs b/src-tauri/src/commands/java2lce/nbt.rs new file mode 100644 index 0000000..bc0324f --- /dev/null +++ b/src-tauri/src/commands/java2lce/nbt.rs @@ -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), + String(String), + List(Vec), + Compound(NbtCompound), + IntArray(Vec), + LongArray(Vec), +} + +#[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 { + self.get(name).and_then(|v| match v { + NbtValue::Byte(b) => Some(*b), + _ => None, + }) + } + + pub fn short(&self, name: &str) -> Option { + self.get(name).and_then(|v| match v { + NbtValue::Short(s) => Some(*s), + _ => None, + }) + } + + pub fn int(&self, name: &str) -> Option { + self.get(name).and_then(|v| match v { + NbtValue::Int(i) => Some(*i), + _ => None, + }) + } + + pub fn long(&self, name: &str) -> Option { + self.get(name).and_then(|v| match v { + NbtValue::Long(l) => Some(*l), + _ => None, + }) + } + + pub fn float(&self, name: &str) -> Option { + self.get(name).and_then(|v| match v { + NbtValue::Float(f) => Some(*f), + _ => None, + }) + } + + pub fn double(&self, name: &str) -> Option { + 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 { + 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 { + 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, 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, 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + compound + .list(name) + .map(|l| l.to_vec()) + .unwrap_or_default() +} diff --git a/src-tauri/src/commands/java2lce/payload.rs b/src-tauri/src/commands/java2lce/payload.rs new file mode 100644 index 0000000..5238467 --- /dev/null +++ b/src-tauri/src/commands/java2lce/payload.rs @@ -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, v: i16) { + out.extend_from_slice(&v.to_be_bytes()); +} + +fn write_be_i32(out: &mut Vec, v: i32) { + out.extend_from_slice(&v.to_be_bytes()); +} + +fn write_be_i64(out: &mut Vec, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, 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) { + 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, 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::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, 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 { + 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 { + 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, 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, 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 { + 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> { + 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 { + 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 { + 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) +} diff --git a/src-tauri/src/commands/java2lce/region.rs b/src-tauri/src/commands/java2lce/region.rs new file mode 100644 index 0000000..2c09d21 --- /dev/null +++ b/src-tauri/src/commands/java2lce/region.rs @@ -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, +} + +pub struct JavaRegionReader { + pub data: Vec, + 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 { + 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::(), parts[2].parse::()) + { + 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, 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 { + 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>, 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, + 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, + 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) { + 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 { + 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, + blob: Vec, + 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 = (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 = 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 { + 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 { + compress_zlib(data) +} + +pub fn decompress_zlib(data: &[u8]) -> Result, 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, String> { + let rle_data = decompress_zlib(data)?; + Ok(rle_decode(&rle_data, decompressed_size)) +} + +pub fn rle_encode(data: &[u8]) -> Vec { + 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 { + 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 +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 38b63f4..e6ca211 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod dlc; pub mod download; pub mod file_dialogs; pub mod game; +pub mod java2lce; pub mod macos_setup; pub mod plugins; pub mod proxy_cmd; diff --git a/src-tauri/src/commands/workshop.rs b/src-tauri/src/commands/workshop.rs index 876f9a4..af37514 100644 --- a/src-tauri/src/commands/workshop.rs +++ b/src-tauri/src/commands/workshop.rs @@ -1,9 +1,176 @@ use std::fs; +use std::io::Write; +use std::path::Path; use std::path::PathBuf; -use tauri::AppHandle; +use tauri::{AppHandle, Emitter}; use crate::types::{WorkshopInstallRequest, InstalledWorkshopPackage, InstalledPackageEntry}; use crate::config; use crate::util; +use super::download::DownloadProgress; +fn group_archives(zips: &std::collections::HashMap) -> Vec<(Vec, String)> { + let mut groups: Vec<(Vec, String)> = Vec::new(); + let mut consumed: std::collections::HashSet = 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 { + let mut downloaded: Vec = 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, 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 = 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::().ok()?; + parts.next()?; + parts.next()?; + Some(parts.collect::>().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 = 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] pub async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -> Result<(), String> { 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); 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 _ = fs::remove_dir_all(&tmp_dir); fs::create_dir_all(&tmp_dir).map_err(|e| e.to_string())?; let mut pkg_dirs: Vec = Vec::new(); - for (zip_name, placeholder) in &request.zips { - let zip_url = format!("{}/{}", raw_base, zip_name); - let zip_tmp = tmp_dir.join(zip_name); - 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 total_parts = request.zips.len(); + let mut done_parts = 0usize; + for (parts, placeholder) in group_archives(&request.zips) { let dest_dir = if placeholder.is_empty() { instance_dir.clone() } else { - let resolved = instance_dir.clone().join(placeholder + instance_dir.clone().join(placeholder .replace("{MediaDir}", media_dir.to_str().unwrap_or("")) .replace("{DLCDir}", dlc_dir.to_str().unwrap_or("")) .replace("{GameHDD}", game_hdd.to_str().unwrap_or("")) - .replace("{MobDir}", mob_dir.to_str().unwrap_or(""))); - PathBuf::from(resolved) + .replace("{MobDir}", mob_dir.to_str().unwrap_or(""))) }; fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?; - let (extracted_files, extract_ok) = if cfg!(target_os = "linux") { - let bsdtar_list = std::process::Command::new("bsdtar") - .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 = 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 = listing.lines() - .filter_map(|l| { - let mut parts = l.trim().split_whitespace(); - let size_str = parts.next()?; - size_str.parse::().ok()?; - parts.next()?; - parts.next()?; - Some(parts.collect::>().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 = 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()) - }; - + let archive_tmp = download_and_assemble(&app, &tmp_dir, &parts, &raw_base, &request.package_id, &mut done_parts, total_parts).await?; + let (extracted_files, extract_ok) = extract_archive(&archive_tmp, &dest_dir)?; if !extract_ok { 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 { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e2852c6..5887f2b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod workshop_server; mod commands; use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use tokio::sync::Mutex; use tauri::{Emitter, Manager}; use commands::config as config_cmds; @@ -54,6 +55,7 @@ pub fn run() { .manage(GameState { child: Arc::new(Mutex::new(None)), workshop_cancel: Arc::new(Mutex::new(None)), + manual_stop: Arc::new(AtomicBool::new(false)), }) .manage(ProxyGuard { cancel_tokens: Arc::new(Mutex::new(HashMap::new())), @@ -108,6 +110,9 @@ pub fn run() { game::backup_instance, game::restore_instance, commands::console2lce::import_world, + commands::console2lce::import_lce_save, + commands::java2lce::java_to_lce, + commands::java2lce::lce_to_java, stun::stun_discover, relay::start_relay_proxy, relay::start_host_relay, diff --git a/src-tauri/src/networking/relay.rs b/src-tauri/src/networking/relay.rs index f4773c5..1046cf0 100644 --- a/src-tauri/src/networking/relay.rs +++ b/src-tauri/src/networking/relay.rs @@ -1,8 +1,6 @@ use tauri::State; -use tauri::webview::cookie::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; -use tokio::time::sleep; use tokio_util::sync::CancellationToken; use crate::state::ProxyGuard; const PROXY_ADDR: &str = "proxy.mclegacyedition.xyz:2052"; //neo: yeah bro im hardcoding it diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index cc35004..a95f219 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; pub struct DownloadState { @@ -9,6 +10,7 @@ pub struct DownloadState { pub struct GameState { pub child: Arc>>, pub workshop_cancel: Arc>>, + pub manual_stop: Arc, } pub struct ProxyGuard { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7b9e841..92cd3bb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "LCE Emerald Launcher", - "version": "1.5.1", + "version": "1.6.0", "identifier": "com.emerald.legacy", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tauri.nightly.conf.json b/src-tauri/tauri.nightly.conf.json index cedd617..0ceb534 100644 --- a/src-tauri/tauri.nightly.conf.json +++ b/src-tauri/tauri.nightly.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Emerald Legacy Launcher Nightly", - "version": "1.5.1-nightly", + "version": "1.6.0-nightly", "identifier": "com.emerald.legacy.nightly", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/common/CapePreview.tsx b/src/components/common/CapePreview.tsx new file mode 100644 index 0000000..9b57f99 --- /dev/null +++ b/src/components/common/CapePreview.tsx @@ -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(null); + const groupRef = useRef(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 ( +
+ ); +}); + +export default CapePreview; diff --git a/src/components/modals/GameLogModal.tsx b/src/components/modals/GameLogModal.tsx new file mode 100644 index 0000000..04b3f10 --- /dev/null +++ b/src/components/modals/GameLogModal.tsx @@ -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 ( + + ); +} + +export default function GameLogModal({ + isOpen, + log, + onClose, + playBackSound, +}: { + isOpen: boolean; + log: string | null; + onClose: () => void; + playBackSound: () => void; +}) { + const scrollRef = useRef(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 ( + { + playBackSound(); + onClose(); + }} + > + e.stopPropagation()} + className="flex flex-col w-[640px] max-w-[92vw] max-h-[85vh] p-5 font-['Mojangles'] mc-options-bg" + > +

+ Game Crash Log +

+ + {truncated && ( +

+ Showing last {MAX_DISPLAY_LINES} lines of {lines.length}. +

+ )} + +
+
+ {visibleLines.map((line, i) => ( + + {line} + {"\n"} + + ))} +
+
+ +
+ + Save As File + + + Copy + + { + playBackSound(); + onClose(); + }} + className="flex-1" + > + Close + +
+ {feedback && ( +

+ {feedback} +

+ )} +
+
+ ); +} diff --git a/src/components/modals/ImportWorldModal.tsx b/src/components/modals/ImportWorldModal.tsx index 363053e..b6d2435 100644 --- a/src/components/modals/ImportWorldModal.tsx +++ b/src/components/modals/ImportWorldModal.tsx @@ -1,7 +1,6 @@ import { useState, useEffect } from "react"; import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; - export default function ImportWorldModal({ isOpen, onClose, @@ -20,7 +19,6 @@ export default function ImportWorldModal({ const [status, setStatus] = useState(""); const [error, setError] = useState(""); const [isImporting, setIsImporting] = useState(false); - useEffect(() => { if (!isOpen) { setStatus(""); @@ -29,19 +27,18 @@ export default function ImportWorldModal({ } }, [isOpen]); - const handleImport = async () => { + const handleImportMs = async () => { if (!targetInstanceId) return; playPressSound(); setIsImporting(true); setError(""); setStatus("Selecting source..."); - try { - setStatus("Selecting LCE save folder or .ms file..."); - const picked = await TauriService.pickFile( - "Select saveData.ms or GameHDD folder", - ["*.ms", "*"], - ); + setStatus("Selecting saveData.ms file..."); + const picked = await TauriService.pickFile("Select saveData.ms", [ + "*.ms", + "*", + ]); if (!picked) { setIsImporting(false); 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) => { if (e.key === "Escape") { playBackSound(); @@ -104,9 +224,77 @@ export default function ImportWorldModal({ Import into:{" "} {targetInstanceName}

-

- 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 = () => {

)} -
+
diff --git a/src/components/views/LocEditorView.tsx b/src/components/views/LocEditorView.tsx index 84c74cf..33cbaf7 100644 --- a/src/components/views/LocEditorView.tsx +++ b/src/components/views/LocEditorView.tsx @@ -103,7 +103,7 @@ export default function LocEditorView() { animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} 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" >
@@ -201,10 +201,10 @@ export default function LocEditorView() {
)} -
+
+
+
+ - -
+ ? "url('/images/button_highlighted.png')" + : "url('/images/Button_Background.png')", + backgroundSize: "100% 100%", + imageRendering: "pixelated", + }} + > + {viewMode === "skin" ? "Delete Skin" : "Delete Cape"} +
@@ -551,7 +581,7 @@ const SkinsView = memo(function SkinsView() { />
-
+
{viewMode === "skin" ? ( savedSkins.map((skin, i) => { const idx = SKINS_START_INDEX + i; @@ -583,21 +613,7 @@ const SkinsView = memo(function SkinsView() { 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]"}`} > - {skin.name} +
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]"}`} > - {cape.name} +

@@ -308,7 +308,7 @@ export default function SwfView() {

)} @@ -1963,10 +2027,37 @@ function InstallModal({ "idle" | "installing" | "success" | "error" >("idle"); const [errorMsg, setErrorMsg] = useState(null); + const [progress, setProgress] = useState(0); + const [progressLabel, setProgressLabel] = useState(null); + const [pkgPct, setPkgPct] = useState>({}); + + 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 () => { setStatus("installing"); setErrorMsg(null); + setProgress(0); + setProgressLabel(null); playPressSound(); try { const pluginsDir = await TauriService.getPluginsDir(); @@ -1994,7 +2085,10 @@ function InstallModal({ const pluginBaseUrl = `${RAW_BASE}/.00plugins/${pkg.id}`; 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( "GET", `${pluginBaseUrl}/${file}`, @@ -2006,6 +2100,7 @@ function InstallModal({ `${pluginDir}/${file}`, encoder.encode(res.body), ); + setProgress(Math.round(((i + 1) / allFiles.length) * 100)); } await PluginManager.instance.reload(); @@ -2063,6 +2158,7 @@ function InstallModal({ for (const depId of dependencies) { const depPkg = allPackages.find((p) => p.id === depId); if (!depPkg || !depPkg.zips) continue; + setProgressLabel(depPkg.name); try { await TauriService.workshopInstall( instanceId, @@ -2079,11 +2175,18 @@ function InstallModal({ const installTo = async (instanceId: string) => { setStatus("installing"); setErrorMsg(null); + setProgress(0); + setProgressLabel(null); + setPkgPct({}); playPressSound(); + const unlisten = await TauriService.onWorkshopProgress((data) => { + setPkgPct((prev) => ({ ...prev, [data.packageId]: data.percent })); + }); try { if (dependencies.length > 0) { await installDeps(instanceId); } + setProgressLabel(pkg.name); await TauriService.workshopInstall( instanceId, pkg.id, @@ -2101,6 +2204,8 @@ function InstallModal({ ? e : "Unknown error", ); + } finally { + unlisten(); } }; @@ -2153,6 +2258,25 @@ function InstallModal({ ? "Downloading and extracting required dependencies" : "Downloading and extracting assets"} +
+
+ + {progressLabel || + (isPluginTab ? "Downloading plugin files" : "Downloading assets")} + + + {Math.floor(isPluginTab ? progress : overallProgress)}% + +
+
+
+
+
{dependencies.length > 0 && (
{dependencies.map((depId) => { diff --git a/src/context/LauncherContext.tsx b/src/context/LauncherContext.tsx index 7f59e7f..0e0a7b8 100644 --- a/src/context/LauncherContext.tsx +++ b/src/context/LauncherContext.tsx @@ -48,6 +48,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) { setProfile: configRaw.setProfile, customEditions: configRaw.customEditions, setCustomEditions: configRaw.setCustomEditions, + customPaths: configRaw.customPaths, + setCustomPaths: configRaw.setCustomPaths, customizations: configRaw.customizations, setCustomizations: configRaw.setCustomizations, extraLaunchArgs: configRaw.extraLaunchArgs, @@ -64,6 +66,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) { configRaw.username, configRaw.theme, configRaw.layout, configRaw.vfxEnabled, configRaw.rpcEnabled, configRaw.musicVol, configRaw.sfxVol, configRaw.isDayTime, configRaw.profile, configRaw.linuxRunner, configRaw.perfBoost, configRaw.customEditions, + configRaw.customPaths, configRaw.customizations, configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled, 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.deleteCustomEdition, gameRaw.downloadRunner, gameRaw.customizations, gameRaw.updateCustomization, + gameRaw.gameLog, gameRaw.clearGameLog, ]); const audio = useMemo(() => audioRaw, [ @@ -126,6 +130,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) { appleSiliconPerformanceBoost: config.perfBoost, profile: config.profile, customEditions: config.customEditions, + customPaths: config.customPaths, customizations: config.customizations, animationsEnabled: config.animationsEnabled, vfxEnabled: config.vfxEnabled, @@ -144,6 +149,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) { }, [ config.username, skinSync.skinBase64, config.theme, config.linuxRunner, config.perfBoost, config.customEditions, config.profile, + config.customPaths, config.customizations, config.vfxEnabled, config.animationsEnabled, config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode, config.mangohudEnabled, config.extraLaunchArgs, config.launchPrefix, diff --git a/src/css/index.css b/src/css/index.css index 0a54cd6..b9ebacf 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -230,6 +230,14 @@ body { scrollbar-color: rgba(255, 255, 255, 0.15) transparent; } +.hidden-scrollbar { + scrollbar-width: none; +} + +.hidden-scrollbar::-webkit-scrollbar { + display: none; +} + .no-animations * { transition: none !important; animation: none !important; diff --git a/src/hooks/useAppConfig.ts b/src/hooks/useAppConfig.ts index 20b6195..ec75e68 100644 --- a/src/hooks/useAppConfig.ts +++ b/src/hooks/useAppConfig.ts @@ -19,6 +19,7 @@ export function useAppConfig() { const [linuxRunner, setLinuxRunner] = useState(); const [perfBoost, setPerfBoost] = useState(false); const [customEditions, setCustomEditions] = useState([]); + const [customPaths, setCustomPaths] = useState>({}); const [customizations, setCustomizations] = useState>({}); const [mangohudEnabled, setMangohudEnabled] = useState(false); const [extraLaunchArgs, setExtraLaunchArgs] = useState(); @@ -33,6 +34,7 @@ export function useAppConfig() { if (config.appleSiliconPerformanceBoost !== undefined) setPerfBoost(config.appleSiliconPerformanceBoost); if (config.customEditions) setCustomEditions(config.customEditions); + if (config.customPaths) setCustomPaths(config.customPaths); if (config.customizations) setCustomizations(config.customizations); if (config.profile) setProfile(config.profile); if (config.vfxEnabled !== undefined) setVfxEnabled(config.vfxEnabled); @@ -60,6 +62,7 @@ export function useAppConfig() { appleSiliconPerformanceBoost: perfBoost, profile, customEditions, + customPaths, customizations, animationsEnabled, vfxEnabled, @@ -75,7 +78,7 @@ export function useAppConfig() { skipIntro, }).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 { username, @@ -108,6 +111,8 @@ export function useAppConfig() { setPerfBoost, customEditions, setCustomEditions, + customPaths, + setCustomPaths, customizations, setCustomizations, isLoaded, diff --git a/src/hooks/useGameManager.ts b/src/hooks/useGameManager.ts index 793a2d6..7185811 100644 --- a/src/hooks/useGameManager.ts +++ b/src/hooks/useGameManager.ts @@ -71,7 +71,7 @@ export const BASE_EDITIONS = [ id: "moon_edition", name: "Minecraft: Moon Edition", 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", supportsSlimSkins: false, logo: "/images/moonEdition.png", @@ -113,6 +113,8 @@ interface GameManagerProps { setProfile: (id: string) => void; customEditions: CustomEdition[]; setCustomEditions: (editions: CustomEdition[]) => void; + customPaths: Record; + setCustomPaths: Dispatch>>; customizations: Record; setCustomizations: Dispatch< SetStateAction> @@ -142,6 +144,7 @@ export function useGameManager({ setProfile, customEditions, setCustomEditions, + setCustomPaths, customizations, setCustomizations, extraLaunchArgs, @@ -157,6 +160,8 @@ export function useGameManager({ number | null >(null); const [error, setError] = useState(null); + const [gameLog, setGameLog] = useState(null); + const gameLogRef = useRef(false); const [gameUpdateMessage, setGameUpdateMessage] = useState( null, ); @@ -392,11 +397,18 @@ export function useGameManager({ const unlistenRetry = TauriService.onDownloadRetry((attempt) => { setError(`Download failed, retrying (${attempt}/3)...`); }); + const unlistenGameLog = TauriService.onGameLog((log) => { + gameLogRef.current = true; + setError(null); + setGameLog(log); + getCurrentWindow().unminimize(); + }); return () => { unlistenDownload.then((u) => u()); unlistenRunner.then((u) => u()); unlistenError.then((u) => u()); unlistenRetry.then((u) => u()); + unlistenGameLog.then((u) => u()); }; }, [customEditions, checkInstalls]); @@ -513,13 +525,15 @@ export function useGameManager({ ); } catch (e: unknown) { console.error(e); - setError( - e instanceof Error - ? e.message - : typeof e === "string" - ? e - : "Failed to launch game", - ); + if (!gameLogRef.current) { + setError( + e instanceof Error + ? e.message + : typeof e === "string" + ? e + : "Failed to launch game", + ); + } } finally { setIsGameRunning(false); } @@ -597,10 +611,16 @@ export function useGameManager({ [instanceId]: path, }; await TauriService.saveConfig(config); + setCustomPaths((prev) => ({ ...prev, [instanceId]: path })); }, - [], + [setCustomPaths], ); + const clearGameLog = useCallback(() => { + gameLogRef.current = false; + setGameLog(null); + }, []); + const addToSteam = useCallback( async ( id: string, @@ -638,6 +658,8 @@ export function useGameManager({ runnerDownloadProgress, error, setError, + gameLog, + clearGameLog, editions, toggleInstall, handleUninstall, @@ -660,4 +682,4 @@ export function useGameManager({ updateCustomization, saveCustomPath, }; -} +} \ No newline at end of file diff --git a/src/hooks/useLceOnlineNotifications.ts b/src/hooks/useLceOnlineNotifications.ts index 270f538..7f70dac 100644 --- a/src/hooks/useLceOnlineNotifications.ts +++ b/src/hooks/useLceOnlineNotifications.ts @@ -1,17 +1,12 @@ import { useState, useEffect, useRef } from "react"; -import { lceOnlineService } from "../services/LceOnlineService"; +import { lceOnlineService, SocialEntry, InviteEntry } from "../services/LceOnlineService"; export function useLceOnlineNotifications() { const [friendRequestMessage, setFriendRequestMessage] = useState< string | null >(null); const [InviteMessage, setInviteMessage] = useState(null); - const [invites, setInvites] = useState< - Array<{ - inviteid: string; - from: { uuid: string; username: string }; - sessionid: string; - }> - >([]); + const [invites, setInvites] = useState([]); + const [requests, setRequests] = useState([]); const seenRequests = useRef>(new Set()); const seenInvites = useRef>(new Set()); useEffect(() => { @@ -20,11 +15,12 @@ export function useLceOnlineNotifications() { const poll = async () => { if (!lceOnlineService.signedIn) return; try { - const lists = await lceOnlineService.getSocialLists(); - lists.requests.forEach((r: string) => { - if (!seenRequests.current.has(r)) { - seenRequests.current.add(r); - setFriendRequestMessage(`${r} wants to be friends!`); + const requestsData = await lceOnlineService.getSocialLists(); + setRequests(requestsData.requests); + requestsData.requests.forEach((r) => { + if (!seenRequests.current.has(r.username)) { + seenRequests.current.add(r.username); + setFriendRequestMessage(`${r.displayName} wants to be friends!`); } }); } catch (e) {} @@ -34,7 +30,7 @@ export function useLceOnlineNotifications() { invitesData.forEach((i) => { if (!seenInvites.current.has(i.inviteid)) { seenInvites.current.add(i.inviteid); - setInviteMessage(`${i.from.username} invited you to play!`); + setInviteMessage(`${i.from.displayName} invited you to play!`); } }); } catch {} @@ -43,10 +39,11 @@ export function useLceOnlineNotifications() { const init = async () => { if (lceOnlineService.signedIn) { try { - const lists = await lceOnlineService.getSocialLists(); - lists.requests.forEach((r: string) => { - if (!seenRequests.current.has(r)) { - seenRequests.current.add(r); + const requestData = await lceOnlineService.getSocialLists(); + setRequests(requestData.requests); + requestData.requests.forEach((r) => { + if (!seenRequests.current.has(r.username)) { + seenRequests.current.add(r.username); } }); } catch (e) {} @@ -56,7 +53,7 @@ export function useLceOnlineNotifications() { invitesData.forEach((i) => { if (!seenInvites.current.has(i.inviteid)) { seenInvites.current.add(i.inviteid); - setInviteMessage(`${i.from.username} invited you to play!`); + setInviteMessage(`${i.from.displayName} invited you to play!`); } }); } catch {} @@ -76,5 +73,6 @@ export function useLceOnlineNotifications() { clearFriendRequestMessage: () => setFriendRequestMessage(null), clearInviteMessage: () => setInviteMessage(null), invites, + requests, }; } diff --git a/src/hooks/useSkinSync.ts b/src/hooks/useSkinSync.ts index ab9826e..e086f32 100644 --- a/src/hooks/useSkinSync.ts +++ b/src/hooks/useSkinSync.ts @@ -103,9 +103,10 @@ export function useSkinSync({ username, profile, editions }: UseSkinSyncProps) { }; const seededId = getSeededId(username); - const packId = seededId.slice(-4); + //neo: (comment reason stated below) const packId = seededId.slice(-4); 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", path: "0", type: PCKAssetType.INFO, @@ -117,7 +118,7 @@ export function useSkinSync({ username, profile, editions }: UseSkinSyncProps) { value: packId, }, ], - }, + },*/ { id: `dlcskin${seededId}`, path: `dlcskin${seededId}.png`, diff --git a/src/pages/App.tsx b/src/pages/App.tsx index b8af3af..bd8f40e 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -27,6 +27,7 @@ import { CinematicIntro } from "../components/common/CinematicIntro"; import { DownloadOverlay } from "../components/layout/DownloadOverlay"; import { AppHeader } from "../components/layout/AppHeader"; import { AchievementToast } from "../components/common/AchievementToast"; +import GameLogModal from "../components/modals/GameLogModal"; import { useUI, useConfig, @@ -74,7 +75,7 @@ export default function App() { InviteMessage, clearFriendRequestMessage, clearInviteMessage, - invites + invites, } = notifications; const [showSetup, setShowSetup] = useState(false); const [isSetupChecked, setIsSetupChecked] = useState(false); @@ -325,9 +326,9 @@ export default function App() {
- {showHeader && ( - - )} + {showHeader && ( + + )}
- + +
-
+
{activeView === "main" && ( - + )} @@ -612,9 +622,7 @@ export default function App() { {activeView === "devtools" && ( )} - {activeView === "guides" && ( - - )} + {activeView === "guides" && } {activeView === "pck-editor" && ( )} diff --git a/src/services/LceOnlineService.ts b/src/services/LceOnlineService.ts index 6a2d3c3..60d6ba7 100644 --- a/src/services/LceOnlineService.ts +++ b/src/services/LceOnlineService.ts @@ -12,9 +12,22 @@ export interface SessionData { account: LceOnlineAccount; } -export interface FriendRequest { +export interface InviteEntry { + inviteid: string; + from: { uuid: string; displayName: string; username: string }; + sessionid: string; +} + +export interface SocialEntry { username: string; displayName: string; + uuid: string; +}; + +export interface SocialList { + friends: SocialEntry[]; + friendRequests: SocialEntry[]; + blocked: SocialEntry[]; } export class LceOnlineService { @@ -220,25 +233,13 @@ export class LceOnlineService { } } - async getSocialLists(): Promise<{ - friends: string[]; - requests: string[]; - blocked: string[]; - }> { - const raw: string = await this.request( - "POST", - "/getSocialLists", - null, - ); - if (typeof raw !== "string") { - return { friends: [], requests: [], blocked: [] }; - } - const withoutPrefix = raw.startsWith("-") ? raw.slice(1) : raw; - const parts = withoutPrefix.split("|"); + async getSocialLists() { + const res = await this.request("GET", "/getSocialLists", null); + if (typeof res === "string") throw new Error(res); return { - friends: parts[0] ? parts[0].split(",").filter(Boolean) : [], - requests: parts[1] ? parts[1].split(",").filter(Boolean) : [], - blocked: parts[2] ? parts[2].split(",").filter(Boolean) : [], + friends: res?.friends ?? [], + requests: res?.friendRequests ?? [], + blocked: res?.blocked ?? [], }; } @@ -295,7 +296,7 @@ export class LceOnlineService { async getInvites(): Promise< Array<{ inviteid: string; - from: { uuid: string; username: string }; + from: { uuid: string; displayName: string; username: string }; sessionid: string; }> > { diff --git a/src/services/TauriService.ts b/src/services/TauriService.ts index cbfbc37..c50640d 100644 --- a/src/services/TauriService.ts +++ b/src/services/TauriService.ts @@ -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) { return listen("runner-download-progress", (event) => callback(event.payload), @@ -212,6 +218,12 @@ export class TauriService { ); } + static onGameLog(callback: (log: string) => void) { + return listen("game-log", (event) => + callback(event.payload), + ); + } + static onDownloadRetry(callback: (attempt: number) => void) { return listen("download-retry", (event) => callback(event.payload), @@ -420,4 +432,25 @@ export class TauriService { ): Promise { return invoke("import_world", { inputPath, outputPath }); } + + static async importLceSave( + inputPath: string, + outputDir: string, + ): Promise { + return invoke("import_lce_save", { inputPath, outputDir }); + } + + static async javaToLce( + javaWorldPath: string, + outputMsPath: string, + ): Promise { + return invoke("java_to_lce", { javaWorldPath, outputMsPath }); + } + + static async lceToJava( + inputMsPath: string, + javaWorldOutput: string, + ): Promise { + return invoke("lce_to_java", { inputMsPath, javaWorldOutput }); + } }