diff --git a/.gitattributes b/.gitattributes index e69de29b..613d9c7c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -0,0 +1,3 @@ +.github/workflows/docker-nightly.yml merge=ours +.github/workflows/nightly.yml merge=ours +docker-compose.dedicated-server.ghcr.yml merge=ours diff --git a/.github/LCRE-banner.png b/.github/LCRE-banner.png new file mode 100644 index 00000000..d0ef387c Binary files /dev/null and b/.github/LCRE-banner.png differ diff --git a/.github/hardcore-hearts.png b/.github/hardcore-hearts.png new file mode 100644 index 00000000..ec108705 Binary files /dev/null and b/.github/hardcore-hearts.png differ diff --git a/.github/hardcore-preview.png b/.github/hardcore-preview.png new file mode 100644 index 00000000..e12ffc64 Binary files /dev/null and b/.github/hardcore-preview.png differ diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 73db0173..381434e1 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -18,144 +18,144 @@ jobs: runs-on: windows-latest steps: - - name: Checkout - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 - - name: Setup MSVC - uses: ilammy/msvc-dev-cmd@v1 + - name: Setup MSVC + uses: ilammy/msvc-dev-cmd@v1 - - name: Setup CMake - uses: lukka/get-cmake@latest + - name: Setup CMake + uses: lukka/get-cmake@latest - - name: Run CMake - uses: lukka/run-cmake@v10 - env: - VCPKG_ROOT: "" - with: - configurePreset: windows64 - buildPreset: windows64-release - buildPresetAdditionalArgs: "['--target', 'Minecraft.Client']" + - name: Run CMake + uses: lukka/run-cmake@v10 + env: + VCPKG_ROOT: "" + with: + configurePreset: windows64 + buildPreset: windows64-release + buildPresetAdditionalArgs: "['--target', 'Minecraft.Client']" - - name: Zip Build - shell: pwsh - run: | - $source = "./build/windows64/Minecraft.Client/Release" - $zip = "LCREWindows64.zip" - $topLevel = "LCREWindows64" - - # Collect files, excluding unwanted extensions - $files = Get-ChildItem -Path $source -Recurse -File | - Where-Object { $_.Extension -notin '.pch', '.zip', '.ipdb', '.iobj' } - - Add-Type -AssemblyName System.IO.Compression - Add-Type -AssemblyName System.IO.Compression.FileSystem - - $basePath = (Resolve-Path $source).Path - $fs = [System.IO.File]::Open($zip, [System.IO.FileMode]::Create) + - name: Zip Build + shell: pwsh + run: | + $source = "./build/windows64/Minecraft.Client/Release" + $zip = "LCREWindows64.zip" + $topLevel = "LCREWindows64" + + # Collect files, excluding unwanted extensions + $files = Get-ChildItem -Path $source -Recurse -File | + Where-Object { $_.Extension -notin '.pch', '.zip', '.ipdb', '.iobj' } + + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $basePath = (Resolve-Path $source).Path + $fs = [System.IO.File]::Open($zip, [System.IO.FileMode]::Create) + try { + $archive = New-Object System.IO.Compression.ZipArchive($fs, [System.IO.Compression.ZipArchiveMode]::Create) try { - $archive = New-Object System.IO.Compression.ZipArchive($fs, [System.IO.Compression.ZipArchiveMode]::Create) - try { - # Add directories - Get-ChildItem -Path $basePath -Recurse -Directory | ForEach-Object { - $rel = $_.FullName.Substring($basePath.Length).TrimStart('\', '/') - $archive.CreateEntry("$topLevel/$($rel -replace '\\','/')/") | Out-Null - } - # Add files - foreach ($file in $files) { - $rel = $file.FullName.Substring($basePath.Length).TrimStart('\', '/') - $entryName = "$topLevel/$($rel -replace '\\','/')" - [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( - $archive, $file.FullName, $entryName, - [System.IO.Compression.CompressionLevel]::Optimal - ) | Out-Null - } - } finally { $archive.Dispose() } - } finally { $fs.Dispose() } - - Write-Host "Created $zip" + # Add directories + Get-ChildItem -Path $basePath -Recurse -Directory | ForEach-Object { + $rel = $_.FullName.Substring($basePath.Length).TrimStart('\', '/') + $archive.CreateEntry("$topLevel/$($rel -replace '\\','/')/") | Out-Null + } + # Add files + foreach ($file in $files) { + $rel = $file.FullName.Substring($basePath.Length).TrimStart('\', '/') + $entryName = "$topLevel/$($rel -replace '\\','/')" + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $archive, $file.FullName, $entryName, + [System.IO.Compression.CompressionLevel]::Optimal + ) | Out-Null + } + } finally { $archive.Dispose() } + } finally { $fs.Dispose() } - - name: Stage artifacts - shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path staging - Copy-Item LCREWindows64.zip staging/ - Copy-Item ./build/windows64/Minecraft.Client/Release/Minecraft.Client.exe staging/ + Write-Host "Created $zip" - - name: Upload artifacts - uses: actions/upload-artifact@v6 - with: - name: client-build - path: staging/* + - name: Stage artifacts + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path staging + Copy-Item LCREWindows64.zip staging/ + Copy-Item ./build/windows64/Minecraft.Client/Release/Minecraft.Client.exe staging/ + + - name: Upload artifacts + uses: actions/upload-artifact@v6 + with: + name: client-build + path: staging/* build-server: name: Build Server runs-on: windows-latest steps: - - name: Checkout - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 - - name: Setup MSVC - uses: ilammy/msvc-dev-cmd@v1 + - name: Setup MSVC + uses: ilammy/msvc-dev-cmd@v1 - - name: Setup CMake - uses: lukka/get-cmake@latest + - name: Setup CMake + uses: lukka/get-cmake@latest - - name: Run CMake - uses: lukka/run-cmake@v10 - env: - VCPKG_ROOT: "" - with: - configurePreset: windows64 - buildPreset: windows64-release - buildPresetAdditionalArgs: "['--target', 'Minecraft.Server']" + - name: Run CMake + uses: lukka/run-cmake@v10 + env: + VCPKG_ROOT: "" + with: + configurePreset: windows64 + buildPreset: windows64-release + buildPresetAdditionalArgs: "['--target', 'Minecraft.Server']" - - name: Zip Build - shell: pwsh - run: | - $source = "./build/windows64/Minecraft.Server/Release" - $zip = "LCREServerWindows64.zip" - $topLevel = "LCREServerWindows64" - - $files = Get-ChildItem -Path $source -Recurse -File | - Where-Object { $_.Extension -notin '.pch', '.zip', '.ipdb', '.iobj' } - - Add-Type -AssemblyName System.IO.Compression - Add-Type -AssemblyName System.IO.Compression.FileSystem - - $basePath = (Resolve-Path $source).Path - $fs = [System.IO.File]::Open($zip, [System.IO.FileMode]::Create) + - name: Zip Build + shell: pwsh + run: | + $source = "./build/windows64/Minecraft.Server/Release" + $zip = "LCREServerWindows64.zip" + $topLevel = "LCREServerWindows64" + + $files = Get-ChildItem -Path $source -Recurse -File | + Where-Object { $_.Extension -notin '.pch', '.zip', '.ipdb', '.iobj' } + + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $basePath = (Resolve-Path $source).Path + $fs = [System.IO.File]::Open($zip, [System.IO.FileMode]::Create) + try { + $archive = New-Object System.IO.Compression.ZipArchive($fs, [System.IO.Compression.ZipArchiveMode]::Create) try { - $archive = New-Object System.IO.Compression.ZipArchive($fs, [System.IO.Compression.ZipArchiveMode]::Create) - try { - Get-ChildItem -Path $basePath -Recurse -Directory | ForEach-Object { - $rel = $_.FullName.Substring($basePath.Length).TrimStart('\', '/') - $archive.CreateEntry("$topLevel/$($rel -replace '\\','/')/") | Out-Null - } - foreach ($file in $files) { - $rel = $file.FullName.Substring($basePath.Length).TrimStart('\', '/') - $entryName = "$topLevel/$($rel -replace '\\','/')" - [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( - $archive, $file.FullName, $entryName, - [System.IO.Compression.CompressionLevel]::Optimal - ) | Out-Null - } - } finally { $archive.Dispose() } - } finally { $fs.Dispose() } - - Write-Host "Created $zip" + Get-ChildItem -Path $basePath -Recurse -Directory | ForEach-Object { + $rel = $_.FullName.Substring($basePath.Length).TrimStart('\', '/') + $archive.CreateEntry("$topLevel/$($rel -replace '\\','/')/") | Out-Null + } + foreach ($file in $files) { + $rel = $file.FullName.Substring($basePath.Length).TrimStart('\', '/') + $entryName = "$topLevel/$($rel -replace '\\','/')" + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $archive, $file.FullName, $entryName, + [System.IO.Compression.CompressionLevel]::Optimal + ) | Out-Null + } + } finally { $archive.Dispose() } + } finally { $fs.Dispose() } - - name: Stage artifacts - shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path staging - Copy-Item LCREServerWindows64.zip staging/ + Write-Host "Created $zip" - - name: Upload artifacts - uses: actions/upload-artifact@v6 - with: - name: server-build - path: staging/* + - name: Stage artifacts + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path staging + Copy-Item LCREServerWindows64.zip staging/ + + - name: Upload artifacts + uses: actions/upload-artifact@v6 + with: + name: server-build + path: staging/* release-server: name: Release Server @@ -163,58 +163,58 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 - - name: Download server artifacts - uses: actions/download-artifact@v7 - with: - name: server-build - path: artifacts + - name: Download server artifacts + uses: actions/download-artifact@v7 + with: + name: server-build + path: artifacts - - name: Attest artifacts - uses: actions/attest-build-provenance@v2 - with: - subject-path: | - artifacts/LCREServerWindows64.zip + - name: Attest artifacts + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + artifacts/LCREServerWindows64.zip - - name: Get short SHA - id: sha - run: echo "short=$(echo '${{ github.sha }}' | cut -c1-7)" >> "$GITHUB_OUTPUT" + - name: Get short SHA + id: sha + run: echo "short=$(echo '${{ github.sha }}' | cut -c1-7)" >> "$GITHUB_OUTPUT" - - name: Delete old release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release delete Nightly-Dedicated-Server --yes || true + - name: Delete old release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release delete Nightly-Dedicated-Server --yes || true - - name: Delete old tag - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh api repos/${{ github.repository }}/git/refs/tags/Nightly-Dedicated-Server --method DELETE || true + - name: Delete old tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh api repos/${{ github.repository }}/git/refs/tags/Nightly-Dedicated-Server --method DELETE || true - - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@v6 - with: - gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} - passphrase: ${{ secrets.GPG_PASSPHRASE }} - git_user_signingkey: true - git_tag_gpgsign: true + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + git_user_signingkey: true + git_tag_gpgsign: true - - name: Create signed tag - run: | - git tag -s -f Nightly-Dedicated-Server -m "Nightly server release ${{ steps.sha.outputs.short }}" - git push origin Nightly-Dedicated-Server --force + - name: Create signed tag + run: | + git tag -s -f Nightly-Dedicated-Server -m "Nightly server release ${{ steps.sha.outputs.short }}" + git push origin Nightly-Dedicated-Server --force - - name: Create release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create Nightly-Dedicated-Server artifacts/* \ - --title "Server: ${{ steps.sha.outputs.short }}" \ - --notes "Dedicated Server runtime for Windows64. - - Download \`LCREServerWindows64.zip\` and extract it to a folder where you'd like to keep the server runtime." \ - --latest=false + - name: Create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create Nightly-Dedicated-Server artifacts/* \ + --title "Server: ${{ steps.sha.outputs.short }}" \ + --notes "Dedicated Server runtime for Windows64. + + Download \`LCREServerWindows64.zip\` and extract it to a folder where you'd like to keep the server runtime." \ + --latest=false release-client: name: Release Client @@ -222,103 +222,103 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 - - name: Download client artifacts - uses: actions/download-artifact@v7 - with: - name: client-build - path: artifacts + - name: Download client artifacts + uses: actions/download-artifact@v7 + with: + name: client-build + path: artifacts - - name: Attest artifacts - uses: actions/attest-build-provenance@v2 - with: - subject-path: | - artifacts/LCREWindows64.zip - artifacts/Minecraft.Client.exe + - name: Attest artifacts + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + artifacts/LCREWindows64.zip + artifacts/Minecraft.Client.exe - - name: Get short SHA - id: sha - run: echo "short=$(echo '${{ github.sha }}' | cut -c1-7)" >> "$GITHUB_OUTPUT" + - name: Get short SHA + id: sha + run: echo "short=$(echo '${{ github.sha }}' | cut -c1-7)" >> "$GITHUB_OUTPUT" - - name: Delete old release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release delete Nightly --yes || true + - name: Delete old release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release delete Nightly --yes || true - - name: Delete old tag - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh api repos/${{ github.repository }}/git/refs/tags/Nightly --method DELETE || true + - name: Delete old tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh api repos/${{ github.repository }}/git/refs/tags/Nightly --method DELETE || true - - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@v6 - with: - gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} - passphrase: ${{ secrets.GPG_PASSPHRASE }} - git_user_signingkey: true - git_tag_gpgsign: true + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + git_user_signingkey: true + git_tag_gpgsign: true - - name: Create signed tag - run: | - git tag -s -f Nightly -m "Nightly release ${{ steps.sha.outputs.short }}" - git push origin Nightly --force + - name: Create signed tag + run: | + git tag -s -f Nightly -m "Nightly release ${{ steps.sha.outputs.short }}" + git push origin Nightly --force - - name: Write release notes - run: | - cat > notes.md <<'NOTES' - # Instructions: - **Newcomers:** - - If this is your first time, download `LCREWindows64.zip` and extract it wherever you would like to keep it. - - I would recommend to set your username prior to launch (create a file called `username.txt`, put your desired username into the file, and save). - - To play, simply run `Minecraft.Client.exe`. - - **For those that wish to update their existing installation with the latest build:** - - Download `Minecraft.Client.exe` and `Minecraft.Client.pdb` and copy them over to your existing LCREWindows64 build (overwrite your old version of Minecraft.Client.exe and Minecraft.Client.pdb). - - **Steam Deck & Linux:** - - Y'all know the drill. Download the `LCREWindows64.zip`, extract it, add the `Minecraft.Client.exe` as a "Non-Steam Game" within the Steam library, turn on compatibility mode with Proton Experimental, and then run it! - - # Multiplayer instructions: - LAN games are natively supported, and any LAN games will appear automatically on the right. However, if you'd like to play with your friends online (and if you don't want to require them to setup a vpn, and/or if you don't want to port forward), I would recommend the following setup. Please keep in mind, you do NOT need to do this to enjoy the game. This is just how I have it setup for me so my friends can join without any hassle: - - Prerequisites: - - Premium playit.gg account, costs about $3 USD per month. This is for setting up the tunnel. - - playit.gg agent installed on host PC. - - How-to: - - Ensure your playit.gg agent is connected to your playit.gg account - - On the playit.gg website, setup a new tunnel (choose TCP). Ensure the configurable settings are set to the below values, assuming your agent is installed on the same computer as your online LCREMinecraft game is hosted from. - - Configurable settings: - - Local IP: `127.0.0.1` - - Local Port: `25565` - - Proxy Protocol: `None` - - After creating your tunnel, navigate to the "Tunnels" main page. You'll see the IP address and port for your tunnel. This is what your friends will input when adding your server in order to join your online game! - - - # Why this fork exists: - Changes/additions that stray from the upstream repo (`smartcmd/MinecraftConsoles`: - - See: https://github.com/itsRevela/MinecraftConsoles?tab=readme-ov-file#latest - - I can tweak this fork while staying compatible with the upstream repo without needing to wait on my pull requests to get accepted upstream (while keeping this fork updated with the latest and greatest from upstream) - NOTES + - name: Write release notes + run: | + cat > notes.md <<'NOTES' + # Instructions: + **Newcomers:** + - If this is your first time, download `LCREWindows64.zip` and extract it wherever you would like to keep it. + - I would recommend to set your username prior to launch (create a file called `username.txt`, put your desired username into the file, and save). + - To play, simply run `Minecraft.Client.exe`. - - name: Create release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create Nightly artifacts/* \ - --title "Client: ${{ steps.sha.outputs.short }}" \ - --notes-file notes.md + **For those that wish to update their existing installation with the latest build:** + - Download `Minecraft.Client.exe` and `Minecraft.Client.pdb` and copy them over to your existing LCREWindows64 build (overwrite your old version of Minecraft.Client.exe and Minecraft.Client.pdb). + + **Steam Deck & Linux:** + - Y'all know the drill. Download the `LCREWindows64.zip`, extract it, add the `Minecraft.Client.exe` as a "Non-Steam Game" within the Steam library, turn on compatibility mode with Proton Experimental, and then run it! + + # Multiplayer instructions: + LAN games are natively supported, and any LAN games will appear automatically on the right. However, if you'd like to play with your friends online (and if you don't want to require them to setup a vpn, and/or if you don't want to port forward), I would recommend the following setup. Please keep in mind, you do NOT need to do this to enjoy the game. This is just how I have it setup for me so my friends can join without any hassle: + + Prerequisites: + - Premium playit.gg account, costs about $3 USD per month. This is for setting up the tunnel. + - playit.gg agent installed on host PC. + + How-to: + - Ensure your playit.gg agent is connected to your playit.gg account + - On the playit.gg website, setup a new tunnel (choose TCP). Ensure the configurable settings are set to the below values, assuming your agent is installed on the same computer as your online LCREMinecraft game is hosted from. + - Configurable settings: + - Local IP: `127.0.0.1` + - Local Port: `25565` + - Proxy Protocol: `None` + - After creating your tunnel, navigate to the "Tunnels" main page. You'll see the IP address and port for your tunnel. This is what your friends will input when adding your server in order to join your online game! + + + # Why this fork exists: + Changes/additions that stray from the upstream repo (`smartcmd/MinecraftConsoles`: + - See: https://github.com/itsRevela/MinecraftConsoles?tab=readme-ov-file#latest + - I can tweak this fork while staying compatible with the upstream repo without needing to wait on my pull requests to get accepted upstream (while keeping this fork updated with the latest and greatest from upstream) + NOTES + + - name: Create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create Nightly artifacts/* \ + --title "Client: ${{ steps.sha.outputs.short }}" \ + --notes-file notes.md cleanup: needs: [release-client, release-server] if: always() runs-on: ubuntu-latest steps: - - name: Cleanup artifacts - uses: geekyeggo/delete-artifact@v5 - with: - name: | - client-build - server-build \ No newline at end of file + - name: Cleanup artifacts + uses: geekyeggo/delete-artifact@v5 + with: + name: | + client-build + server-build diff --git a/.gitignore b/.gitignore index d0ec3cf9..b6060795 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ mono_crash.* [Rr]elease/ [Rr]eleases/ x64/ +x64_*/ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ @@ -421,4 +422,9 @@ result-* .direnv/ .xwin-cache/ -.xwin \ No newline at end of file +.xwin +# Tools build artifacts and intermediates +tools/*.class +tools/*.swf +tools/staging/ +tools/server-monitor/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 4eb2b823..1ff1e956 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,9 +68,14 @@ set(MINECRAFT_SHARED_DEFINES $<$:_DEBUG> _CRT_NON_CONFORMING_SWPRINTFS _CRT_SECURE_NO_WARNINGS + _HAS_STD_BYTE=0 ) # Add platform-specific defines +if(PLATFORM_NAME STREQUAL "Windows64") + list(APPEND MINECRAFT_SHARED_DEFINES _WINDOWS64) + set(IGGY_LIBS iggy_w64.lib) +endif() list(APPEND MINECRAFT_SHARED_DEFINES ${PLATFORM_DEFINES}) # --- diff --git a/Minecraft.Client/CMakeLists.txt b/Minecraft.Client/CMakeLists.txt index cac92825..cb64bf5b 100644 --- a/Minecraft.Client/CMakeLists.txt +++ b/Minecraft.Client/CMakeLists.txt @@ -52,6 +52,7 @@ set_target_properties(Minecraft.Client PROPERTIES target_link_libraries(Minecraft.Client PRIVATE Minecraft.World d3d11 + dxgi d3dcompiler XInput9_1_0 wsock32 diff --git a/Minecraft.Client/ChatScreen.cpp b/Minecraft.Client/ChatScreen.cpp index e2933a0c..feb01d5d 100644 --- a/Minecraft.Client/ChatScreen.cpp +++ b/Minecraft.Client/ChatScreen.cpp @@ -6,6 +6,7 @@ #include "../Minecraft.World/SharedConstants.h" #include "../Minecraft.World/StringHelpers.h" #include "../Minecraft.World/ChatPacket.h" +#include "../Minecraft.World/ArabicShaping.h" const wstring ChatScreen::allowedChars = SharedConstants::acceptableLetters; vector ChatScreen::s_chatHistory; @@ -14,7 +15,12 @@ wstring ChatScreen::s_historyDraft; bool ChatScreen::isAllowedChatChar(wchar_t c) { - return c >= 0x20 && (c == L'\u00A7' || allowedChars.empty() || allowedChars.find(c) != wstring::npos); + if (c < 0x20) return false; + // Block Unicode bidirectional override characters that can be used to + // spoof chat messages or impersonate players. + if (c >= 0x202A && c <= 0x202E) return false; // LRE, RLE, PDF, LRO, RLO + if (c >= 0x2066 && c <= 0x2069) return false; // LRI, RLI, FSI, PDI + return true; } ChatScreen::ChatScreen() @@ -93,6 +99,9 @@ void ChatScreen::keyPressed(wchar_t ch, int eventKey) if (eventKey == Keyboard::KEY_RETURN) { wstring trim = trimString(message); + { char buf[64]; sprintf_s(buf, "[CHAT] Sending (%d chars): ", (int)trim.length()); OutputDebugStringA(buf); } + OutputDebugStringW(trim.c_str()); + OutputDebugStringA("\n"); if (trim.length() > 0) { if (!minecraft->handleClientSideCommand(trim)) @@ -145,14 +154,21 @@ void ChatScreen::render(int xm, int ym, float a) int x = 4; drawString(font, prefix, x, height - 12, 0xe0e0e0); x += font->width(prefix); - wstring beforeCursor = message.substr(0, cursorIndex); - wstring afterCursor = message.substr(cursorIndex); - drawStringLiteral(font, beforeCursor, x, height - 12, 0xe0e0e0); - x += font->widthLiteral(beforeCursor); + + // Shape the full message as one unit so letter connections and word order + // are correct. Track where the logical cursor maps in the visual string. + int visualCursorPos = 0; + wstring shaped = shapeArabicText(message, cursorIndex, &visualCursorPos); + + // Render the full shaped message without re-shaping it + drawStringPreshaped(font, shaped, x, height - 12, 0xe0e0e0); + + // Place the cursor at the correct visual position + wstring beforeCursorVisual = shaped.substr(0, visualCursorPos); + int cursorX = x + font->widthPreshaped(beforeCursorVisual); if (frame / 6 % 2 == 0) - drawString(font, L"_", x, height - 12, 0xe0e0e0); - x += font->width(L"_"); - drawStringLiteral(font, afterCursor, x, height - 12, 0xe0e0e0); + drawString(font, L"_", cursorX, height - 12, 0xe0e0e0); + Screen::render(xm, ym, a); } diff --git a/Minecraft.Client/ClientConnection.cpp b/Minecraft.Client/ClientConnection.cpp index 578549f8..424f957f 100644 --- a/Minecraft.Client/ClientConnection.cpp +++ b/Minecraft.Client/ClientConnection.cpp @@ -58,6 +58,7 @@ #ifdef _WINDOWS64 #include "Xbox/Network/NetworkPlayerXbox.h" #include "Common/Network/PlatformNetworkManagerStub.h" +#include "Windows64\Network\WinsockNetLayer.h" #endif @@ -65,6 +66,7 @@ #include "../Minecraft.World/DurangoStats.h" #include "../Minecraft.World/GenericStats.h" #endif + namespace { char mapIconToFrame(char iconSlot) @@ -133,6 +135,7 @@ ClientConnection::ClientConnection(Minecraft *minecraft, Socket *socket, int iUs started = false; savedDataStorage = new SavedDataStorage(nullptr); maxPlayers = 20; + m_isForkServer = false; this->minecraft = minecraft; @@ -365,7 +368,7 @@ void ClientConnection::handleLogin(shared_ptr packet) Level *dimensionLevel = minecraft->getLevel( packet->dimension ); if( dimensionLevel == nullptr ) { - level = new MultiPlayerLevel(this, new LevelSettings(packet->seed, GameType::byId(packet->gameType), false, false, packet->m_newSeaLevel, packet->m_pLevelType, packet->m_xzSize, packet->m_hellScale), packet->dimension, packet->difficulty); + level = new MultiPlayerLevel(this, new LevelSettings(packet->seed, GameType::byId(packet->gameType), false, packet->m_isHardcore, packet->m_newSeaLevel, packet->m_pLevelType, packet->m_xzSize, packet->m_hellScale), packet->dimension, packet->difficulty); // 4J Stu - We want to share the SavedDataStorage between levels int otherDimensionId = packet->dimension == 0 ? -1 : 0; @@ -435,7 +438,7 @@ void ClientConnection::handleLogin(shared_ptr packet) activeLevel = minecraft->getLevel(otherDimensionId); } - MultiPlayerLevel *dimensionLevel = new MultiPlayerLevel(this, new LevelSettings(packet->seed, GameType::byId(packet->gameType), false, false, packet->m_newSeaLevel, packet->m_pLevelType, packet->m_xzSize, packet->m_hellScale), packet->dimension, packet->difficulty); + MultiPlayerLevel *dimensionLevel = new MultiPlayerLevel(this, new LevelSettings(packet->seed, GameType::byId(packet->gameType), false, packet->m_isHardcore, packet->m_newSeaLevel, packet->m_pLevelType, packet->m_xzSize, packet->m_hellScale), packet->dimension, packet->difficulty); dimensionLevel->savedDataStorage = activeLevel->savedDataStorage; @@ -1139,7 +1142,11 @@ void ClientConnection::handleMoveEntitySmall(shared_ptr p void ClientConnection::handleRemoveEntity(shared_ptr packet) { #ifdef _WINDOWS64 - if (!g_NetworkManager.IsHost()) + // On fork servers, IQNet cleanup is handled by the MC|ForkPLeave custom + // payload so players stay in Tab regardless of render distance. On + // upstream servers (no MC|ForkHello received), fall back to the old + // behaviour of cleaning up IQNet here. + if (!m_isForkServer && !g_NetworkManager.IsHost()) { for (int i = 0; i < packet->ids.length; i++) { @@ -1149,7 +1156,6 @@ void ClientConnection::handleRemoveEntity(shared_ptr packe shared_ptr player = dynamic_pointer_cast(entity); if (player != nullptr) { - // Match by gamertag in the IQNet array (XUID may be 0 on dedicated servers) for (int s = 1; s < MINECRAFT_NET_MAX_PLAYERS; ++s) { IQNetPlayer* qp = &IQNet::m_player[s]; @@ -2925,7 +2931,7 @@ void ClientConnection::handleRespawn(shared_ptr packet) MultiPlayerLevel *dimensionLevel = (MultiPlayerLevel *)minecraft->getLevel( packet->dimension ); if( dimensionLevel == nullptr ) { - dimensionLevel = new MultiPlayerLevel(this, new LevelSettings(packet->mapSeed, packet->playerGameType, false, minecraft->level->getLevelData()->isHardcore(), packet->m_newSeaLevel, packet->m_pLevelType, packet->m_xzSize, packet->m_hellScale), packet->dimension, packet->difficulty); + dimensionLevel = new MultiPlayerLevel(this, new LevelSettings(packet->mapSeed, packet->playerGameType, false, packet->m_isHardcore, packet->m_newSeaLevel, packet->m_pLevelType, packet->m_xzSize, packet->m_hellScale), packet->dimension, packet->difficulty); // 4J Stu - We want to shared the savedDataStorage between both levels //if( dimensionLevel->savedDataStorage != nullptr ) @@ -3370,7 +3376,9 @@ void ClientConnection::handleTileEditorOpen(shared_ptr pac void ClientConnection::handleSignUpdate(shared_ptr packet) { - app.DebugPrintf("ClientConnection::handleSignUpdate - "); + app.DebugPrintf("[SIGN] handleSignUpdate at (%d, %d, %d):\n", packet->x, packet->y, packet->z); + for (int i = 0; i < MAX_SIGN_LINES; i++) + app.DebugPrintf("[SIGN] Line%d: \"%ls\"\n", i+1, packet->lines[i].c_str()); if (minecraft->level->hasChunkAt(packet->x, packet->y, packet->z)) { shared_ptr te = minecraft->level->getTileEntity(packet->x, packet->y, packet->z); @@ -3384,7 +3392,7 @@ void ClientConnection::handleSignUpdate(shared_ptr packet) ste->SetMessage(i,packet->lines[i]); } - app.DebugPrintf("verified = %d\tCensored = %d\n",packet->m_bVerified,packet->m_bCensored); + app.DebugPrintf("[SIGN] verified=%d censored=%d\n", packet->m_bVerified, packet->m_bCensored); ste->SetVerified(packet->m_bVerified); ste->SetCensored(packet->m_bCensored); @@ -3392,12 +3400,12 @@ void ClientConnection::handleSignUpdate(shared_ptr packet) } else { - app.DebugPrintf("dynamic_pointer_cast(te) == nullptr\n"); + app.DebugPrintf("[SIGN] ERROR: tile entity is not a SignTileEntity\n"); } } else { - app.DebugPrintf("hasChunkAt failed\n"); + app.DebugPrintf("[SIGN] ERROR: chunk not loaded at position\n"); } } @@ -3784,6 +3792,158 @@ void ClientConnection::handleSoundEvent(shared_ptr packet) void ClientConnection::handleCustomPayload(shared_ptr customPayloadPacket) { +#ifdef _WINDOWS64 + // Build a server-specific identity token file path next to the executable. + // Each server gets its own token file based on a hash of the server address, + // so connecting to multiple secured servers doesn't overwrite tokens. + auto buildIdentityTokenPath = []() -> std::string { + char exePath[MAX_PATH] = {}; + DWORD len = GetModuleFileNameA(NULL, exePath, MAX_PATH); + if (len == 0 || len >= MAX_PATH) return std::string(); + char *lastSlash = strrchr(exePath, '\\'); + if (lastSlash != NULL) *(lastSlash + 1) = 0; + + // Hash the server IP:port to create a unique filename per server + char serverAddr[300] = {}; + sprintf_s(serverAddr, sizeof(serverAddr), "%s:%d", g_Win64MultiplayerIP, g_Win64MultiplayerPort); + unsigned int hash = 5381; + for (const char *p = serverAddr; *p; ++p) + hash = ((hash << 5) + hash) + static_cast(*p); + + char filename[64] = {}; + sprintf_s(filename, sizeof(filename), "identity-token-%08x.dat", hash); + return std::string(exePath) + filename; + }; + + // Identity token: server issued us a new token - store it locally + if (CustomPayloadPacket::IDENTITY_TOKEN_ISSUE.compare(customPayloadPacket->identifier) == 0) + { + if (customPayloadPacket->data.data != nullptr && customPayloadPacket->length == 32) + { + std::string tokenPath = buildIdentityTokenPath(); + if (!tokenPath.empty()) + { + FILE *f = nullptr; + fopen_s(&f, tokenPath.c_str(), "wb"); + if (f != nullptr) + { + size_t written = fwrite(customPayloadPacket->data.data, 1, 32, f); + fclose(f); + if (written == 32) + { + app.DebugPrintf("Client: Stored identity token to %s\n", tokenPath.c_str()); + } + else + { + app.DebugPrintf("Client: Failed to write full identity token (wrote %zu/32)\n", written); + } + } + else + { + app.DebugPrintf("Client: Failed to open %s for writing\n", tokenPath.c_str()); + } + } + } + return; + } + + // Identity token: server is challenging us to present our stored token + if (CustomPayloadPacket::IDENTITY_TOKEN_CHALLENGE.compare(customPayloadPacket->identifier) == 0) + { + std::string tokenPath = buildIdentityTokenPath(); + FILE *f = nullptr; + if (!tokenPath.empty()) + fopen_s(&f, tokenPath.c_str(), "rb"); + if (f != nullptr) + { + uint8_t token[32] = {}; + size_t bytesRead = fread(token, 1, 32, f); + fclose(f); + if (bytesRead == 32) + { + byteArray tokenData(32); + memcpy(tokenData.data, token, 32); + connection->send(std::make_shared( + CustomPayloadPacket::IDENTITY_TOKEN_RESPONSE, tokenData)); + app.DebugPrintf("Client: Sent identity token response\n"); + } + else + { + app.DebugPrintf("Client: identity-token.dat is invalid (%zu bytes)\n", bytesRead); + connection->send(std::make_shared( + CustomPayloadPacket::IDENTITY_TOKEN_RESPONSE, byteArray())); + } + SecureZeroMemory(token, sizeof(token)); + } + else + { + app.DebugPrintf("Client: No identity-token.dat found, sending empty response\n"); + connection->send(std::make_shared( + CustomPayloadPacket::IDENTITY_TOKEN_RESPONSE, byteArray())); + } + return; + } + + // Stream cipher handshake: server sent us a key + if (CustomPayloadPacket::CIPHER_KEY_CHANNEL.compare(customPayloadPacket->identifier) == 0) + { + if (customPayloadPacket->length == ServerRuntime::Security::StreamCipher::KEY_SIZE && + customPayloadPacket->data.data != nullptr) + { + app.DebugPrintf("Client: Received MC|CKey from server (%d bytes)\n", customPayloadPacket->length); + // Store key and send ack+activate atomically to prevent ResetClientCipher race + WinsockNetLayer::StoreClientCipherKey(customPayloadPacket->data.data); + if (!WinsockNetLayer::SendAckAndActivateClientSendCipher()) + { + app.DebugPrintf("Client: Failed to send cipher ack, connection will be closed\n"); + } + } + else + { + app.DebugPrintf("Client: Received malformed MC|CKey (length=%d)\n", customPayloadPacket->length); + } + return; + } + + // Fork server identification: enables render-distance-independent player list + if (CustomPayloadPacket::FORK_HELLO_CHANNEL.compare(customPayloadPacket->identifier) == 0) + { + m_isForkServer = true; + app.DebugPrintf("Client: Connected to fork server\n"); + return; + } + + // Fork server player leave: clean up IQNet slot so player leaves Tab list + if (CustomPayloadPacket::FORK_PLAYER_LEAVE_CHANNEL.compare(customPayloadPacket->identifier) == 0) + { + if (customPayloadPacket->data.data != nullptr && customPayloadPacket->length > 0) + { + int nameLen = customPayloadPacket->length / static_cast(sizeof(wchar_t)); + wstring leavingName(reinterpret_cast(customPayloadPacket->data.data), nameLen); + + for (int s = 1; s < MINECRAFT_NET_MAX_PLAYERS; ++s) + { + IQNetPlayer* qp = &IQNet::m_player[s]; + if (qp->GetCustomDataValue() != 0 && + _wcsicmp(qp->m_gamertag, leavingName.c_str()) == 0) + { + extern CPlatformNetworkManagerStub* g_pPlatformNetworkManager; + g_pPlatformNetworkManager->NotifyPlayerLeaving(qp); + qp->m_smallId = 0; + qp->m_isRemote = false; + qp->m_isHostPlayer = false; + qp->m_resolvedXuid = INVALID_XUID; + qp->m_gamertag[0] = 0; + qp->SetCustomDataValue(0); + app.DebugPrintf("Client: Player \"%ls\" left fork server, cleared IQNet slot %d\n", leavingName.c_str(), s); + break; + } + } + } + return; + } +#endif + if (CustomPayloadPacket::TRADER_LIST_PACKET.compare(customPayloadPacket->identifier) == 0) { ByteArrayInputStream bais(customPayloadPacket->data); @@ -4091,8 +4251,7 @@ void ClientConnection::handleSetPlayerTeamPacket(shared_ptr void ClientConnection::handleParticleEvent(shared_ptr packet) { - wstring particleName = packet->getName(); - ePARTICLE_TYPE particleId = (ePARTICLE_TYPE)Integer::parseInt(particleName); + ePARTICLE_TYPE particleId = (ePARTICLE_TYPE)Integer::parseInt(packet->getName()); for (int i = 0; i < packet->getCount(); i++) { diff --git a/Minecraft.Client/ClientConnection.h b/Minecraft.Client/ClientConnection.h index a27e739c..ad78cac9 100644 --- a/Minecraft.Client/ClientConnection.h +++ b/Minecraft.Client/ClientConnection.h @@ -48,6 +48,7 @@ private: std::unordered_set m_trackedEntityIds; std::unordered_set m_visibleChunks; + bool m_isForkServer; // true when connected to a fork server (received MC|ForkHello) static int64_t chunkKey(int x, int z) { return ((int64_t)x << 32) | ((int64_t)z & 0xFFFFFFFF); } diff --git a/Minecraft.Client/ClientConstants.cpp b/Minecraft.Client/ClientConstants.cpp index d1d80aa9..c9b791e4 100644 --- a/Minecraft.Client/ClientConstants.cpp +++ b/Minecraft.Client/ClientConstants.cpp @@ -1,5 +1,13 @@ #include "stdafx.h" #include "ClientConstants.h" +#include "Common/BuildVer.h" const wstring ClientConstants::VERSION_STRING = wstring(L"Minecraft LCE ") + VER_FILEVERSION_STR_W;//+ SharedConstants::VERSION_STRING; const wstring ClientConstants::BRANCH_STRING = VER_BRANCHVERSION_STR_W; + +// Default value for the toggle. If BuildVer defines VER_SHOW_VERSION_WATERMARK, use that. +#ifdef VER_SHOW_VERSION_WATERMARK +const bool ClientConstants::SHOW_VERSION_WATERMARK = (VER_SHOW_VERSION_WATERMARK != 0); +#else +const bool ClientConstants::SHOW_VERSION_WATERMARK = false; +#endif diff --git a/Minecraft.Client/ClientConstants.h b/Minecraft.Client/ClientConstants.h index 1476e51b..726430f1 100644 --- a/Minecraft.Client/ClientConstants.h +++ b/Minecraft.Client/ClientConstants.h @@ -15,5 +15,8 @@ public: static const wstring VERSION_STRING; static const wstring BRANCH_STRING; + // Toggle to show/hide the version/branch watermark in the debug overlay + static const bool SHOW_VERSION_WATERMARK; + static const bool DEADMAU5_CAMERA_CHEATS = false; }; \ No newline at end of file diff --git a/Minecraft.Client/Common/App_Defines.h b/Minecraft.Client/Common/App_Defines.h index 7e96896c..ad72eb5b 100644 --- a/Minecraft.Client/Common/App_Defines.h +++ b/Minecraft.Client/Common/App_Defines.h @@ -45,6 +45,7 @@ #define GAME_HOST_OPTION_BITMASK_DOTILEDROPS 0x08000000 #define GAME_HOST_OPTION_BITMASK_NATURALREGEN 0x10000000 #define GAME_HOST_OPTION_BITMASK_DODAYLIGHTCYCLE 0x20000000 +#define GAME_HOST_OPTION_BITMASK_HARDCORE 0x40000000 // 4J Added - for hardcore mode #define GAME_HOST_OPTION_BITMASK_ALL 0xFFFFFFFF #define GAME_HOST_OPTION_BITMASK_WORLDSIZE_BITSHIFT 20 @@ -104,6 +105,8 @@ enum EGameHostOptionWorldSize #define GAMESETTING_ANIMATEDCHARACTER 0x00008000 #define GAMESETTING_PS3EULAREAD 0x00010000 #define GAMESETTING_PSVITANETWORKMODEADHOC 0x00020000 +#define GAMESETTING_VSYNC 0x01000000 +#define GAMESETTING_EXCLUSIVEFULLSCREEN 0x02000000 // defines for languages diff --git a/Minecraft.Client/Common/App_enums.h b/Minecraft.Client/Common/App_enums.h index c054b252..03064ff8 100644 --- a/Minecraft.Client/Common/App_enums.h +++ b/Minecraft.Client/Common/App_enums.h @@ -178,6 +178,9 @@ enum eGameSetting // PSVita eGameSetting_PSVita_NetworkModeAdhoc, + // PC + eGameSetting_VSync, + eGameSetting_ExclusiveFullscreen, }; @@ -660,6 +663,7 @@ enum eGameHostOption eGameHostOption_DoTileDrops, eGameHostOption_NaturalRegeneration, eGameHostOption_DoDaylightCycle, + eGameHostOption_Hardcore, // 4J Added - for hardcore mode }; // 4J-PB - If any new DLC items are added to the TMSFiles, this array needs updated diff --git a/Minecraft.Client/Common/Consoles_App.cpp b/Minecraft.Client/Common/Consoles_App.cpp index 8c7f979e..af99e62f 100644 --- a/Minecraft.Client/Common/Consoles_App.cpp +++ b/Minecraft.Client/Common/Consoles_App.cpp @@ -207,6 +207,8 @@ CMinecraftApp::CMinecraftApp() m_dwRequiredTexturePackID=0; m_bResetNether=false; + m_seedOverride = 0; + m_hasSeedOverride = false; #ifdef _XBOX // m_bTransferSavesToXboxOne=false; @@ -1398,6 +1400,7 @@ void CMinecraftApp::ApplyGameSettingsChanged(int iPad) ActionGameSettings(iPad,eGameSetting_AnimatedCharacter); ActionGameSettings(iPad,eGameSetting_PS3_EULA_Read); + ActionGameSettings(iPad,eGameSetting_VSync); } @@ -1633,6 +1636,22 @@ void CMinecraftApp::ActionGameSettings(int iPad,eGameSetting eVal) case eGameSetting_PSVita_NetworkModeAdhoc: //nothing to do here break; + case eGameSetting_VSync: +#ifdef _WINDOWS64 + { + extern bool g_bVSync; + g_bVSync = (GetGameSettings(iPad, eGameSetting_VSync) != 0); + } +#endif + break; + case eGameSetting_ExclusiveFullscreen: +#ifdef _WINDOWS64 + { + extern void SetExclusiveFullscreen(bool enabled); + SetExclusiveFullscreen(GetGameSettings(iPad, eGameSetting_ExclusiveFullscreen) != 0); + } +#endif + break; } } @@ -2344,6 +2363,38 @@ void CMinecraftApp::SetGameSettings(int iPad,eGameSetting eVal,unsigned char ucV } break; + case eGameSetting_VSync: + if(((GameSettingsA[iPad]->uiBitmaskValues&GAMESETTING_VSYNC)>>24)!=(ucVal&0x01)) + { + if(ucVal==1) + { + GameSettingsA[iPad]->uiBitmaskValues|=GAMESETTING_VSYNC; + } + else + { + GameSettingsA[iPad]->uiBitmaskValues&=~GAMESETTING_VSYNC; + } + ActionGameSettings(iPad,eVal); + GameSettingsA[iPad]->bSettingsChanged=true; + } + break; + + case eGameSetting_ExclusiveFullscreen: + if(((GameSettingsA[iPad]->uiBitmaskValues&GAMESETTING_EXCLUSIVEFULLSCREEN)>>25)!=(ucVal&0x01)) + { + if(ucVal==1) + { + GameSettingsA[iPad]->uiBitmaskValues|=GAMESETTING_EXCLUSIVEFULLSCREEN; + } + else + { + GameSettingsA[iPad]->uiBitmaskValues&=~GAMESETTING_EXCLUSIVEFULLSCREEN; + } + ActionGameSettings(iPad,eVal); + GameSettingsA[iPad]->bSettingsChanged=true; + } + break; + } } @@ -2479,6 +2530,12 @@ unsigned char CMinecraftApp::GetGameSettings(int iPad,eGameSetting eVal) case eGameSetting_PSVita_NetworkModeAdhoc: return (GameSettingsA[iPad]->uiBitmaskValues&GAMESETTING_PSVITANETWORKMODEADHOC)>>17; + case eGameSetting_VSync: + return (GameSettingsA[iPad]->uiBitmaskValues&GAMESETTING_VSYNC)>>24; + + case eGameSetting_ExclusiveFullscreen: + return (GameSettingsA[iPad]->uiBitmaskValues&GAMESETTING_EXCLUSIVEFULLSCREEN)>>25; + } return 0; } @@ -8103,6 +8160,16 @@ void CMinecraftApp::SetGameHostOption(unsigned int &uiHostSettings, eGameHostOpt uiHostSettings&=~GAME_HOST_OPTION_BITMASK_WORLDSIZE; uiHostSettings|=(GAME_HOST_OPTION_BITMASK_WORLDSIZE & (uiVal<19 stop + +NormalHardcore + +stop + + +HalfHardcore + +stop + + +FullHardcore + +stop + + +HalfPoisonHardcore + +stop + + +FullPoisonHardcore + +stop + + +NormalFlashHardcore + +stop + + +HalfFlashHardcore + +stop + + +FullFlashHardcore + +stop + + +HalfPoisonFlashHardcore + +stop + + +FullPoisonFlashHardcore + +stop + Border @@ -38274,6 +38324,16 @@ 0 Graphics\HUD\Health_Background_Flash.png + + +0 +Graphics\HUD\Health_Background_Hardcore.png + + + +0 +Graphics\HUD\Health_Background_Hardcore_Flash.png + Heart @@ -38399,6 +38459,66 @@ true Graphics\HUD\HorseHealth_Half_Flash.png + + +0 +false + + + + +0 +true +Graphics\HUD\Health_Half_Hardcore.png + + + +0 +true +Graphics\HUD\Health_Full_Hardcore.png + + + +0 +true +Graphics\HUD\Health_Half_Poison_Hardcore.png + + + +0 +true +Graphics\HUD\Health_Full_Poison_Hardcore.png + + + +0 +false + + + + +0 +true +Graphics\HUD\Health_Half_Flash_Hardcore.png + + + +0 +true +Graphics\HUD\Health_Full_Flash_Hardcore.png + + + +0 +true +Graphics\HUD\Health_Half_Poison_Flash_Hardcore.png + + + +0 +true +Graphics\HUD\Health_Full_Poison_Flash_Hardcore.png + diff --git a/Minecraft.Client/Common/Network/GameNetworkManager.cpp b/Minecraft.Client/Common/Network/GameNetworkManager.cpp index bd1d73c3..2105be9e 100644 --- a/Minecraft.Client/Common/Network/GameNetworkManager.cpp +++ b/Minecraft.Client/Common/Network/GameNetworkManager.cpp @@ -802,6 +802,9 @@ void CGameNetworkManager::CancelJoinGame(LPVOID lpParam) #ifdef _XBOX_ONE s_pPlatformNetworkManager->CancelJoinGame(); #endif +#ifdef _WINDOWS64 + WinsockNetLayer::CancelJoinGame(); +#endif } bool CGameNetworkManager::LeaveGame(bool bMigrateHost) diff --git a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp index e1cf6661..89b7cb6e 100644 --- a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp +++ b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp @@ -175,8 +175,6 @@ bool CPlatformNetworkManagerStub::Initialise(CGameNetworkManager *pGameNetworkMa m_bIsOfflineGame = false; #ifdef _WINDOWS64 m_bJoinPending = false; - m_joinLocalUsersMask = 0; - m_joinHostName[0] = 0; #endif m_pSearchParam = nullptr; m_SessionsUpdatedCallback = nullptr; @@ -288,6 +286,8 @@ void CPlatformNetworkManagerStub::DoWork() } } + // Async join finalization: when the background thread reports success, + // register players and transition the session to starting state. if (m_bJoinPending) { WinsockNetLayer::eJoinState state = WinsockNetLayer::GetJoinState(); @@ -296,7 +296,6 @@ void CPlatformNetworkManagerStub::DoWork() WinsockNetLayer::FinalizeJoin(); BYTE localSmallId = WinsockNetLayer::GetLocalSmallId(); - IQNet::m_player[localSmallId].m_smallId = localSmallId; IQNet::m_player[localSmallId].m_isRemote = false; IQNet::m_player[localSmallId].m_isHostPlayer = false; @@ -548,17 +547,15 @@ int CPlatformNetworkManagerStub::JoinGame(FriendSessionInfo* searchResult, int l IQNet::m_player[0].m_smallId = 0; IQNet::m_player[0].m_isRemote = true; IQNet::m_player[0].m_isHostPlayer = true; + // Remote host still maps to legacy host XUID in mixed old/new sessions. IQNet::m_player[0].m_resolvedXuid = Win64Xuid::GetLegacyEmbeddedHostXuid(); wcsncpy_s(IQNet::m_player[0].m_gamertag, 32, searchResult->data.hostName, _TRUNCATE); WinsockNetLayer::StopDiscovery(); - wcsncpy_s(m_joinHostName, 32, searchResult->data.hostName, _TRUNCATE); - m_joinLocalUsersMask = localUsersMask; - if (!WinsockNetLayer::BeginJoinGame(hostIP, hostPort)) { - app.DebugPrintf("Win64 LAN: Failed to connect to %s:%d\n", hostIP, hostPort); + app.DebugPrintf("Win64 LAN: Failed to start async join to %s:%d\n", hostIP, hostPort); return CGameNetworkManager::JOINGAME_FAIL_GENERAL; } @@ -978,6 +975,13 @@ void CPlatformNetworkManagerStub::ForceFriendsSessionRefresh() delete m_pSearchResults[i]; m_pSearchResults[i] = nullptr; } + +#ifdef _WINDOWS64 + // Immediately rebuild the session list from servers.db so that + // edits/deletions are visible as soon as the UI regains focus, + // rather than waiting for the next TickSearch() cycle. + SearchForGames(); +#endif } INetworkPlayer *CPlatformNetworkManagerStub::addNetworkPlayer(IQNetPlayer *pQNetPlayer) diff --git a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.h b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.h index 200f28ab..5799e840 100644 --- a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.h +++ b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.h @@ -76,11 +76,8 @@ private: bool m_bIsOfflineGame; bool m_bIsPrivateGame; int m_flagIndexSize; - #ifdef _WINDOWS64 bool m_bJoinPending; - int m_joinLocalUsersMask; - wchar_t m_joinHostName[32]; #endif // This is only maintained by the host, and is not valid on client machines diff --git a/Minecraft.Client/Common/UI/IUIScene_HUD.cpp b/Minecraft.Client/Common/UI/IUIScene_HUD.cpp index 643cf9d0..20139c52 100644 --- a/Minecraft.Client/Common/UI/IUIScene_HUD.cpp +++ b/Minecraft.Client/Common/UI/IUIScene_HUD.cpp @@ -5,6 +5,8 @@ #include "../../../Minecraft.World/net.minecraft.world.item.h" #include "../../../Minecraft.World/net.minecraft.world.entity.ai.attributes.h" #include "../../../Minecraft.World/net.minecraft.world.entity.monster.h" +#include "../../MultiPlayerLevel.h" +#include "../../../Minecraft.World\LevelData.h" #include "IUIScene_HUD.h" #include "UI.h" @@ -20,6 +22,7 @@ IUIScene_HUD::IUIScene_HUD() m_lastMaxHealth = 20; m_lastHealthBlink = false; m_lastHealthPoison = false; + m_lastHealthHardcore = false; m_iCurrentFood = -1; m_lastFoodPoison = false; m_lastAir = 10; @@ -94,9 +97,10 @@ void IUIScene_HUD::updateFrameTick() ShowHealth(false); ShowFood(false); ShowAir(false); - ShowArmour(false); + ShowArmour(false); ShowExpBar(false); - SetHealthAbsorb(0); + SetHealthAbsorb(0); + SetHardcoreMode(false); } if(pMinecraft->localplayers[iPad]->isRidingJumpable()) @@ -206,6 +210,12 @@ void IUIScene_HUD::renderPlayerHealth() // Update armour int armor = pMinecraft->localplayers[iPad]->getArmorValue(); + // Check hardcore mode + bool bHardcore = pMinecraft->level != nullptr + && pMinecraft->level->getLevelData() != nullptr + && pMinecraft->level->getLevelData()->isHardcore(); + SetHardcoreMode(bHardcore); + SetHealth(currentHealth, oldHealth, blink, bHasPoison || bHasWither, bHasWither); SetHealthAbsorb(totalAbsorption); diff --git a/Minecraft.Client/Common/UI/IUIScene_HUD.h b/Minecraft.Client/Common/UI/IUIScene_HUD.h index 0f643dd3..9a51da36 100644 --- a/Minecraft.Client/Common/UI/IUIScene_HUD.h +++ b/Minecraft.Client/Common/UI/IUIScene_HUD.h @@ -11,6 +11,7 @@ protected: int m_iCurrentHealth; int m_lastMaxHealth; bool m_lastHealthBlink, m_lastHealthPoison, m_lastHealthWither; + bool m_lastHealthHardcore; int m_iCurrentFood; bool m_lastFoodPoison; int m_lastAir, m_currentExtraAir; @@ -46,6 +47,7 @@ protected: virtual void SetActiveSlot(int slot) = 0; virtual void SetHealth(int iHealth, int iLastHealth, bool bBlink, bool bPoison, bool bWither) = 0; + virtual void SetHardcoreMode(bool bHardcore) = 0; virtual void SetFood(int iFood, int iLastFood, bool bPoison) = 0; virtual void SetAir(int iAir, int extra) = 0; virtual void SetArmour(int iArmour) = 0; diff --git a/Minecraft.Client/Common/UI/IUIScene_PauseMenu.cpp b/Minecraft.Client/Common/UI/IUIScene_PauseMenu.cpp index 41934c25..fe118919 100644 --- a/Minecraft.Client/Common/UI/IUIScene_PauseMenu.cpp +++ b/Minecraft.Client/Common/UI/IUIScene_PauseMenu.cpp @@ -410,11 +410,72 @@ int IUIScene_PauseMenu::ExitWorldThreadProc( void* lpParameter ) return S_OK; } +#ifdef _WINDOWS64 +static bool Win64_DeleteSaveDirectory(const wchar_t* wPath) +{ + wchar_t wSearch[MAX_PATH]; + swprintf_s(wSearch, MAX_PATH, L"%s\\*", wPath); + WIN32_FIND_DATAW fd; + HANDLE hFind = FindFirstFileW(wSearch, &fd); + if (hFind != INVALID_HANDLE_VALUE) + { + do + { + if (wcscmp(fd.cFileName, L".") == 0 || wcscmp(fd.cFileName, L"..") == 0) continue; + wchar_t wChild[MAX_PATH]; + swprintf_s(wChild, MAX_PATH, L"%s\\%s", wPath, fd.cFileName); + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + Win64_DeleteSaveDirectory(wChild); + else + DeleteFileW(wChild); + } while (FindNextFileW(hFind, &fd)); + FindClose(hFind); + } + return RemoveDirectoryW(wPath) != 0; +} +#endif // _WINDOWS64 + // This function performs the meat of exiting from a level. It should be called from a thread other than the main thread. void IUIScene_PauseMenu::_ExitWorld(LPVOID lpParameter) { Minecraft *pMinecraft=Minecraft::GetInstance(); + // 4J Added: Capture hardcore delete info before the server is destroyed +#ifdef _WINDOWS64 + bool shouldDeleteHardcoreWorld = false; + wstring hardcoreSaveFolderName; + if (MinecraftServer::getInstance() != nullptr && MinecraftServer::getInstance()->getDeleteWorldOnExit()) + { + shouldDeleteHardcoreWorld = true; + // Try 1: Use the save folder name stored by UIScene_LoadMenu::StartGameFromSave (works for existing saves) + hardcoreSaveFolderName = app.GetCurrentSaveFolderName(); + if (!hardcoreSaveFolderName.empty()) + { + app.DebugPrintf("Hardcore mode: save folder from app = '%ls'\n", hardcoreSaveFolderName.c_str()); + } + // Try 2: StorageManager (may work for new saves after first autosave) + if (hardcoreSaveFolderName.empty()) + { + char szSaveFolder[MAX_SAVEFILENAME_LENGTH] = {}; + StorageManager.GetSaveUniqueFilename(szSaveFolder); + if (szSaveFolder[0] != '\0') + { + wchar_t wSaveFolder[MAX_SAVEFILENAME_LENGTH] = {}; + mbstowcs(wSaveFolder, szSaveFolder, MAX_SAVEFILENAME_LENGTH - 1); + hardcoreSaveFolderName = wSaveFolder; + app.DebugPrintf("Hardcore mode: save folder from StorageManager = '%s'\n", szSaveFolder); + } + } + // Try 3: Stored during loadLevel + if (hardcoreSaveFolderName.empty()) + { + hardcoreSaveFolderName = MinecraftServer::getInstance()->getSaveFolderName(); + app.DebugPrintf("Hardcore mode: save folder from server = '%ls'\n", hardcoreSaveFolderName.c_str()); + } + MinecraftServer::getInstance()->setDeleteWorldOnExit(false); + } +#endif + int exitReasonStringId = pMinecraft->progressRenderer->getCurrentTitle(); int exitReasonTitleId = IDS_CONNECTION_LOST; @@ -625,6 +686,17 @@ void IUIScene_PauseMenu::_ExitWorld(LPVOID lpParameter) { Sleep(1); } + // 4J Added: Hardcore mode — delete world save data now that the server is fully stopped +#ifdef _WINDOWS64 + if (shouldDeleteHardcoreWorld && !hardcoreSaveFolderName.empty()) + { + wchar_t wFolderPath[MAX_PATH] = {}; + swprintf_s(wFolderPath, MAX_PATH, L"Windows64\\GameHDD\\%s", hardcoreSaveFolderName.c_str()); + app.DebugPrintf("Hardcore mode: Deleting world save folder '%ls'\n", wFolderPath); + Win64_DeleteSaveDirectory(wFolderPath); + } +#endif + pMinecraft->setLevel(nullptr,exitReasonStringId,nullptr,saveStats); TelemetryManager->Flush(); diff --git a/Minecraft.Client/Common/UI/UIBitmapFont.cpp b/Minecraft.Client/Common/UI/UIBitmapFont.cpp index 2b1518c4..52b97b9d 100644 --- a/Minecraft.Client/Common/UI/UIBitmapFont.cpp +++ b/Minecraft.Client/Common/UI/UIBitmapFont.cpp @@ -141,6 +141,9 @@ S32 UIBitmapFont::GetCodepointGlyph(U32 codepoint) // 4J-JEV: Change "right single quotation marks" to apostrophies. if (codepoint == 0x2019) codepoint = 0x27; + if (!m_cFontData->hasGlyph(codepoint)) + return IGGY_GLYPH_INVALID; + return m_cFontData->getGlyphId(codepoint); } @@ -253,19 +256,6 @@ rrbool UIBitmapFont::GetGlyphBitmap(S32 glyph,F32 pixel_scale,IggyBitmapCharacte while ( (0.5f + glyphScale) * truePixelScale < pixel_scale) glyphScale++; - // Debug: log each unique (font, pixel_scale) pair - { - static std::unordered_set s_loggedScaleKeys; - // Encode font pointer + quantized scale into a key to log each combo once - int scaleKey = (int)(pixel_scale * 100.0f) ^ (int)(uintptr_t)m_cFontData; - if (s_loggedScaleKeys.find(scaleKey) == s_loggedScaleKeys.end() && s_loggedScaleKeys.size() < 50) { - s_loggedScaleKeys.insert(scaleKey); - float tps = truePixelScale; - app.DebugPrintf("[FONT-DBG] GetGlyphBitmap: font=%s glyph=%d pixel_scale=%.3f truePixelScale=%.1f glyphScale=%.0f\n", - m_cFontData->getFontName().c_str(), glyph, pixel_scale, tps, glyphScale); - } - } - // 4J-JEV: Debug code to check which font sizes are being used. #if (!defined _CONTENT_PACKAGE) && (VERBOSE_FONT_OUTPUT > 0) diff --git a/Minecraft.Client/Common/UI/UIControl_Base.cpp b/Minecraft.Client/Common/UI/UIControl_Base.cpp index 13fe3375..46f32638 100644 --- a/Minecraft.Client/Common/UI/UIControl_Base.cpp +++ b/Minecraft.Client/Common/UI/UIControl_Base.cpp @@ -1,8 +1,10 @@ #include "stdafx.h" #include "UI.h" #include "UIControl.h" + #include "../../../Minecraft.World/StringHelpers.h" #include "../../../Minecraft.World/JavaMath.h" +#include "../../../Minecraft.World/ArabicShaping.h" UIControl_Base::UIControl_Base() { @@ -47,13 +49,16 @@ void UIControl_Base::tick() //app.DebugPrintf("Calling SetLabel - '%ls'\n", m_label.c_str()); m_bLabelChanged = false; + // Shape the text before sending to Iggy; m_label stays unshaped for future updates + wstring shaped = shapeArabicText(m_label.getString()); + IggyDataValue result; IggyDataValue value[1]; value[0].type = IGGY_DATATYPE_string_UTF16; IggyStringUTF16 stringVal; - stringVal.string = (IggyUTF16*) m_label.c_str(); - stringVal.length = m_label.length(); + stringVal.string = (IggyUTF16*) shaped.c_str(); + stringVal.length = (int)shaped.length(); value[0].string16 = stringVal; IggyResult out = IggyPlayerCallMethodRS ( m_parentScene->getMovie() , &result, getIggyValuePath() , m_setLabelFunc , 1 , value ); @@ -71,13 +76,16 @@ void UIControl_Base::setLabel(UIString label, bool instant, bool force) { m_bLabelChanged = false; + // Shape the text before sending to Iggy; m_label stays unshaped for future updates + wstring shaped = shapeArabicText(m_label.getString()); + IggyDataValue result; IggyDataValue value[1]; value[0].type = IGGY_DATATYPE_string_UTF16; IggyStringUTF16 stringVal; - stringVal.string = (IggyUTF16*)m_label.c_str(); - stringVal.length = m_label.length(); + stringVal.string = (IggyUTF16*) shaped.c_str(); + stringVal.length = (int)shaped.length(); value[0].string16 = stringVal; IggyResult out = IggyPlayerCallMethodRS ( m_parentScene->getMovie() , &result, getIggyValuePath() , m_setLabelFunc , 1 , value ); diff --git a/Minecraft.Client/Common/UI/UIControl_Label.cpp b/Minecraft.Client/Common/UI/UIControl_Label.cpp index d6b77de9..14ea098c 100644 --- a/Minecraft.Client/Common/UI/UIControl_Label.cpp +++ b/Minecraft.Client/Common/UI/UIControl_Label.cpp @@ -2,6 +2,7 @@ #include "UI.h" #include "UIControl_Label.h" #include "..\..\..\Minecraft.World\StringHelpers.h" +#include "..\..\..\Minecraft.World\ArabicShaping.h" UIControl_Label::UIControl_Label() { @@ -38,13 +39,15 @@ void UIControl_Label::init(UIString label, int id) m_label = label; m_id = id; + wstring shaped = shapeArabicText(m_label.getString()); + IggyDataValue result; IggyDataValue value[2]; value[0].type = IGGY_DATATYPE_string_UTF16; IggyStringUTF16 stringVal; - stringVal.string = (IggyUTF16*)label.c_str(); - stringVal.length = label.length(); + stringVal.string = (IggyUTF16*)shaped.c_str(); + stringVal.length = (int)shaped.length(); value[0].string16 = stringVal; value[1].type = IGGY_DATATYPE_number; diff --git a/Minecraft.Client/Common/UI/UIControl_PlayerSkinPreview.cpp b/Minecraft.Client/Common/UI/UIControl_PlayerSkinPreview.cpp index aa21fc6b..39432370 100644 --- a/Minecraft.Client/Common/UI/UIControl_PlayerSkinPreview.cpp +++ b/Minecraft.Client/Common/UI/UIControl_PlayerSkinPreview.cpp @@ -15,6 +15,56 @@ //#define SKIN_PREVIEW_BOB_ANIM #define SKIN_PREVIEW_WALKING_ANIM +#ifdef _WINDOWS64 +// Frame-rate-independent animation scaling. +// The skin preview animations were designed for ~60fps (VSync on). +// With uncapped FPS, each frame's contribution must be scaled down. +// The scale is computed once per frame (keyed by frame counter) so +// that multiple skin previews rendered in the same frame all use the +// same value instead of measuring near-zero deltas between each other. +static float s_skinAnimCachedScale = 1.0f; +static double s_skinAnimLastTime = 0.0; +static double s_skinAnimFreqInv = 0.0; + +static float GetSkinAnimDeltaScale() +{ + // Use the main loop's frame counter to detect a new frame. + // GetTickCount changes every ~16ms, but we need per-frame detection. + // Use a simple time threshold: if <0.1ms since last call, same frame. + if (s_skinAnimFreqInv == 0.0) + { + LARGE_INTEGER freq; + QueryPerformanceFrequency(&freq); + s_skinAnimFreqInv = 1.0 / (double)freq.QuadPart; + } + LARGE_INTEGER now; + QueryPerformanceCounter(&now); + double currentTime = (double)now.QuadPart * s_skinAnimFreqInv; + + // If less than 0.5ms since last call, assume same frame -- reuse cached scale + double elapsed = currentTime - s_skinAnimLastTime; + if (s_skinAnimLastTime != 0.0 && elapsed < 0.0005) + { + return s_skinAnimCachedScale; + } + + if (s_skinAnimLastTime == 0.0) + { + s_skinAnimLastTime = currentTime; + s_skinAnimCachedScale = 1.0f; + return 1.0f; + } + + s_skinAnimLastTime = currentTime; + + const double kBaselineFrameTime = 1.0 / 60.0; + float scale = static_cast(elapsed / kBaselineFrameTime); + if (scale > 3.0f) scale = 3.0f; + s_skinAnimCachedScale = scale; + return scale; +} +#endif + UIControl_PlayerSkinPreview::UIControl_PlayerSkinPreview() { UIControl::setControlType(UIControl::ePlayerSkinPreview); @@ -306,7 +356,11 @@ void UIControl_PlayerSkinPreview::render(EntityRenderer *renderer, double x, dou break; case e_SkinPreviewAnimation_Attacking: model->holdingRightHand = true; +#ifdef _WINDOWS64 + m_swingTime += GetSkinAnimDeltaScale(); +#else m_swingTime++; +#endif if (m_swingTime >= (Player::SWING_DURATION * 3) ) { m_swingTime = 0; @@ -359,8 +413,16 @@ void UIControl_PlayerSkinPreview::render(EntityRenderer *renderer, double x, dou #ifdef SKIN_PREVIEW_WALKING_ANIM m_walkAnimSpeedO = m_walkAnimSpeed; +#ifdef _WINDOWS64 + { + float animScale = GetSkinAnimDeltaScale(); + m_walkAnimSpeed += (0.1f - m_walkAnimSpeed) * 0.4f * animScale; + m_walkAnimPos += m_walkAnimSpeed * animScale; + } +#else m_walkAnimSpeed += (0.1f - m_walkAnimSpeed) * 0.4f; m_walkAnimPos += m_walkAnimSpeed; +#endif float ws = m_walkAnimSpeedO + (m_walkAnimSpeed - m_walkAnimSpeedO) * a; float wp = m_walkAnimPos - m_walkAnimSpeed * (1 - a); #else diff --git a/Minecraft.Client/Common/UI/UIControl_SaveList.cpp b/Minecraft.Client/Common/UI/UIControl_SaveList.cpp index 5ae9c8f0..00aaa93a 100644 --- a/Minecraft.Client/Common/UI/UIControl_SaveList.cpp +++ b/Minecraft.Client/Common/UI/UIControl_SaveList.cpp @@ -1,6 +1,7 @@ #include "stdafx.h" #include "UI.h" #include "UIControl_SaveList.h" +#include "..\..\..\Minecraft.World\ArabicShaping.h" bool UIControl_SaveList::setupControl(UIScene *scene, IggyValuePath *parent, const string &controlName) { @@ -69,12 +70,14 @@ void UIControl_SaveList::addItem(const string &label, const wstring &iconName, i void UIControl_SaveList::addItem(const wstring &label, const wstring &iconName, int data) { + wstring shaped = shapeArabicText(label); + IggyDataValue result; IggyDataValue value[3]; IggyStringUTF16 stringVal; - stringVal.string = (IggyUTF16*)label.c_str(); - stringVal.length = static_cast(label.length()); + stringVal.string = (IggyUTF16*)shaped.c_str(); + stringVal.length = static_cast(shaped.length()); value[0].type = IGGY_DATATYPE_string_UTF16; value[0].string16 = stringVal; diff --git a/Minecraft.Client/Common/UI/UIControl_TextInput.cpp b/Minecraft.Client/Common/UI/UIControl_TextInput.cpp index a6470eca..ea00be9f 100644 --- a/Minecraft.Client/Common/UI/UIControl_TextInput.cpp +++ b/Minecraft.Client/Common/UI/UIControl_TextInput.cpp @@ -220,7 +220,7 @@ UIControl_TextInput::EDirectEditResult UIControl_TextInput::tickDirectEdit() sanitized.reserve(pasted.length()); for (wchar_t pc : pasted) - { + { if (pc >= 0x20) // Keep printable characters { if (m_iCharLimit > 0 && (m_editBuffer.length() + sanitized.length()) >= (size_t)m_iCharLimit) diff --git a/Minecraft.Client/Common/UI/UIController.cpp b/Minecraft.Client/Common/UI/UIController.cpp index d582fe93..90ecdc4a 100644 --- a/Minecraft.Client/Common/UI/UIController.cpp +++ b/Minecraft.Client/Common/UI/UIController.cpp @@ -13,6 +13,7 @@ #include "../../EnderDragonRenderer.h" #include "../../MultiPlayerLocalPlayer.h" #include "UIFontData.h" +#include "UIUnicodeBitmapFont.h" #include "UISplitScreenHelpers.h" #ifdef _WINDOWS64 #include "../../Windows64/KeyboardMouseInput.h" @@ -193,6 +194,7 @@ UIController::UIController() m_mcTTFFont = nullptr; m_moj7 = nullptr; m_moj11 = nullptr; + m_unicodeBitmapFont = nullptr; // 4J-JEV: It's important that these remain the same, unless updateCurrentLanguage is going to be called. m_eCurrentFont = m_eTargetFont = eFont_NotLoaded; @@ -307,6 +309,14 @@ void UIController::postInit() IggySetAS3ExternalFunctionCallbackUTF16 ( &UIController::ExternalFunctionCallback, this ); IggySetTextureSubstitutionCallbacks ( &UIController::TextureSubstitutionCreateCallback , &UIController::TextureSubstitutionDestroyCallback, this ); + // Load a unicode bitmap font as Iggy's global fallback for characters not + // covered by the Mojangles bitmap font (CJK, Thai, Arabic, Korean, etc.). + // Uses the same glyph page PNGs as the legacy Font class, with matching + // Mojangles metrics for correct vertical alignment. + m_unicodeBitmapFont = new UIUnicodeBitmapFont("Mojangles_Unicode_Bitmap", SFontData::Mojangles_7); + m_unicodeBitmapFont->registerFont(); + IggyFontSetFallbackFontUTF8("Mojangles_Unicode_Bitmap", -1, IGGY_FONTFLAG_none); + SetupFont(); // loadSkins(); diff --git a/Minecraft.Client/Common/UI/UIController.h b/Minecraft.Client/Common/UI/UIController.h index 08a5ba08..4a189d4d 100644 --- a/Minecraft.Client/Common/UI/UIController.h +++ b/Minecraft.Client/Common/UI/UIController.h @@ -7,6 +7,7 @@ using namespace std; class UIAbstractBitmapFont; class UIBitmapFont; +class UIUnicodeBitmapFont; class UITTFFont; class UIComponent_DebugUIConsole; class UIComponent_DebugUIMarketingGuide; @@ -63,6 +64,7 @@ private: UIAbstractBitmapFont *m_mcBitmapFont; UITTFFont *m_mcTTFFont; UIBitmapFont *m_moj7, *m_moj11; + UIUnicodeBitmapFont *m_unicodeBitmapFont; std::mt19937 m_randomGenerator; std::uniform_real_distribution m_randomDistribution; diff --git a/Minecraft.Client/Common/UI/UIFontData.cpp b/Minecraft.Client/Common/UI/UIFontData.cpp index 715d08dd..dc9f70cd 100644 --- a/Minecraft.Client/Common/UI/UIFontData.cpp +++ b/Minecraft.Client/Common/UI/UIFontData.cpp @@ -335,6 +335,11 @@ bool CFontData::unicodeIsWhitespace(unsigned int unicode) return false; } +bool CFontData::hasGlyph(unsigned int unicodepoint) +{ + return m_unicodeMap.find(unicodepoint) != m_unicodeMap.end(); +} + void CFontData::moveCursor(unsigned char *&cursor, unsigned int dx, unsigned int dy) { cursor += (dy * m_sFontData->m_uiGlyphMapX) + dx; diff --git a/Minecraft.Client/Common/UI/UIFontData.h b/Minecraft.Client/Common/UI/UIFontData.h index b7e38ffa..7c4ac861 100644 --- a/Minecraft.Client/Common/UI/UIFontData.h +++ b/Minecraft.Client/Common/UI/UIFontData.h @@ -125,6 +125,9 @@ public: // Returns true if this unicodepoint is whitespace bool unicodeIsWhitespace(unsigned int unicodepoint); + // Returns true if this unicodepoint exists in the font's glyph map. + bool hasGlyph(unsigned int unicodepoint); + private: // Move a pointer in an image dx pixels right and dy pixels down, wrap around in either dimension leads to unknown behaviour. diff --git a/Minecraft.Client/Common/UI/UIScene_ConnectingProgress.cpp b/Minecraft.Client/Common/UI/UIScene_ConnectingProgress.cpp index 40557cd5..e0e388db 100644 --- a/Minecraft.Client/Common/UI/UIScene_ConnectingProgress.cpp +++ b/Minecraft.Client/Common/UI/UIScene_ConnectingProgress.cpp @@ -4,7 +4,6 @@ #include "..\..\Minecraft.h" #ifdef _WINDOWS64 #include "..\..\Windows64\Network\WinsockNetLayer.h" -#include "..\..\..\Minecraft.World\DisconnectPacket.h" static int ConnectingProgress_OnRejectedDialogOK(LPVOID, int iPad, const C4JStorage::EMessageResult) { @@ -53,10 +52,10 @@ UIScene_ConnectingProgress::UIScene_ConnectingProgress(int iPad, void *_initData m_cancelFuncParam = param->cancelFuncParam; m_removeLocalPlayer = false; m_showingButton = false; - #ifdef _WINDOWS64 WinsockNetLayer::eJoinState initState = WinsockNetLayer::GetJoinState(); - m_asyncJoinActive = (initState != WinsockNetLayer::eJoinState_Idle && initState != WinsockNetLayer::eJoinState_Cancelled); + m_asyncJoinActive = (initState == WinsockNetLayer::eJoinState_Connecting || + initState == WinsockNetLayer::eJoinState_Success); m_asyncJoinFailed = false; #endif } @@ -72,12 +71,12 @@ void UIScene_ConnectingProgress::updateTooltips() #ifdef _WINDOWS64 if (m_asyncJoinActive) { - ui.SetTooltips( m_iPad, -1, IDS_TOOLTIPS_BACK); + ui.SetTooltips(m_iPad, -1, IDS_TOOLTIPS_BACK); return; } if (m_asyncJoinFailed) { - ui.SetTooltips( m_iPad, IDS_TOOLTIPS_SELECT, -1); + ui.SetTooltips(m_iPad, IDS_TOOLTIPS_SELECT, -1); return; } #endif @@ -94,78 +93,54 @@ void UIScene_ConnectingProgress::tick() if (m_asyncJoinActive) { WinsockNetLayer::eJoinState state = WinsockNetLayer::GetJoinState(); - if (state == WinsockNetLayer::eJoinState_Connecting) + switch (state) + { + case WinsockNetLayer::eJoinState_Connecting: { - // connecting............. int attempt = WinsockNetLayer::GetJoinAttempt(); int maxAttempts = WinsockNetLayer::GetJoinMaxAttempts(); - char buf[128]; - if (attempt <= 1) - sprintf_s(buf, "Connecting..."); + wchar_t buf[128]; + if (attempt > 1) + swprintf_s(buf, L"Connecting... (attempt %d/%d)", attempt, maxAttempts); else - sprintf_s(buf, "Connecting failed, trying again (%d/%d)", attempt, maxAttempts); - wchar_t wbuf[128]; - mbstowcs(wbuf, buf, 128); - m_labelTitle.setLabel(wstring(wbuf)); + swprintf_s(buf, L"Connecting..."); + m_labelTitle.setLabel(buf); + break; } - else if (state == WinsockNetLayer::eJoinState_Success) - { + case WinsockNetLayer::eJoinState_Success: m_asyncJoinActive = false; - // go go go - } - else if (state == WinsockNetLayer::eJoinState_Cancelled) - { - // cancel + m_labelTitle.setLabel(L"Joining world..."); + break; + case WinsockNetLayer::eJoinState_Cancelled: m_asyncJoinActive = false; navigateBack(); - } - else if (state == WinsockNetLayer::eJoinState_Rejected) + break; + case WinsockNetLayer::eJoinState_Rejected: { - // server full and banned are passed differently compared to other disconnects it seems m_asyncJoinActive = false; DisconnectPacket::eDisconnectReason reason = WinsockNetLayer::GetJoinRejectReason(); - int exitReasonStringId; - switch (reason) - { - case DisconnectPacket::eDisconnect_ServerFull: - exitReasonStringId = IDS_DISCONNECTED_SERVER_FULL; - break; - case DisconnectPacket::eDisconnect_Banned: - exitReasonStringId = IDS_DISCONNECTED_KICKED; - break; - default: - exitReasonStringId = IDS_CONNECTION_LOST_SERVER; - break; - } + int reasonStringId = IDS_CONNECTION_LOST_SERVER; + if (reason == DisconnectPacket::eDisconnect_ServerFull) + reasonStringId = IDS_DISCONNECTED_SERVER_FULL; + else if (reason == DisconnectPacket::eDisconnect_Kicked) + reasonStringId = IDS_DISCONNECTED_KICKED; + UINT uiIDA[1]; uiIDA[0] = IDS_CONFIRM_OK; - ui.RequestErrorMessage(IDS_CONNECTION_FAILED, exitReasonStringId, uiIDA, 1, ProfileManager.GetPrimaryPad(), ConnectingProgress_OnRejectedDialogOK, nullptr, nullptr); + ui.RequestErrorMessage(IDS_CONNECTION_FAILED, reasonStringId, uiIDA, 1, m_iPad, ConnectingProgress_OnRejectedDialogOK, nullptr); + break; } - else if (state == WinsockNetLayer::eJoinState_Failed) - { - // FAIL + case WinsockNetLayer::eJoinState_Failed: m_asyncJoinActive = false; m_asyncJoinFailed = true; - - int maxAttempts = WinsockNetLayer::GetJoinMaxAttempts(); - char buf[256]; - sprintf_s(buf, "Failed to connect after %d attempts. The server may be unavailable.", maxAttempts); - wchar_t wbuf[256]; - mbstowcs(wbuf, buf, 256); - - // TIL that these exist - // not going to use a actual popup due to it requiring messing with strings which can really mess things up - // i dont trust myself with that - // these need to be touched up later as teh button is a bit offset - m_labelTitle.setLabel(L"Unable to connect to server"); - m_progressBar.setLabel(wstring(wbuf)); - m_progressBar.showBar(false); - m_progressBar.setVisible(true); + m_labelTitle.setLabel(app.GetString(IDS_CONNECTION_FAILED)); m_buttonConfirm.setVisible(true); m_showingButton = true; - m_controlTimer.setVisible(false); + updateTooltips(); + break; + default: + break; } - return; } #endif @@ -202,7 +177,6 @@ void UIScene_ConnectingProgress::handleGainFocus(bool navBack) void UIScene_ConnectingProgress::handleLoseFocus() { if (!m_runFailTimer) return; - int millisecsLeft = getTimer(0)->targetTime - System::currentTimeMillis(); int millisecsTaken = getTimer(0)->duration - millisecsLeft; app.DebugPrintf("\n"); @@ -316,7 +290,6 @@ void UIScene_ConnectingProgress::handleInput(int iPad, int key, bool repeat, boo switch(key) { -// 4J-PB - Removed the option to cancel join - it didn't work anyway #ifdef _WINDOWS64 case ACTION_MENU_CANCEL: if (pressed && m_asyncJoinActive) @@ -325,9 +298,11 @@ void UIScene_ConnectingProgress::handleInput(int iPad, int key, bool repeat, boo WinsockNetLayer::CancelJoinGame(); navigateBack(); handled = true; + return; } break; #endif +// 4J-PB - Removed the option to cancel join - it didn't work anyway // case ACTION_MENU_CANCEL: // { // if(m_cancelFunc != nullptr) @@ -374,8 +349,8 @@ void UIScene_ConnectingProgress::handlePress(F64 controlId, F64 childId) if (m_asyncJoinFailed) { navigateBack(); + break; } - else #endif if( m_iPad != ProfileManager.GetPrimaryPad() && g_NetworkManager.IsInSession() ) { diff --git a/Minecraft.Client/Common/UI/UIScene_ConnectingProgress.h b/Minecraft.Client/Common/UI/UIScene_ConnectingProgress.h index eaaea7f6..09eddc18 100644 --- a/Minecraft.Client/Common/UI/UIScene_ConnectingProgress.h +++ b/Minecraft.Client/Common/UI/UIScene_ConnectingProgress.h @@ -12,7 +12,6 @@ private: bool m_showingButton; void (*m_cancelFunc)(LPVOID param); LPVOID m_cancelFuncParam; - #ifdef _WINDOWS64 bool m_asyncJoinActive; bool m_asyncJoinFailed; diff --git a/Minecraft.Client/Common/UI/UIScene_CreateWorldMenu.cpp b/Minecraft.Client/Common/UI/UIScene_CreateWorldMenu.cpp index bc56134b..757e0210 100644 --- a/Minecraft.Client/Common/UI/UIScene_CreateWorldMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_CreateWorldMenu.cpp @@ -26,6 +26,8 @@ #define GAME_CREATE_ONLINE_TIMER_ID 0 #define GAME_CREATE_ONLINE_TIMER_TIME 100 +static bool s_bHardcore = false; // 4J Added: tracks when difficulty slider is at Hardcore position (file-scope to avoid header layout changes) + int UIScene_CreateWorldMenu::m_iDifficultyTitleSettingA[4]= { IDS_DIFFICULTY_TITLE_PEACEFUL, @@ -59,8 +61,9 @@ UIScene_CreateWorldMenu::UIScene_CreateWorldMenu(int iPad, void *initData, UILay WCHAR TempString[256]; swprintf( (WCHAR *)TempString, 256, L"%ls: %ls", app.GetString( IDS_SLIDER_DIFFICULTY ),app.GetString(m_iDifficultyTitleSettingA[app.GetGameSettings(m_iPad,eGameSetting_Difficulty)])); - m_sliderDifficulty.init(TempString,eControl_Difficulty,0,3,app.GetGameSettings(m_iPad,eGameSetting_Difficulty)); + m_sliderDifficulty.init(TempString,eControl_Difficulty,0,4,app.GetGameSettings(m_iPad,eGameSetting_Difficulty)); + s_bHardcore = false; m_MoreOptionsParams.bGenerateOptions=TRUE; m_MoreOptionsParams.bStructures=TRUE; m_MoreOptionsParams.bFlatWorld=FALSE; @@ -458,6 +461,8 @@ void UIScene_CreateWorldMenu::handlePress(F64 controlId, F64 childId) } break; case eControl_GameModeToggle: + if (s_bHardcore) + break; // Hardcore mode locks game mode to Survival switch(m_iGameModeId) { case 0: // Creative @@ -470,7 +475,7 @@ void UIScene_CreateWorldMenu::handlePress(F64 controlId, F64 childId) m_iGameModeId = GameType::ADVENTURE->getId(); m_bGameModeCreative = false; break; - case 2: // Survival + case 2: // Survival m_buttonGamemode.setLabel(app.GetString(IDS_GAMEMODE_SURVIVAL)); m_iGameModeId = GameType::SURVIVAL->getId(); m_bGameModeCreative = false; @@ -654,9 +659,22 @@ void UIScene_CreateWorldMenu::handleSliderMove(F64 sliderId, F64 currentValue) case eControl_Difficulty: m_sliderDifficulty.handleSliderMove(value); - app.SetGameSettings(m_iPad,eGameSetting_Difficulty,value); - swprintf( (WCHAR *)TempString, 256, L"%ls: %ls", app.GetString( IDS_SLIDER_DIFFICULTY ),app.GetString(m_iDifficultyTitleSettingA[value])); + // 4J Added: Difficulty value 4 = Hardcore (store actual difficulty as Hard, track hardcore separately) + s_bHardcore = (value >= 4); + app.SetGameSettings(m_iPad, eGameSetting_Difficulty, s_bHardcore ? 3 : value); + if (value >= 4) + swprintf( (WCHAR *)TempString, 256, L"%ls: %ls", app.GetString( IDS_SLIDER_DIFFICULTY ), L"Hardcore"); + else + swprintf( (WCHAR *)TempString, 256, L"%ls: %ls", app.GetString( IDS_SLIDER_DIFFICULTY ),app.GetString(m_iDifficultyTitleSettingA[value])); m_sliderDifficulty.setLabel(TempString); + + // Hardcore locks game mode to Survival + if (s_bHardcore && m_iGameModeId != GameType::SURVIVAL->getId()) + { + m_iGameModeId = GameType::SURVIVAL->getId(); + m_bGameModeCreative = false; + m_buttonGamemode.setLabel(app.GetString(IDS_GAMEMODE_SURVIVAL)); + } break; } } @@ -823,7 +841,7 @@ void UIScene_CreateWorldMenu::checkStateAndStartGame() // 4J Stu - This is a bit messy and is due to the library incorrectly returning false for IsSignedInLive if the npAvailability isn't SCE_OK UINT uiIDA[1]; uiIDA[0]=IDS_OK; - ui.RequestErrorMessage(IDS_ONLINE_SERVICE_TITLE, IDS_CONTENT_RESTRICTION, uiIDA, 1, iPadNotSignedInLive); + ui.RequestAlertMessage(IDS_ONLINE_SERVICE_TITLE, IDS_CONTENT_RESTRICTION, uiIDA, 1, iPadNotSignedInLive); } else { @@ -1113,6 +1131,10 @@ void UIScene_CreateWorldMenu::CreateGame(UIScene_CreateWorldMenu* pClass, DWORD StorageManager.ResetSaveData(); // Make our next save default to the name of the level StorageManager.SetSaveTitle((wchar_t *)wWorldName.c_str()); +#ifdef _WINDOWS64 + // New world — save folder doesn't exist yet, clear for now (will be set after first autosave) + app.SetCurrentSaveFolderName(L""); +#endif wstring wSeed; if(!pClass->m_MoreOptionsParams.seed.empty() ) @@ -1179,7 +1201,9 @@ void UIScene_CreateWorldMenu::CreateGame(UIScene_CreateWorldMenu* pClass, DWORD Minecraft *pMinecraft = Minecraft::GetInstance(); pMinecraft->skins->selectTexturePackById(pClass->m_MoreOptionsParams.dwTexturePack); - app.SetGameHostOption(eGameHostOption_Difficulty,Minecraft::GetInstance()->options->difficulty); + // 4J Added: If hardcore was selected on difficulty slider, set difficulty to Hard and enable hardcore flag + app.SetGameHostOption(eGameHostOption_Difficulty, Minecraft::GetInstance()->options->difficulty); + app.SetGameHostOption(eGameHostOption_Hardcore, s_bHardcore ? 1 : 0); app.SetGameHostOption(eGameHostOption_FriendsOfFriends,pClass->m_MoreOptionsParams.bAllowFriendsOfFriends); app.SetGameHostOption(eGameHostOption_Gamertags,app.GetGameSettings(pClass->m_iPad,eGameSetting_GamertagsVisible)?1:0); diff --git a/Minecraft.Client/Common/UI/UIScene_DeathMenu.cpp b/Minecraft.Client/Common/UI/UIScene_DeathMenu.cpp index 84b1f187..00991ca7 100644 --- a/Minecraft.Client/Common/UI/UIScene_DeathMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_DeathMenu.cpp @@ -2,25 +2,46 @@ #include "UI.h" #include "UIScene_DeathMenu.h" #include "IUIScene_PauseMenu.h" + #include "../../Minecraft.h" #include "../../MultiPlayerLocalPlayer.h" +#include "../../MultiPlayerLevel.h" +#include "../../MinecraftServer.h" + +#include "../../../Minecraft.World/net.minecraft.world.level.storage.h" UIScene_DeathMenu::UIScene_DeathMenu(int iPad, void *initData, UILayer *parentLayer) : UIScene(iPad, parentLayer) { // Setup all the Iggy references we need for this scene initialiseMovie(); - m_buttonRespawn.init(app.GetString(IDS_RESPAWN),eControl_Respawn); m_buttonExitGame.init(app.GetString(IDS_EXIT_GAME),eControl_ExitGame); m_labelTitle.setLabel(app.GetString(IDS_YOU_DIED)); + // 4J Added: In hardcore mode, disable respawn and show hardcore death message + Minecraft *pMC = Minecraft::GetInstance(); + bool isHardcore = false; + if (pMC != nullptr && pMC->level != nullptr) + { + isHardcore = pMC->level->getLevelData()->isHardcore(); + } + + if (isHardcore) + { + m_buttonRespawn.init(app.GetString(IDS_HARDCORE_DEATH_MESSAGE), eControl_Respawn); + m_buttonRespawn.setVisible(false); + } + else + { + m_buttonRespawn.init(app.GetString(IDS_RESPAWN), eControl_Respawn); + } + m_bIgnoreInput = false; - Minecraft *pMinecraft = Minecraft::GetInstance(); - if(pMinecraft != nullptr && pMinecraft->localgameModes[iPad] != nullptr ) + if(pMC != nullptr && pMC->localgameModes[iPad] != nullptr ) { - TutorialMode *gameMode = static_cast(pMinecraft->localgameModes[iPad]); + TutorialMode *gameMode = static_cast(pMC->localgameModes[iPad]); // This just allows it to be shown gameMode->getTutorial()->showTutorialPopup(false); @@ -84,8 +105,16 @@ void UIScene_DeathMenu::handlePress(F64 controlId, F64 childId) switch(static_cast(controlId)) { case eControl_Respawn: - m_bIgnoreInput = true; - app.SetAction(m_iPad,eAppAction_Respawn); + { + // 4J Added: Safeguard - don't respawn in hardcore mode + Minecraft *pMC = Minecraft::GetInstance(); + if (pMC != nullptr && pMC->level != nullptr && pMC->level->getLevelData()->isHardcore()) + { + break; + } + m_bIgnoreInput = true; + app.SetAction(m_iPad,eAppAction_Respawn); + } #ifdef _DURANGO //InputManager.SetEnabledGtcButtons(_360_GTC_MENU|_360_GTC_PAUSE|_360_GTC_VIEW); #endif @@ -109,7 +138,16 @@ void UIScene_DeathMenu::handlePress(F64 controlId, F64 childId) playTime = static_cast(pMinecraft->localplayers[m_iPad]->getSessionTimer()); } TelemetryManager->RecordLevelExit(m_iPad, eSen_LevelExitStatus_Failed); - + + // 4J Added: Hardcore mode — skip save dialog, exit without saving, delete world + if (pMinecraft->level != nullptr && pMinecraft->level->getLevelData()->isHardcore() && g_NetworkManager.IsHost()) + { + MinecraftServer::getInstance()->setSaveOnExit(false); + MinecraftServer::getInstance()->setDeleteWorldOnExit(true); + app.SetAction(m_iPad, eAppAction_ExitWorld); + break; + } + #if defined (_XBOX_ONE) || defined(__ORBIS__) if(g_NetworkManager.IsHost() && StorageManager.GetSaveDisabled()) { diff --git a/Minecraft.Client/Common/UI/UIScene_EndPoem.cpp b/Minecraft.Client/Common/UI/UIScene_EndPoem.cpp index 29789815..1676f3c4 100644 --- a/Minecraft.Client/Common/UI/UIScene_EndPoem.cpp +++ b/Minecraft.Client/Common/UI/UIScene_EndPoem.cpp @@ -50,13 +50,18 @@ UIScene_EndPoem::UIScene_EndPoem(int iPad, void *initData, UILayer *parentLayer) Minecraft *pMinecraft = Minecraft::GetInstance(); wstring playerName = L""; - if(pMinecraft->localplayers[ui.GetWinUserIndex()] != nullptr) + unsigned int winIdx = ui.GetWinUserIndex(); + if(winIdx < XUSER_MAX_COUNT && pMinecraft->localplayers[winIdx] != nullptr) { - playerName = escapeXML( pMinecraft->localplayers[ui.GetWinUserIndex()]->getDisplayName() ); + playerName = escapeXML( pMinecraft->localplayers[winIdx]->getDisplayName() ); + } + else if(pMinecraft->localplayers[ProfileManager.GetPrimaryPad()] != nullptr) + { + playerName = escapeXML( pMinecraft->localplayers[ProfileManager.GetPrimaryPad()]->getDisplayName() ); } else { - playerName = escapeXML( pMinecraft->localplayers[ProfileManager.GetPrimaryPad()]->getDisplayName() ); + playerName = L"Player"; } noNoiseString = replaceAll(noNoiseString,L"{*PLAYER*}",playerName); diff --git a/Minecraft.Client/Common/UI/UIScene_HUD.cpp b/Minecraft.Client/Common/UI/UIScene_HUD.cpp index 14f35908..676542d3 100644 --- a/Minecraft.Client/Common/UI/UIScene_HUD.cpp +++ b/Minecraft.Client/Common/UI/UIScene_HUD.cpp @@ -664,6 +664,23 @@ void UIScene_HUD::SetHorseJumpBarProgress(float progress) } } +void UIScene_HUD::SetHardcoreMode(bool bHardcore) +{ + IggyDataValue result; + IggyDataValue value[1]; + value[0].type = IGGY_DATATYPE_boolean; + value[0].boolval = bHardcore; + IggyResult out = IggyPlayerCallMethodRS ( getMovie() , &result, IggyPlayerRootPath( getMovie() ), m_funcSetHardcore , 1 , value ); + + // When hardcore state changes, invalidate SetHealth's dirty check + // so hearts are redrawn with the correct frame set on the next tick + if(bHardcore != m_lastHealthHardcore) + { + m_lastHealthHardcore = bHardcore; + m_lastMaxHealth = -1; + } +} + void UIScene_HUD::SetHealthAbsorb(int healthAbsorb) { if(m_iCurrentHealthAbsorb != healthAbsorb) diff --git a/Minecraft.Client/Common/UI/UIScene_HUD.h b/Minecraft.Client/Common/UI/UIScene_HUD.h index 04468c8e..4739ec8f 100644 --- a/Minecraft.Client/Common/UI/UIScene_HUD.h +++ b/Minecraft.Client/Common/UI/UIScene_HUD.h @@ -25,6 +25,7 @@ protected: IggyName m_funcRepositionHud, m_funcSetDisplayName, m_funcSetTooltipsEnabled; IggyName m_funcSetRidingHorse, m_funcSetHorseHealth, m_funcSetHorseJumpBarProgress; IggyName m_funcSetHealthAbsorb; + IggyName m_funcSetHardcore; UI_BEGIN_MAP_ELEMENTS_AND_NAMES(UIScene) UI_MAP_ELEMENT(m_labelChatText[0],"Label1") UI_MAP_ELEMENT(m_labelChatText[1],"Label2") @@ -89,6 +90,7 @@ protected: UI_MAP_NAME(m_funcSetHorseJumpBarProgress, L"SetHorseJumpBarProgress") UI_MAP_NAME(m_funcSetHealthAbsorb, L"SetHealthAbsorb") + UI_MAP_NAME(m_funcSetHardcore, L"SetHardcore") UI_END_MAP_ELEMENTS_AND_NAMES() public: @@ -159,6 +161,8 @@ private: void SetHealthAbsorb(int healthAbsorb); + void SetHardcoreMode(bool bHardcore); + public: void SetSelectedLabel(const wstring &label); void ShowDisplayName(bool show); diff --git a/Minecraft.Client/Common/UI/UIScene_JoinMenu.cpp b/Minecraft.Client/Common/UI/UIScene_JoinMenu.cpp index 7aa32147..2e75c662 100644 --- a/Minecraft.Client/Common/UI/UIScene_JoinMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_JoinMenu.cpp @@ -1,13 +1,19 @@ #include "stdafx.h" #include "UI.h" #include "UIScene_JoinMenu.h" + #include "../../Minecraft.h" #include "../../TexturePackRepository.h" #include "../../Options.h" #include "../../MinecraftServer.h" + #include "../../../Minecraft.World/net.minecraft.world.level.h" #include "../../../Minecraft.World/net.minecraft.world.h" +#ifdef _WINDOWS64 +#include "../../Windows64/Network/WinsockNetLayer.h" +#endif + #define UPDATE_PLAYERS_TIMER_ID 0 #define UPDATE_PLAYERS_TIMER_TIME 30000 @@ -587,10 +593,9 @@ void UIScene_JoinMenu::JoinGame(UIScene_JoinMenu* pClass) if (result == CGameNetworkManager::JOINGAME_PENDING) { pClass->m_bIgnoreInput = false; - ConnectionProgressParams *param = new ConnectionProgressParams(); param->iPad = ProfileManager.GetPrimaryPad(); - param->stringId = -1; + param->stringId = IDS_PROGRESS_CONNECTING; param->showTooltips = true; param->setFailTimer = false; param->timerTime = 0; @@ -655,16 +660,18 @@ void UIScene_JoinMenu::JoinGame(UIScene_JoinMenu* pClass) if( exitReasonStringId == -1 ) { - ui.NavigateBack(pClass->m_iPad); + // No specific disconnect reason was set — the server was likely + // unreachable. Show a "Connection Failed" dialog instead of + // silently navigating back so the user knows what happened. + exitReasonStringId = IDS_CONNECTION_LOST_SERVER; } - else + { UINT uiIDA[1]; uiIDA[0]=IDS_CONFIRM_OK; ui.RequestErrorMessage( IDS_CONNECTION_FAILED, exitReasonStringId, uiIDA,1,ProfileManager.GetPrimaryPad()); - exitReasonStringId = -1; - ui.NavigateToHomeMenu(); + pClass->m_bIgnoreInput = false; } } } diff --git a/Minecraft.Client/Common/UI/UIScene_LoadMenu.cpp b/Minecraft.Client/Common/UI/UIScene_LoadMenu.cpp index 5945e10e..c0e511dc 100644 --- a/Minecraft.Client/Common/UI/UIScene_LoadMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_LoadMenu.cpp @@ -24,12 +24,13 @@ #define CHECKFORAVAILABLETEXTUREPACKS_TIMER_TIME 50 #endif -int UIScene_LoadMenu::m_iDifficultyTitleSettingA[4]= +int UIScene_LoadMenu::m_iDifficultyTitleSettingA[5]= { IDS_DIFFICULTY_TITLE_PEACEFUL, IDS_DIFFICULTY_TITLE_EASY, IDS_DIFFICULTY_TITLE_NORMAL, - IDS_DIFFICULTY_TITLE_HARD + IDS_DIFFICULTY_TITLE_HARD, + IDS_GAMEMODE_HARDCORE }; int UIScene_LoadMenu::LoadSaveDataThumbnailReturned(LPVOID lpParam,PBYTE pbThumbnail,DWORD dwThumbnailBytes) @@ -110,6 +111,7 @@ UIScene_LoadMenu::UIScene_LoadMenu(int iPad, void *initData, UILayer *parentLaye m_bThumbnailGetFailed = false; m_seed = 0; m_bIsCorrupt = false; + m_bHardcore = false; m_bMultiplayerAllowed = ProfileManager.IsSignedInLive( m_iPad ) && ProfileManager.AllowedToPlayMultiplayer(m_iPad); // 4J-PB - read the settings for the online flag. We'll only save this setting if the user changed it. @@ -253,10 +255,34 @@ UIScene_LoadMenu::UIScene_LoadMenu(int iPad, void *initData, UILayer *parentLaye { wchar_t wSaveName[128]; ZeroMemory(wSaveName, sizeof(wSaveName)); - mbstowcs(wSaveName, params->saveDetails->UTF8SaveName, 127); + MultiByteToWideChar(CP_UTF8, 0, params->saveDetails->UTF8SaveName, -1, wSaveName, 127); m_levelName = wstring(wSaveName); m_labelGameName.init(m_levelName); } + if (params->saveDetails != nullptr) + { + // Set thumbnail name from save filename (needed for texture display in tick) + wchar_t wFilename[MAX_SAVEFILENAME_LENGTH]; + ZeroMemory(wFilename, sizeof(wFilename)); + mbstowcs(wFilename, params->saveDetails->UTF8SaveFilename, MAX_SAVEFILENAME_LENGTH - 1); + m_thumbnailName = wFilename; + + if (params->saveDetails->pbThumbnailData && params->saveDetails->dwThumbnailSize > 0) + { + m_pbThumbnailData = params->saveDetails->pbThumbnailData; + m_uiThumbnailSize = params->saveDetails->dwThumbnailSize; + m_bSaveThumbnailReady = true; + m_bRetrievingSaveThumbnail = false; + } + + m_bHardcore = params->saveDetails->isHardcore; + if (m_bHardcore) + { + WCHAR TempString[256]; + swprintf((WCHAR *)TempString, 256, L"%ls: %ls", app.GetString(IDS_SLIDER_DIFFICULTY), L"Hardcore"); + m_sliderDifficulty.init(TempString, eControl_Difficulty, 0, 4, 4); + } + } #endif } @@ -546,6 +572,19 @@ void UIScene_LoadMenu::tick() { m_MoreOptionsParams.bAllowFriendsOfFriends = TRUE; } + + m_bHardcore = app.GetGameHostOption(uiHostOptions, eGameHostOption_Hardcore) > 0; + if (m_bHardcore) + { + WCHAR TempString[256]; + swprintf( (WCHAR *)TempString, 256, L"%ls: %ls", app.GetString( IDS_SLIDER_DIFFICULTY ), L"Hardcore"); + m_sliderDifficulty.init(TempString, eControl_Difficulty, 0, 4, 4); + + // Hardcore locks game mode to Survival + m_iGameModeId = GameType::SURVIVAL->getId(); + m_bGameModeCreative = false; + m_buttonGamemode.setLabel(app.GetString(IDS_GAMEMODE_SURVIVAL)); + } } Minecraft *pMinecraft = Minecraft::GetInstance(); @@ -699,6 +738,8 @@ void UIScene_LoadMenu::handlePress(F64 controlId, F64 childId) switch(static_cast(controlId)) { case eControl_GameMode: + if (m_bHardcore) + break; // Hardcore mode locks game mode to Survival switch(m_iGameModeId) { case 0: // Survival @@ -950,10 +991,15 @@ void UIScene_LoadMenu::handleSliderMove(F64 sliderId, F64 currentValue) switch(static_cast(sliderId)) { case eControl_Difficulty: + if (m_bHardcore) + { + m_sliderDifficulty.handleSliderMove(4); + break; + } m_sliderDifficulty.handleSliderMove(value); app.SetGameSettings(m_iPad,eGameSetting_Difficulty,value); - swprintf( (WCHAR *)TempString, 256, L"%ls: %ls", app.GetString( IDS_SLIDER_DIFFICULTY ),app.GetString(m_iDifficultyTitleSettingA[value])); + swprintf( (WCHAR *)TempString, 256, L"%ls: %ls", app.GetString( IDS_SLIDER_DIFFICULTY ),app.GetString(m_iDifficultyTitleSettingA[value])); m_sliderDifficulty.setLabel(TempString); break; } @@ -1167,7 +1213,7 @@ void UIScene_LoadMenu::LaunchGame(void) #if TO_BE_IMPLEMENTED if(eLoadStatus==C4JStorage::ELoadGame_DeviceRemoved) { - // disable saving + // disable saving StorageManager.SetSaveDisabled(true); StorageManager.SetSaveDeviceSelected(m_iPad,false); UINT uiIDA[1]; @@ -1580,6 +1626,24 @@ void UIScene_LoadMenu::StartGameFromSave(UIScene_LoadMenu* pClass, DWORD dwLocal PSAVE_DETAILS pSaveDetails=StorageManager.ReturnSavesInfo(); +#ifdef _WINDOWS64 + // 4J Added: Store save folder name for potential hardcore world deletion + app.DebugPrintf("StartGameFromSave: pSaveDetails=%p, levelGen=%p, saveInfoIndex=%d\n", pSaveDetails, pClass->m_levelGen, pClass->m_iSaveGameInfoIndex); + if (pSaveDetails != nullptr && pClass->m_levelGen == nullptr) + { + app.DebugPrintf("StartGameFromSave: UTF8SaveFilename='%s'\n", pSaveDetails->SaveInfoA[(int)pClass->m_iSaveGameInfoIndex].UTF8SaveFilename); + wchar_t wFolder[MAX_SAVEFILENAME_LENGTH] = {}; + mbstowcs(wFolder, pSaveDetails->SaveInfoA[(int)pClass->m_iSaveGameInfoIndex].UTF8SaveFilename, MAX_SAVEFILENAME_LENGTH - 1); + app.SetCurrentSaveFolderName(wFolder); + app.DebugPrintf("StartGameFromSave: stored folder name '%ls'\n", wFolder); + } + else + { + app.DebugPrintf("StartGameFromSave: no save details or is levelGen, clearing folder name\n"); + app.SetCurrentSaveFolderName(L""); + } +#endif + NetworkGameInitData *param = new NetworkGameInitData(); param->seed = pClass->m_seed; param->saveData = nullptr; @@ -1612,6 +1676,7 @@ void UIScene_LoadMenu::StartGameFromSave(UIScene_LoadMenu* pClass, DWORD dwLocal app.SetGameHostOption(eGameHostOption_DoTileDrops, pClass->m_MoreOptionsParams.bDoTileDrops); app.SetGameHostOption(eGameHostOption_NaturalRegeneration, pClass->m_MoreOptionsParams.bNaturalRegeneration); app.SetGameHostOption(eGameHostOption_DoDaylightCycle, pClass->m_MoreOptionsParams.bDoDaylightCycle); + app.SetGameHostOption(eGameHostOption_Hardcore, pClass->m_bHardcore ? 1 : 0); #ifdef _LARGE_WORLDS app.SetGameHostOption(eGameHostOption_WorldSize, pClass->m_MoreOptionsParams.worldSize+1 ); // 0 is GAME_HOST_OPTION_WORLDSIZE_UNKNOWN diff --git a/Minecraft.Client/Common/UI/UIScene_LoadMenu.h b/Minecraft.Client/Common/UI/UIScene_LoadMenu.h index 53d66d55..91a6adc0 100644 --- a/Minecraft.Client/Common/UI/UIScene_LoadMenu.h +++ b/Minecraft.Client/Common/UI/UIScene_LoadMenu.h @@ -15,7 +15,7 @@ private: eControl_OnlineGame, }; - static int m_iDifficultyTitleSettingA[4]; + static int m_iDifficultyTitleSettingA[5]; UIControl m_controlMainPanel; UIControl_Label m_labelGameName, m_labelSeed, m_labelCreatedMode; @@ -71,6 +71,7 @@ private: wstring m_thumbnailName; bool m_bRebuildTouchBoxes; + bool m_bHardcore; public: UIScene_LoadMenu(int iPad, void *initData, UILayer *parentLayer); diff --git a/Minecraft.Client/Common/UI/UIScene_LoadOrJoinMenu.cpp b/Minecraft.Client/Common/UI/UIScene_LoadOrJoinMenu.cpp index 3810b3aa..1e83aee6 100644 --- a/Minecraft.Client/Common/UI/UIScene_LoadOrJoinMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_LoadOrJoinMenu.cpp @@ -29,7 +29,7 @@ #include "../../../Minecraft.World/NbtIo.h" #include "../../../Minecraft.World/compression.h" -static wstring ReadLevelNameFromSaveFile(const wstring& filePath) +static wstring ReadLevelNameFromSaveFile(const wstring& filePath, bool *outHardcore = nullptr) { // Check for a worldname.txt sidecar written by the rename feature first size_t slashPos = filePath.rfind(L'\\'); @@ -49,7 +49,7 @@ static wstring ReadLevelNameFromSaveFile(const wstring& filePath) if (len > 0) { wchar_t wbuf[128] = {}; - mbstowcs(wbuf, buf, 127); + MultiByteToWideChar(CP_UTF8, 0, buf, -1, wbuf, 127); return wstring(wbuf); } } @@ -124,7 +124,11 @@ static wstring ReadLevelNameFromSaveFile(const wstring& filePath) { CompoundTag *dataTag = root->getCompound(L"Data"); if (dataTag != nullptr) + { result = dataTag->getString(L"LevelName"); + if (outHardcore) + *outHardcore = dataTag->getBoolean(L"hardcore"); + } delete root; } } @@ -633,6 +637,11 @@ void UIScene_LoadOrJoinMenu::handleGainFocus(bool navBack) if( m_bMultiplayerAllowed ) { +#ifdef _WINDOWS64 + // Refresh the games list immediately so that any server + // edits/deletions made in the JoinMenu are visible now. + UpdateGamesList(); +#endif #if TO_BE_IMPLEMENTED HXUICLASS hClassFullscreenProgress = XuiFindClass( L"CScene_FullscreenProgress" ); HXUICLASS hClassConnectingProgress = XuiFindClass( L"CScene_ConnectingProgress" ); @@ -788,13 +797,17 @@ void UIScene_LoadOrJoinMenu::tick() #else #ifdef _WINDOWS64 { - wstring levelName = ReadLevelNameFromSaveFile(filePath); - + bool saveHardcore = false; + wstring levelName = ReadLevelNameFromSaveFile(filePath, &saveHardcore); + m_saveDetails[i].isHardcore = saveHardcore; if (!levelName.empty()) { m_buttonListSaves.addItem(levelName, wstring(L"")); - wcstombs(m_saveDetails[i].UTF8SaveName, levelName.c_str(), 127); - m_saveDetails[i].UTF8SaveName[127] = '\0'; + { + int n = WideCharToMultiByte(CP_UTF8, 0, levelName.c_str(), -1, m_saveDetails[i].UTF8SaveName, 127, nullptr, nullptr); + if (n <= 0) m_saveDetails[i].UTF8SaveName[0] = '\0'; + m_saveDetails[i].UTF8SaveName[127] = '\0'; + } } else { @@ -1439,9 +1452,9 @@ int UIScene_LoadOrJoinMenu::KeyboardCompleteWorldNameCallback(LPVOID lpParam,boo for (int k = 0; k < 127 && ui16Text[k]; k++) wNewName[k] = static_cast(ui16Text[k]); - // Convert to narrow for storage and in-memory update - char narrowName[128] = {}; - wcstombs(narrowName, wNewName, 127); + // Convert to narrow for storage and in-memory update (UTF-8 to preserve Unicode) + char narrowName[256] = {}; + WideCharToMultiByte(CP_UTF8, 0, wNewName, -1, narrowName, 255, nullptr, nullptr); // Build the sidecar path: Windows64\GameHDD\{folder}\worldname.txt wchar_t wFilename[MAX_SAVEFILENAME_LENGTH] = {}; @@ -1457,7 +1470,7 @@ int UIScene_LoadOrJoinMenu::KeyboardCompleteWorldNameCallback(LPVOID lpParam,boo // Update the in-memory display name so the list reflects it immediately strncpy_s(pClass->m_saveDetails[listPos].UTF8SaveName, narrowName, 127); - pClass->m_saveDetails[listPos].UTF8SaveName[127] = '\0'; + pClass->m_saveDetails[listPos].UTF8SaveName[127] = '\0'; // UTF8SaveName is still 128 bytes; narrowName fits as Arabic is <=2 bytes/char in UTF-8 // Reuse the existing callback to trigger the list repopulate UIScene_LoadOrJoinMenu::RenameSaveDataReturned(pClass, true); @@ -2525,7 +2538,7 @@ int UIScene_LoadOrJoinMenu::SaveOptionsDialogReturned(void *pParam,int iPad,C4JS { wchar_t wSaveName[128]; ZeroMemory(wSaveName, 128 * sizeof(wchar_t)); - mbstowcs_s(nullptr, wSaveName, 128, pClass->m_saveDetails[pClass->m_iSaveListIndex - pClass->m_iDefaultButtonsC].UTF8SaveName, _TRUNCATE); + MultiByteToWideChar(CP_UTF8, 0, pClass->m_saveDetails[pClass->m_iSaveListIndex - pClass->m_iDefaultButtonsC].UTF8SaveName, -1, wSaveName, 127); UIKeyboardInitData kbData; kbData.title = app.GetString(IDS_RENAME_WORLD_TITLE); kbData.defaultText = wSaveName; diff --git a/Minecraft.Client/Common/UI/UIScene_SettingsGraphicsMenu.cpp b/Minecraft.Client/Common/UI/UIScene_SettingsGraphicsMenu.cpp index e4836bc5..f3eab6d3 100644 --- a/Minecraft.Client/Common/UI/UIScene_SettingsGraphicsMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_SettingsGraphicsMenu.cpp @@ -5,6 +5,11 @@ #include "../../Options.h" #include "../../GameRenderer.h" +#ifdef _WINDOWS64 +extern bool g_bVSync; +extern void SetExclusiveFullscreen(bool enabled); +#endif + namespace { constexpr int FOV_MIN = 70; @@ -62,12 +67,14 @@ UIScene_SettingsGraphicsMenu::UIScene_SettingsGraphicsMenu(int iPad, void *initD m_checkboxClouds.init(app.GetString(IDS_CHECKBOX_RENDER_CLOUDS),eControl_Clouds,(app.GetGameSettings(m_iPad,eGameSetting_Clouds)!=0)); m_checkboxBedrockFog.init(app.GetString(IDS_CHECKBOX_RENDER_BEDROCKFOG),eControl_BedrockFog,(app.GetGameSettings(m_iPad,eGameSetting_BedrockFog)!=0)); m_checkboxCustomSkinAnim.init(app.GetString(IDS_CHECKBOX_CUSTOM_SKIN_ANIM),eControl_CustomSkinAnim,(app.GetGameSettings(m_iPad,eGameSetting_CustomSkinAnim)!=0)); + m_checkboxVSync.init(L"VSync",eControl_VSync,(app.GetGameSettings(m_iPad,eGameSetting_VSync)!=0)); + m_checkboxExclusiveFullscreen.init(L"Fullscreen",eControl_ExclusiveFullscreen,(app.GetGameSettings(m_iPad,eGameSetting_ExclusiveFullscreen)!=0)); + - WCHAR TempString[256]; swprintf(TempString, 256, L"Render Distance: %d",app.GetGameSettings(m_iPad,eGameSetting_RenderDistance)); - m_sliderRenderDistance.init(TempString,eControl_RenderDistance,0,5,DistanceToLevel(app.GetGameSettings(m_iPad,eGameSetting_RenderDistance))); + m_sliderRenderDistance.init(TempString,eControl_RenderDistance,0,3,DistanceToLevel(app.GetGameSettings(m_iPad,eGameSetting_RenderDistance))); swprintf( TempString, 256, L"%ls: %d%%", app.GetString( IDS_SLIDER_GAMMA ),app.GetGameSettings(m_iPad,eGameSetting_Gamma)); m_sliderGamma.init(TempString,eControl_Gamma,0,100,app.GetGameSettings(m_iPad,eGameSetting_Gamma)); @@ -82,28 +89,50 @@ UIScene_SettingsGraphicsMenu::UIScene_SettingsGraphicsMenu(int iPad, void *initD doHorizontalResizeCheck(); +#ifndef _WINDOWS64 + // VSync and Exclusive Fullscreen are only available on PC + removeControl(&m_checkboxVSync, true); + removeControl(&m_checkboxExclusiveFullscreen, true); +#else + // The SWF's original focus chain skips VSync, Fullscreen, and RenderDistance + // (CustomSkinAnim -> Gamma). Rewire the navigation so all controls are reachable: + // CustomSkinAnim -> VSync -> Fullscreen -> RenderDistance -> Gamma + { + IggyName navDown = registerFastName(L"m_objNavDown"); + IggyName navUp = registerFastName(L"m_objNavUp"); + + IggyValueSetStringUTF8RS(m_checkboxCustomSkinAnim.getIggyValuePath(), navDown, nullptr, "VSync", -1); + + IggyValueSetStringUTF8RS(m_checkboxVSync.getIggyValuePath(), navUp, nullptr, "CustomSkinAnim", -1); + IggyValueSetStringUTF8RS(m_checkboxVSync.getIggyValuePath(), navDown, nullptr, "ExclusiveFullscreen", -1); + + IggyValueSetStringUTF8RS(m_checkboxExclusiveFullscreen.getIggyValuePath(), navUp, nullptr, "VSync", -1); + IggyValueSetStringUTF8RS(m_checkboxExclusiveFullscreen.getIggyValuePath(), navDown, nullptr, "RenderDistance", -1); + + IggyValueSetStringUTF8RS(m_sliderRenderDistance.getIggyValuePath(), navUp, nullptr, "ExclusiveFullscreen", -1); + } +#endif + const bool bInGame=(Minecraft::GetInstance()->level!=nullptr); const bool bIsPrimaryPad=(ProfileManager.GetPrimaryPad()==m_iPad); - // if we're not in the game, we need to use basescene 0 + // if we're not in the game, we need to use basescene 0 if(bInGame) { - // If the game has started, then you need to be the host to change the in-game gamertags +#ifndef _WINDOWS64 + // Console splitscreen: non-host and non-primary players can't change world-level settings if(bIsPrimaryPad) - { - // we are the primary player on this machine, but not the game host - // are we the game host? If not, we need to remove the bedrockfog setting + { if(!g_NetworkManager.IsHost()) { - // hide the in-game bedrock fog setting removeControl(&m_checkboxBedrockFog, true); } } else { - // We shouldn't have the bedrock fog option, or the m_CustomSkinAnim option removeControl(&m_checkboxBedrockFog, true); removeControl(&m_checkboxCustomSkinAnim, true); } +#endif } if(app.GetLocalPlayerCount()>1) @@ -165,6 +194,12 @@ void UIScene_SettingsGraphicsMenu::handleInput(int iPad, int key, bool repeat, b app.SetGameSettings(m_iPad,eGameSetting_Clouds,m_checkboxClouds.IsChecked()?1:0); app.SetGameSettings(m_iPad,eGameSetting_BedrockFog,m_checkboxBedrockFog.IsChecked()?1:0); app.SetGameSettings(m_iPad,eGameSetting_CustomSkinAnim,m_checkboxCustomSkinAnim.IsChecked()?1:0); + app.SetGameSettings(m_iPad,eGameSetting_VSync,m_checkboxVSync.IsChecked()?1:0); + app.SetGameSettings(m_iPad,eGameSetting_ExclusiveFullscreen,m_checkboxExclusiveFullscreen.IsChecked()?1:0); +#ifdef _WINDOWS64 + g_bVSync = m_checkboxVSync.IsChecked(); + SetExclusiveFullscreen(m_checkboxExclusiveFullscreen.IsChecked()); +#endif navigateBack(); handled = true; diff --git a/Minecraft.Client/Common/UI/UIScene_SettingsGraphicsMenu.h b/Minecraft.Client/Common/UI/UIScene_SettingsGraphicsMenu.h index 99022c83..ef150f39 100644 --- a/Minecraft.Client/Common/UI/UIScene_SettingsGraphicsMenu.h +++ b/Minecraft.Client/Common/UI/UIScene_SettingsGraphicsMenu.h @@ -12,18 +12,22 @@ private: eControl_Clouds, eControl_BedrockFog, eControl_CustomSkinAnim, + eControl_VSync, + eControl_ExclusiveFullscreen, eControl_RenderDistance, eControl_Gamma, eControl_FOV, eControl_InterfaceOpacity }; - UIControl_CheckBox m_checkboxClouds, m_checkboxBedrockFog, m_checkboxCustomSkinAnim; // Checkboxes + UIControl_CheckBox m_checkboxClouds, m_checkboxBedrockFog, m_checkboxCustomSkinAnim, m_checkboxVSync, m_checkboxExclusiveFullscreen; // Checkboxes UIControl_Slider m_sliderRenderDistance, m_sliderGamma, m_sliderFOV, m_sliderInterfaceOpacity; // Sliders UI_BEGIN_MAP_ELEMENTS_AND_NAMES(UIScene) UI_MAP_ELEMENT( m_checkboxClouds, "Clouds") UI_MAP_ELEMENT( m_checkboxBedrockFog, "BedrockFog") UI_MAP_ELEMENT( m_checkboxCustomSkinAnim, "CustomSkinAnim") + UI_MAP_ELEMENT( m_checkboxVSync, "VSync") + UI_MAP_ELEMENT( m_checkboxExclusiveFullscreen, "ExclusiveFullscreen") UI_MAP_ELEMENT( m_sliderRenderDistance, "RenderDistance") UI_MAP_ELEMENT( m_sliderGamma, "Gamma") UI_MAP_ELEMENT(m_sliderFOV, "FOV") diff --git a/Minecraft.Client/Common/UI/UIStructs.h b/Minecraft.Client/Common/UI/UIStructs.h index ca8e5195..2f01c3e2 100644 --- a/Minecraft.Client/Common/UI/UIStructs.h +++ b/Minecraft.Client/Common/UI/UIStructs.h @@ -257,11 +257,14 @@ typedef struct _SaveListDetails #endif #endif + bool isHardcore; + _SaveListDetails() { saveId = 0; pbThumbnailData = nullptr; dwThumbnailSize = 0; + isHardcore = false; #ifdef _DURANGO ZeroMemory(UTF16SaveName,sizeof(wchar_t)*128); ZeroMemory(UTF16SaveFilename,sizeof(wchar_t)*MAX_SAVEFILENAME_LENGTH); diff --git a/Minecraft.Client/Common/UI/UITTFFont.cpp b/Minecraft.Client/Common/UI/UITTFFont.cpp index 49f415e9..69fa9bb2 100644 --- a/Minecraft.Client/Common/UI/UITTFFont.cpp +++ b/Minecraft.Client/Common/UI/UITTFFont.cpp @@ -4,7 +4,7 @@ #include "../../../Minecraft.World/File.h" #include "UITTFFont.h" -UITTFFont::UITTFFont(const string &name, const string &path, S32 fallbackCharacter) +UITTFFont::UITTFFont(const string &name, const string &path, S32 fallbackCharacter, bool registerAsDefaultFonts) : m_strFontName(name) { app.DebugPrintf("UITTFFont opening %s\n",path.c_str()); @@ -41,9 +41,12 @@ UITTFFont::UITTFFont(const string &name, const string &path, S32 fallbackCharact IggyFontInstallTruetypeFallbackCodepointUTF8( m_strFontName.c_str(), -1, IGGY_FONTFLAG_none, fallbackCharacter ); - // 4J Stu - These are so we can use the default flash controls - IggyFontInstallTruetypeUTF8 ( (void *)pbData, IGGY_TTC_INDEX_none, "Times New Roman", -1, IGGY_FONTFLAG_none ); - IggyFontInstallTruetypeUTF8 ( (void *)pbData, IGGY_TTC_INDEX_none, "Arial", -1, IGGY_FONTFLAG_none ); + if (registerAsDefaultFonts) + { + // 4J Stu - These are so we can use the default flash controls + IggyFontInstallTruetypeUTF8 ( (void *)pbData, IGGY_TTC_INDEX_none, "Times New Roman", -1, IGGY_FONTFLAG_none ); + IggyFontInstallTruetypeUTF8 ( (void *)pbData, IGGY_TTC_INDEX_none, "Arial", -1, IGGY_FONTFLAG_none ); + } } } diff --git a/Minecraft.Client/Common/UI/UITTFFont.h b/Minecraft.Client/Common/UI/UITTFFont.h index 023bd51b..f33ef846 100644 --- a/Minecraft.Client/Common/UI/UITTFFont.h +++ b/Minecraft.Client/Common/UI/UITTFFont.h @@ -9,7 +9,7 @@ private: //DWORD dwDataSize; public: - UITTFFont(const string &name, const string &path, S32 fallbackCharacter); + UITTFFont(const string &name, const string &path, S32 fallbackCharacter, bool registerAsDefaultFonts = true); ~UITTFFont(); string getFontName(); diff --git a/Minecraft.Client/Common/UI/UIUnicodeBitmapFont.cpp b/Minecraft.Client/Common/UI/UIUnicodeBitmapFont.cpp new file mode 100644 index 00000000..6900558b --- /dev/null +++ b/Minecraft.Client/Common/UI/UIUnicodeBitmapFont.cpp @@ -0,0 +1,164 @@ +#include "stdafx.h" +#include "BufferedImage.h" +#include "UIFontData.h" +#include "UIUnicodeBitmapFont.h" + +UIUnicodeBitmapFont::UIUnicodeBitmapFont(const string &fontname, SFontData &referenceFontData) + : UIAbstractBitmapFont(fontname) +{ + m_numGlyphs = 65536; + m_referenceFontData = &referenceFontData; + memset(m_glyphPages, 0, sizeof(m_glyphPages)); + memset(m_unicodeWidth, 0, sizeof(m_unicodeWidth)); + + FILE *f = nullptr; + fopen_s(&f, "Common/res/1_2_2/font/glyph_sizes.bin", "rb"); + if (f) + { + fread(m_unicodeWidth, 1, 65536, f); + fclose(f); + } +} + +UIUnicodeBitmapFont::~UIUnicodeBitmapFont() +{ + for (int i = 0; i < 256; i++) + delete[] m_glyphPages[i]; +} + +void UIUnicodeBitmapFont::loadGlyphPage(int page) +{ + wchar_t fileName[64]; + swprintf(fileName, 64, L"/1_2_2/font/glyph_%02X.png", page); + BufferedImage bimg(fileName); + int *rawData = bimg.getData(); + if (!rawData) return; + + int size = 256 * 256; + m_glyphPages[page] = new unsigned char[size]; + for (int i = 0; i < size; i++) + m_glyphPages[page][i] = (rawData[i] & 0xFF000000) >> 24; +} + +IggyFontMetrics *UIUnicodeBitmapFont::GetFontMetrics(IggyFontMetrics *metrics) +{ + metrics->ascent = m_referenceFontData->m_fAscent; + metrics->descent = m_referenceFontData->m_fDescent; + metrics->average_glyph_width_for_tab_stops = 8.0f; + metrics->largest_glyph_bbox_y1 = metrics->descent; + return metrics; +} + +S32 UIUnicodeBitmapFont::GetCodepointGlyph(U32 codepoint) +{ + if (codepoint < 65536 && m_unicodeWidth[codepoint] != 0) + return (S32)codepoint; + return IGGY_GLYPH_INVALID; +} + +IggyGlyphMetrics *UIUnicodeBitmapFont::GetGlyphMetrics(S32 glyph, IggyGlyphMetrics *metrics) +{ + if (glyph < 0 || glyph >= 65536) { metrics->x0 = metrics->x1 = metrics->advance = metrics->y0 = metrics->y1 = 0; return metrics; } + int left = m_unicodeWidth[glyph] >> 4; + int right = (m_unicodeWidth[glyph] & 0xF) + 1; + float pixelWidth = (right - left) / 2.0f + 1.0f; + float advance = pixelWidth * m_referenceFontData->m_fAdvPerPixel; + + metrics->x0 = 0.0f; + metrics->x1 = advance; + metrics->advance = advance; + metrics->y0 = 0.0f; + metrics->y1 = 1.0f; + return metrics; +} + +rrbool UIUnicodeBitmapFont::IsGlyphEmpty(S32 glyph) +{ + if (glyph < 0 || glyph >= 65536) return true; + return m_unicodeWidth[glyph] == 0; +} + +F32 UIUnicodeBitmapFont::GetKerningForGlyphPair(S32 first_glyph, S32 second_glyph) +{ + return 0.0f; +} + +rrbool UIUnicodeBitmapFont::CanProvideBitmap(S32 glyph, F32 pixel_scale) +{ + return glyph >= 0 && glyph < 65536; +} + +rrbool UIUnicodeBitmapFont::GetGlyphBitmap(S32 glyph, F32 pixel_scale, IggyBitmapCharacter *bitmap) +{ + if (glyph < 0 || glyph >= 65536) return false; + int page = glyph / 256; + if (!m_glyphPages[page]) + { + loadGlyphPage(page); + if (!m_glyphPages[page]) return false; + } + + int cx = (glyph % 16) * 16; + int cy = ((glyph & 0xFF) / 16) * 16; + + bitmap->pixels_one_per_byte = m_glyphPages[page] + (cy * 256) + cx; + bitmap->width_in_pixels = 16; + bitmap->height_in_pixels = 16; + bitmap->stride_in_bytes = 256; + + bitmap->top_left_x = 0; + bitmap->top_left_y = -static_cast(16) * m_referenceFontData->m_fAscent; + + bitmap->oversample = 0; + + // Scale parameters: match UIBitmapFont's approach. + // truePixelScale = the pixel_scale at which 1 glyph pixel = 1 screen pixel. + // For 16px glyphs displayed at the same visual size as Mojangles_7 (8px glyphs with advPerPixel 1/10): + // The reference truePixelScale for Mojangles_7 is 1.0f/m_fAdvPerPixel = 10.0f + // Since our glyphs are 16px (2x the Mojangles 8px), our truePixelScale is 20.0f + float truePixelScale = 2.0f / m_referenceFontData->m_fAdvPerPixel; + +#ifdef _WINDOWS64 + bitmap->pixel_scale_correct = truePixelScale; + if (pixel_scale < truePixelScale) + { + bitmap->pixel_scale_min = 0.0f; + bitmap->pixel_scale_max = truePixelScale; + bitmap->point_sample = false; + } + else + { + bitmap->pixel_scale_min = truePixelScale; + bitmap->pixel_scale_max = 99.0f; + bitmap->point_sample = true; + } +#else + float glyphScale = 1.0f; + while ((0.5f + glyphScale) * truePixelScale < pixel_scale) + glyphScale++; + + if (glyphScale <= 1 && pixel_scale < truePixelScale) + { + bitmap->pixel_scale_correct = truePixelScale; + bitmap->pixel_scale_min = 0.0f; + bitmap->pixel_scale_max = truePixelScale * 1.001f; + bitmap->point_sample = false; + } + else + { + float actualScale = pixel_scale / glyphScale; + bitmap->pixel_scale_correct = actualScale; + bitmap->pixel_scale_min = truePixelScale; + bitmap->pixel_scale_max = 99.0f; + bitmap->point_sample = true; + } +#endif + + bitmap->user_context_for_free = nullptr; + return true; +} + +void UIUnicodeBitmapFont::FreeGlyphBitmap(S32 glyph, F32 pixel_scale, IggyBitmapCharacter *bitmap) +{ + // Pixel data lives in m_glyphPages -- nothing to free. +} diff --git a/Minecraft.Client/Common/UI/UIUnicodeBitmapFont.h b/Minecraft.Client/Common/UI/UIUnicodeBitmapFont.h new file mode 100644 index 00000000..53649b69 --- /dev/null +++ b/Minecraft.Client/Common/UI/UIUnicodeBitmapFont.h @@ -0,0 +1,27 @@ +#pragma once +#include "UIBitmapFont.h" + +struct SFontData; + +class UIUnicodeBitmapFont : public UIAbstractBitmapFont +{ +private: + unsigned char m_unicodeWidth[65536]; + unsigned char* m_glyphPages[256]; + SFontData* m_referenceFontData; + + void loadGlyphPage(int page); + +public: + UIUnicodeBitmapFont(const string &fontname, SFontData &referenceFontData); + ~UIUnicodeBitmapFont(); + + virtual IggyFontMetrics *GetFontMetrics(IggyFontMetrics *metrics); + virtual S32 GetCodepointGlyph(U32 codepoint); + virtual IggyGlyphMetrics *GetGlyphMetrics(S32 glyph, IggyGlyphMetrics *metrics); + virtual rrbool IsGlyphEmpty(S32 glyph); + virtual F32 GetKerningForGlyphPair(S32 first_glyph, S32 second_glyph); + virtual rrbool CanProvideBitmap(S32 glyph, F32 pixel_scale); + virtual rrbool GetGlyphBitmap(S32 glyph, F32 pixel_scale, IggyBitmapCharacter *bitmap); + virtual void FreeGlyphBitmap(S32 glyph, F32 pixel_scale, IggyBitmapCharacter *bitmap); +}; diff --git a/Minecraft.Client/Common/XUI/XUI_Chat.cpp b/Minecraft.Client/Common/XUI/XUI_Chat.cpp index e2d33bc9..4fabb031 100644 --- a/Minecraft.Client/Common/XUI/XUI_Chat.cpp +++ b/Minecraft.Client/Common/XUI/XUI_Chat.cpp @@ -2,6 +2,7 @@ #include "XUI_Chat.h" #include "../../Minecraft.h" #include "../../Gui.h" +#include "../../../Minecraft.World\ArabicShaping.h" HRESULT CScene_Chat::OnInit( XUIMessageInit* pInitData, BOOL& bHandled ) { @@ -29,7 +30,8 @@ HRESULT CScene_Chat::OnTimer( XUIMessageTimer *pXUIMessageTimer, BOOL &bHandled) { m_Backgrounds[i].SetOpacity(opacity); m_Labels[i].SetOpacity(opacity); - m_Labels[i].SetText( pGui->getMessage(m_iPad,i).c_str() ); + wstring shaped = shapeArabicText(pGui->getMessage(m_iPad, i)); + m_Labels[i].SetText( shaped.c_str() ); } else { diff --git a/Minecraft.Client/Common/XUI/XUI_Ctrl_4JList.cpp b/Minecraft.Client/Common/XUI/XUI_Ctrl_4JList.cpp index 7523c523..4b7eb5f6 100644 --- a/Minecraft.Client/Common/XUI/XUI_Ctrl_4JList.cpp +++ b/Minecraft.Client/Common/XUI/XUI_Ctrl_4JList.cpp @@ -1,5 +1,6 @@ #include "stdafx.h" #include "XUI_Ctrl_4JList.h" +#include "..\..\..\Minecraft.World\ArabicShaping.h" static bool TimeSortFn(const void *a, const void *b); @@ -294,8 +295,16 @@ HRESULT CXuiCtrl4JList::OnGetSourceDataText(XUIMessageGetSourceText *pGetSourceT if( ( 0 == pGetSourceTextData->iData ) && ( ( pGetSourceTextData->bItemData ) ) ) { EnterCriticalSection(&m_AccessListData); - pGetSourceTextData->szText = - GetData(pGetSourceTextData->iItem).pwszText; + LPCWSTR rawText = GetData(pGetSourceTextData->iItem).pwszText; + if (rawText) + { + m_shapedTextCache = shapeArabicText(rawText); + pGetSourceTextData->szText = m_shapedTextCache.c_str(); + } + else + { + pGetSourceTextData->szText = rawText; + } LeaveCriticalSection(&m_AccessListData); bHandled = TRUE; } diff --git a/Minecraft.Client/Common/XUI/XUI_Ctrl_4JList.h b/Minecraft.Client/Common/XUI/XUI_Ctrl_4JList.h index 11bdd456..99813ff6 100644 --- a/Minecraft.Client/Common/XUI/XUI_Ctrl_4JList.h +++ b/Minecraft.Client/Common/XUI/XUI_Ctrl_4JList.h @@ -75,4 +75,5 @@ private: static bool IndexSortFn(const void *a, const void *b); HXUIOBJ m_hSelectionChangedHandlerObj; + std::wstring m_shapedTextCache; // temp buffer for Arabic-shaped text in OnGetSourceDataText }; diff --git a/Minecraft.Client/Common/XUI/XUI_Death.cpp b/Minecraft.Client/Common/XUI/XUI_Death.cpp index 0f0bc25d..bf69f9c2 100644 --- a/Minecraft.Client/Common/XUI/XUI_Death.cpp +++ b/Minecraft.Client/Common/XUI/XUI_Death.cpp @@ -4,6 +4,7 @@ #include "stdafx.h" #include "../XUI/XUI_Death.h" #include + #include "../../../Minecraft.World/AABB.h" #include "../../../Minecraft.World/Vec3.h" #include "../../../Minecraft.World/net.minecraft.stats.h" @@ -17,10 +18,13 @@ #include "../../../Minecraft.Client/LevelRenderer.h" #include "../../../Minecraft.World/Pos.h" #include "../../../Minecraft.World/Dimension.h" +#include "../../../Minecraft.World/net.minecraft.world.level.storage.h" +#include "../../../Minecraft.World/compression.h" + #include "../../Minecraft.h" +#include "../../MinecraftServer.h" #include "../../Options.h" #include "../../LocalPlayer.h" -#include "../../../Minecraft.World/compression.h" //---------------------------------------------------------------------------------- // Performs initialization tasks - retrieves controls. //---------------------------------------------------------------------------------- @@ -37,9 +41,26 @@ HRESULT CScene_Death::OnInit( XUIMessageInit* pInitData, BOOL& bHandled ) } XuiControlSetText(m_Title,app.GetString(IDS_YOU_DIED)); - XuiControlSetText(m_Buttons[BUTTON_DEATH_RESPAWN],app.GetString(IDS_RESPAWN)); XuiControlSetText(m_Buttons[BUTTON_DEATH_EXITGAME],app.GetString(IDS_EXIT_GAME)); + // 4J Added: In hardcore mode, disable respawn and show hardcore death message + Minecraft *pMinecraft = Minecraft::GetInstance(); + bool isHardcore = false; + if (pMinecraft != nullptr && pMinecraft->level != nullptr) + { + isHardcore = pMinecraft->level->getLevelData()->isHardcore(); + } + + if (isHardcore) + { + XuiControlSetText(m_Buttons[BUTTON_DEATH_RESPAWN], app.GetString(IDS_HARDCORE_DEATH_MESSAGE)); + XuiElementSetShow(m_Buttons[BUTTON_DEATH_RESPAWN], FALSE); + } + else + { + XuiControlSetText(m_Buttons[BUTTON_DEATH_RESPAWN], app.GetString(IDS_RESPAWN)); + } + // Display the tooltips ui.SetTooltips( m_iPad, IDS_TOOLTIPS_SELECT); @@ -110,7 +131,16 @@ HRESULT CScene_Death::OnNotifyPressEx(HXUIOBJ hObjPressed, XUINotifyPress* pNoti playTime = static_cast(pMinecraft->localplayers[pNotifyPressData->UserIndex]->getSessionTimer()); } TelemetryManager->RecordLevelExit(pNotifyPressData->UserIndex, eSen_LevelExitStatus_Failed); - + + // 4J Added: Hardcore mode — skip save dialog, exit without saving, delete world + if (pMinecraft->level != nullptr && pMinecraft->level->getLevelData()->isHardcore() && g_NetworkManager.IsHost()) + { + MinecraftServer::getInstance()->setSaveOnExit(false); + MinecraftServer::getInstance()->setDeleteWorldOnExit(true); + app.SetAction(pNotifyPressData->UserIndex, eAppAction_ExitWorld); + break; + } + if(StorageManager.GetSaveDisabled()) { uiIDA[0]=IDS_CONFIRM_CANCEL; @@ -172,6 +202,12 @@ HRESULT CScene_Death::OnNotifyPressEx(HXUIOBJ hObjPressed, XUINotifyPress* pNoti break; case BUTTON_DEATH_RESPAWN: { + // 4J Added: Safeguard - don't respawn in hardcore mode + Minecraft *pMC = Minecraft::GetInstance(); + if (pMC != nullptr && pMC->level != nullptr && pMC->level->getLevelData()->isHardcore()) + { + break; + } m_bIgnoreInput = true; app.SetAction(pNotifyPressData->UserIndex,eAppAction_Respawn); } diff --git a/Minecraft.Client/Common/XUI/XUI_HUD.cpp b/Minecraft.Client/Common/XUI/XUI_HUD.cpp index 7937bb48..134061f4 100644 --- a/Minecraft.Client/Common/XUI/XUI_HUD.cpp +++ b/Minecraft.Client/Common/XUI/XUI_HUD.cpp @@ -186,7 +186,8 @@ HRESULT CXuiSceneHud::OnCustomMessage_TickScene() if (pMinecraft->localplayers[m_iPad]->invulnerableTime < 10) blink = false; int iHealth = pMinecraft->localplayers[m_iPad]->getHealth(); int iLastHealth = pMinecraft->localplayers[m_iPad]->lastHealth; - bool bHasPoison = pMinecraft->localplayers[m_iPad]->hasEffect(MobEffect::poison); + bool bHasPoison = pMinecraft->localplayers[m_iPad]->hasEffect(MobEffect::poison); + bool isHardcore = pMinecraft->level != nullptr && pMinecraft->level->getLevelData()->isHardcore(); for (int icon = 0; icon < Player::MAX_HEALTH / 2; icon++) { if(blink) @@ -196,11 +197,15 @@ HRESULT CXuiSceneHud::OnCustomMessage_TickScene() // Full if(bHasPoison) { - m_healthIcon[icon].PlayVisualRange(L"FullPoisonFlash",nullptr,L"FullPoisonFlash"); + m_healthIcon[icon].PlayVisualRange( + isHardcore ? L"FullPoisonFlashHardcore" : L"FullPoisonFlash", nullptr, + isHardcore ? L"FullPoisonFlashHardcore" : L"FullPoisonFlash"); } else { - m_healthIcon[icon].PlayVisualRange(L"FullFlash",nullptr,L"FullFlash"); + m_healthIcon[icon].PlayVisualRange( + isHardcore ? L"FullFlashHardcore" : L"FullFlash", nullptr, + isHardcore ? L"FullFlashHardcore" : L"FullFlash"); } } else if (icon * 2 + 1 == iLastHealth || icon * 2 + 1 == iHealth) @@ -208,17 +213,23 @@ HRESULT CXuiSceneHud::OnCustomMessage_TickScene() // Half if(bHasPoison) { - m_healthIcon[icon].PlayVisualRange(L"HalfPoisonFlash",nullptr,L"HalfPoisonFlash"); + m_healthIcon[icon].PlayVisualRange( + isHardcore ? L"HalfPoisonFlashHardcore" : L"HalfPoisonFlash", nullptr, + isHardcore ? L"HalfPoisonFlashHardcore" : L"HalfPoisonFlash"); } else { - m_healthIcon[icon].PlayVisualRange(L"HalfFlash",nullptr,L"HalfFlash"); + m_healthIcon[icon].PlayVisualRange( + isHardcore ? L"HalfFlashHardcore" : L"HalfFlash", nullptr, + isHardcore ? L"HalfFlashHardcore" : L"HalfFlash"); } } else { // Empty - m_healthIcon[icon].PlayVisualRange(L"NormalFlash",nullptr,L"NormalFlash"); + m_healthIcon[icon].PlayVisualRange( + isHardcore ? L"NormalFlashHardcore" : L"NormalFlash", nullptr, + isHardcore ? L"NormalFlashHardcore" : L"NormalFlash"); } } else @@ -228,11 +239,15 @@ HRESULT CXuiSceneHud::OnCustomMessage_TickScene() // Full if(bHasPoison) { - m_healthIcon[icon].PlayVisualRange(L"FullPoison",nullptr,L"FullPoison"); + m_healthIcon[icon].PlayVisualRange( + isHardcore ? L"FullPoisonHardcore" : L"FullPoison", nullptr, + isHardcore ? L"FullPoisonHardcore" : L"FullPoison"); } else { - m_healthIcon[icon].PlayVisualRange(L"Full",nullptr,L"Full"); + m_healthIcon[icon].PlayVisualRange( + isHardcore ? L"FullHardcore" : L"Full", nullptr, + isHardcore ? L"FullHardcore" : L"Full"); } } else if (icon * 2 + 1 == iHealth) @@ -240,17 +255,23 @@ HRESULT CXuiSceneHud::OnCustomMessage_TickScene() // Half if(bHasPoison) { - m_healthIcon[icon].PlayVisualRange(L"HalfPoison",nullptr,L"HalfPoison"); + m_healthIcon[icon].PlayVisualRange( + isHardcore ? L"HalfPoisonHardcore" : L"HalfPoison", nullptr, + isHardcore ? L"HalfPoisonHardcore" : L"HalfPoison"); } else { - m_healthIcon[icon].PlayVisualRange(L"Half",nullptr,L"Half"); + m_healthIcon[icon].PlayVisualRange( + isHardcore ? L"HalfHardcore" : L"Half", nullptr, + isHardcore ? L"HalfHardcore" : L"Half"); } } else { // Empty - m_healthIcon[icon].PlayVisualRange(L"Normal",nullptr,L"Normal"); + m_healthIcon[icon].PlayVisualRange( + isHardcore ? L"NormalHardcore" : L"Normal", nullptr, + isHardcore ? L"NormalHardcore" : L"Normal"); } } diff --git a/Minecraft.Client/EnderDragonRenderer.cpp b/Minecraft.Client/EnderDragonRenderer.cpp index ed50748e..44e92192 100644 --- a/Minecraft.Client/EnderDragonRenderer.cpp +++ b/Minecraft.Client/EnderDragonRenderer.cpp @@ -89,6 +89,11 @@ void EnderDragonRenderer::render(shared_ptr _mob, double x, double y, do // 4J - dynamic cast required because we aren't using templates/generics in our version shared_ptr mob = dynamic_pointer_cast(_mob); BossMobGuiInfo::setBossHealth(mob, false); + if (!mob->getCustomName().empty()) + { + BossMobGuiInfo::name = mob->getCustomName(); + } + MobRenderer::render(mob, x, y, z, rot, a); if (mob->nearestCrystal != nullptr) { diff --git a/Minecraft.Client/EntityTracker.cpp b/Minecraft.Client/EntityTracker.cpp index 76fb7989..4dbcf2c3 100644 --- a/Minecraft.Client/EntityTracker.cpp +++ b/Minecraft.Client/EntityTracker.cpp @@ -21,6 +21,10 @@ #include "../Minecraft.World/net.minecraft.world.level.chunk.h" #include "PlayerConnection.h" +#ifdef _WINDOWS64 +extern bool g_Win64DedicatedServer; +#endif + EntityTracker::EntityTracker(ServerLevel *level) { this->level = level; @@ -140,32 +144,40 @@ void EntityTracker::tick() // 4J Stu - If one player on a system is updated, then make sure they all are as they all have their // range extended to include entities visible by any other player on the system // Fix for #11194 - Gameplay: Host player and their split-screen avatars can become invisible and invulnerable to client. - MinecraftServer *server = MinecraftServer::getInstance(); - for( unsigned int i = 0; i < server->getPlayers()->players.size(); i++ ) + // NOTE: On dedicated servers, IsSameSystem() always returns false for remote + // players (no split-screen), so this loop does nothing. Skip it entirely to + // avoid the O(players * movedPlayers) overhead. +#ifdef _WINDOWS64 + if (!g_Win64DedicatedServer) +#endif { - shared_ptr ep = server->getPlayers()->players[i]; - if( ep->dimension != level->dimension->id ) continue; - - if( ep->connection == nullptr ) continue; - INetworkPlayer *thisPlayer = ep->connection->getNetworkPlayer(); - if( thisPlayer == nullptr ) continue; - - bool addPlayer = false; - for (unsigned int j = 0; j < movedPlayers.size(); j++) + MinecraftServer *server = MinecraftServer::getInstance(); + for( unsigned int i = 0; i < server->getPlayers()->players.size(); i++ ) { - shared_ptr sp = movedPlayers[j]; + shared_ptr ep = server->getPlayers()->players[i]; + if( ep->dimension != level->dimension->id ) continue; - if( sp == ep ) break; + if( ep->connection == nullptr ) continue; + INetworkPlayer *thisPlayer = ep->connection->getNetworkPlayer(); + if( thisPlayer == nullptr ) continue; - if(sp->connection == nullptr) continue; - INetworkPlayer *otherPlayer = sp->connection->getNetworkPlayer(); - if( otherPlayer != nullptr && thisPlayer->IsSameSystem(otherPlayer) ) + bool addPlayer = false; + for (unsigned int j = 0; j < movedPlayers.size(); j++) { - addPlayer = true; - break; + shared_ptr sp = movedPlayers[j]; + + if( sp == ep ) break; + + if(sp->connection == nullptr) continue; + INetworkPlayer *otherPlayer = sp->connection->getNetworkPlayer(); + if( otherPlayer != nullptr && thisPlayer->IsSameSystem(otherPlayer) ) + { + addPlayer = true; + break; + } } - } - if( addPlayer ) movedPlayers.push_back( ep ); + if( addPlayer ) movedPlayers.push_back( ep ); + } } for (unsigned int i = 0; i < movedPlayers.size(); i++) diff --git a/Minecraft.Client/Extrax64Stubs.cpp b/Minecraft.Client/Extrax64Stubs.cpp index a699e953..d4c67aa3 100644 --- a/Minecraft.Client/Extrax64Stubs.cpp +++ b/Minecraft.Client/Extrax64Stubs.cpp @@ -196,9 +196,29 @@ void IQNetPlayer::SendData(IQNetPlayer * player, const void* pvData, DWORD dwDat { if (!WinsockNetLayer::IsHosting() && !m_isRemote) { + // Client sending to server via local socket (bypasses SendToSmallId) SOCKET sock = WinsockNetLayer::GetLocalSocket(m_smallId); if (sock != INVALID_SOCKET) - WinsockNetLayer::SendOnSocket(sock, pvData, dwDataSize); + { + // Encrypt if client send cipher is active + if (dwDataSize > 0) + { + std::vector buf(static_cast(pvData), + static_cast(pvData) + dwDataSize); + if (WinsockNetLayer::TryEncryptClientOutgoing(buf.data(), static_cast(dwDataSize))) + { + WinsockNetLayer::SendOnSocket(sock, buf.data(), static_cast(dwDataSize)); + } + else + { + WinsockNetLayer::SendOnSocket(sock, pvData, dwDataSize); + } + } + else + { + WinsockNetLayer::SendOnSocket(sock, pvData, dwDataSize); + } + } } else { diff --git a/Minecraft.Client/Font.cpp b/Minecraft.Client/Font.cpp index f610c585..85b1391c 100644 --- a/Minecraft.Client/Font.cpp +++ b/Minecraft.Client/Font.cpp @@ -8,6 +8,7 @@ #include "../Minecraft.World/net.minecraft.h" #include "../Minecraft.World/StringHelpers.h" #include "../Minecraft.World/Random.h" +#include "..\Minecraft.World\ArabicShaping.h" Font::Font(Options *options, const wstring& name, Textures* textures, bool enforceUnicode, ResourceLocation *textureLocation, int cols, int rows, int charWidth, int charHeight, unsigned short charMap[]/* = nullptr */) : textures(textures) { @@ -16,7 +17,7 @@ Font::Font(Options *options, const wstring& name, Textures* textures, bool enfor charWidths = new int[charC]; // 4J - added initialisers - memset(charWidths, 0, charC); + memset(charWidths, 0, charC * sizeof(int)); enforceUnicodeSheet = false; bidirectional = false; @@ -26,6 +27,19 @@ Font::Font(Options *options, const wstring& name, Textures* textures, bool enfor m_underline = false; m_strikethrough = false; + memset(unicodeTexID, 0, sizeof(unicodeTexID)); + memset(unicodeWidth, 0, sizeof(unicodeWidth)); + lastBoundTexture = 0; + + // Load unicode glyph sizes + FILE *glyphFile = nullptr; + fopen_s(&glyphFile, "Common/res/1_2_2/font/glyph_sizes.bin", "rb"); + if (glyphFile) + { + fread(unicodeWidth, 1, 65536, glyphFile); + fclose(glyphFile); + } + // Set up member variables m_cols = cols; m_rows = rows; @@ -268,7 +282,87 @@ void Font::drawLiteral(const wstring& str, int x, int y, int color) yPos = static_cast(y); wstring cleanStr = sanitize(str); for (size_t i = 0; i < cleanStr.length(); ++i) - renderCharacter(cleanStr.at(i)); + { + wchar_t c = cleanStr.at(i); + if (isUnicodeGlyphChar(c)) + { + renderUnicodeCharacter(c); + textures->bindTexture(m_textureLocation); + lastBoundTexture = fontTexture; + } + else + { + renderCharacter(c); + } + } +} + +// Like sanitize() but skips the shapeArabicText() call - for pre-shaped strings. +wstring Font::sanitizePreshaped(const wstring& str) +{ + wstring sb = str; + for (unsigned int i = 0; i < sb.length(); i++) + { + if (CharacterExists(sb[i])) + sb[i] = MapCharacter(sb[i]); + else if (unicodeWidth[sb[i]] != 0) + { + // Leave as-is: raw codepoint for glyph page rendering + } + else + { + sb[i] = 0; + } + } + return sb; +} + +void Font::drawLiteralPreshaped(const wstring& str, int x, int y, int color) +{ + if (str.empty()) return; + if ((color & 0xFC000000) == 0) color |= 0xFF000000; + textures->bindTexture(m_textureLocation); + glColor4f((color >> 16 & 255) / 255.0F, (color >> 8 & 255) / 255.0F, (color & 255) / 255.0F, (color >> 24 & 255) / 255.0F); + xPos = static_cast(x); + yPos = static_cast(y); + wstring cleanStr = sanitizePreshaped(str); + for (size_t i = 0; i < cleanStr.length(); ++i) + { + wchar_t c = cleanStr.at(i); + if (isUnicodeGlyphChar(c)) + { + renderUnicodeCharacter(c); + textures->bindTexture(m_textureLocation); + lastBoundTexture = fontTexture; + } + else + { + renderCharacter(c); + } + } +} + +void Font::drawShadowLiteralPreshaped(const wstring& str, int x, int y, int color) +{ + int shadowColor = (color & 0xFCFCFC) >> 2 | (color & 0xFF000000); + drawLiteralPreshaped(str, x + 1, y + 1, shadowColor); + drawLiteralPreshaped(str, x, y, color); +} + +int Font::widthPreshaped(const wstring& str) +{ + wstring cleanStr = sanitizePreshaped(str); + if (cleanStr.empty()) return 0; + int len = 0; + for (size_t i = 0; i < cleanStr.length(); ++i) + { + wchar_t wc = cleanStr.at(i); + if (isUnicodeGlyphChar(wc)) + len += (int)unicodeCharWidth(wc); + else + len += charWidths[static_cast(wc)]; + } + return len; } void Font::drawShadowWordWrap(const wstring &str, int x, int y, int w, int color, int h) @@ -358,7 +452,7 @@ void Font::draw(const wstring &str, bool dropShadow, int initialColor) } // "noise" for crazy splash screen message - if (noise) + if (noise && !isUnicodeGlyphChar(c)) { int newc; do @@ -368,7 +462,23 @@ void Font::draw(const wstring &str, bool dropShadow, int initialColor) c = newc; } - addCharacterQuad(c); + if (isUnicodeGlyphChar(c)) + { + t->end(); + // renderUnicodeCharacter uses its own begin/end and relies on glColor + glColor4f((currentColor >> 16 & 255) / 255.0F, (currentColor >> 8 & 255) / 255.0F, + (currentColor & 255) / 255.0F, (currentColor >> 24 & 255) / 255.0F); + renderUnicodeCharacter(c); + glColor4f(1.0F, 1.0F, 1.0F, 1.0F); + textures->bindTexture(m_textureLocation); + lastBoundTexture = fontTexture; + t->begin(); + t->color(currentColor & 0x00ffffff, (currentColor >> 24) & 255); + } + else + { + addCharacterQuad(c); + } } t->end(); @@ -409,11 +519,22 @@ int Font::width(const wstring& str) ++i; else { - len += charWidths[167]; + if (isUnicodeGlyphChar(167)) + len += (int)unicodeCharWidth(167); + else + len += charWidths[167]; if (i + 1 < cleanStr.length()) - len += charWidths[static_cast(cleanStr[++i])]; + { + wchar_t nextC = cleanStr[++i]; + if (isUnicodeGlyphChar(nextC)) + len += (int)unicodeCharWidth(nextC); + else if (static_cast(nextC) < static_cast(m_cols * m_rows)) + len += charWidths[static_cast(nextC)]; + } } } + else if (isUnicodeGlyphChar(c)) + len += (int)unicodeCharWidth(c); else len += charWidths[c]; } @@ -427,13 +548,19 @@ int Font::widthLiteral(const wstring& str) if (cleanStr == L"") return 0; int len = 0; for (size_t i = 0; i < cleanStr.length(); ++i) - len += charWidths[static_cast(cleanStr.at(i))]; + { + wchar_t wc = cleanStr.at(i); + if (isUnicodeGlyphChar(wc)) + len += (int)unicodeCharWidth(wc); + else + len += charWidths[static_cast(wc)]; + } return len; } wstring Font::sanitize(const wstring& str) { - wstring sb = str; + wstring sb = shapeArabicText(str); for (unsigned int i = 0; i < sb.length(); i++) { @@ -441,6 +568,10 @@ wstring Font::sanitize(const wstring& str) { sb[i] = MapCharacter(sb[i]); } + else if (unicodeWidth[sb[i]] != 0) + { + // Leave as-is: raw codepoint for glyph page rendering + } else { // If this character isn't supported, just show the first character (empty square box character) @@ -684,33 +815,22 @@ void Font::renderFakeCB(IntBuffer *ib) } } } +*/ void Font::loadUnicodePage(int page) { - wchar_t fileName[25]; - //String fileName = String.format("/1_2_2/font/glyph_%02X.png", page); - swprintf(fileName,25,L"/1_2_2/font/glyph_%02X.png",page); + wchar_t fileName[40]; + swprintf(fileName, 40, L"/1_2_2/font/glyph_%02X.png", page); BufferedImage *image = new BufferedImage(fileName); - //try - //{ - // image = ImageIO.read(Textures.class.getResourceAsStream(fileName.toString())); - //} - //catch (IOException e) - //{ - // throw new RuntimeException(e); - //} - unicodeTexID[page] = textures->getTexture(image); lastBoundTexture = unicodeTexID[page]; + delete image; } void Font::renderUnicodeCharacter(wchar_t c) { if (unicodeWidth[c] == 0) - { - // System.out.println("no-width char " + c); return; - } int page = c / 256; @@ -722,19 +842,17 @@ void Font::renderUnicodeCharacter(wchar_t c) lastBoundTexture = unicodeTexID[page]; } - // first column with non-trans pixels int firstLeft = unicodeWidth[c] >> 4; - // last column with non-trans pixels int firstRight = unicodeWidth[c] & 0xF; - float left = firstLeft; - float right = firstRight + 1; + float left = (float)firstLeft; + float right = (float)(firstRight + 1); - float xOff = c % 16 * 16 + left; - float yOff = (c & 0xFF) / 16 * 16; + float xOff = (c % 16) * 16 + left; + float yOff = ((c & 0xFF) / 16) * 16; float width = right - left - .02f; - Tesselator *t = Tesselator::getInstance(); + Tesselator *t = Tesselator::getInstance(); t->begin(GL_TRIANGLE_STRIP); t->tex(xOff / 256.0F, yOff / 256.0F); t->vertex(xPos, yPos, 0.0f); @@ -748,5 +866,17 @@ void Font::renderUnicodeCharacter(wchar_t c) xPos += (right - left) / 2 + 1; } -*/ + +float Font::unicodeCharWidth(wchar_t c) +{ + if (unicodeWidth[c] == 0) return 0; + int firstLeft = unicodeWidth[c] >> 4; + int firstRight = unicodeWidth[c] & 0xF; + return (firstRight + 1 - firstLeft) / 2.0f + 1; +} + +bool Font::isUnicodeGlyphChar(wchar_t c) +{ + return c >= m_cols * m_rows && unicodeWidth[c] != 0; +} diff --git a/Minecraft.Client/Font.h b/Minecraft.Client/Font.h index 58bceb4c..91ee5aec 100644 --- a/Minecraft.Client/Font.h +++ b/Minecraft.Client/Font.h @@ -18,6 +18,10 @@ private: Textures *textures; + int unicodeTexID[256]; + unsigned char unicodeWidth[65536]; + int lastBoundTexture; + float xPos; float yPos; @@ -72,10 +76,18 @@ private: void drawLiteral(const wstring& str, int x, int y, int color); // no § parsing int MapCharacter(wchar_t c); // 4J added bool CharacterExists(wchar_t c); // 4J added + void loadUnicodePage(int page); + void renderUnicodeCharacter(wchar_t c); + float unicodeCharWidth(wchar_t c); + bool isUnicodeGlyphChar(wchar_t c); + wstring sanitizePreshaped(const wstring& str); // sanitize without re-shaping Arabic + void drawLiteralPreshaped(const wstring& str, int x, int y, int color); public: int width(const wstring& str); int widthLiteral(const wstring& str); // width without skipping § codes (for chat input) + int widthPreshaped(const wstring& str); // width of already-shaped text, no re-shaping + void drawShadowLiteralPreshaped(const wstring& str, int x, int y, int color); wstring sanitize(const wstring& str); void drawWordWrap(const wstring &string, int x, int y, int w, int col, int h); // 4J Added h param diff --git a/Minecraft.Client/GameRenderer.cpp b/Minecraft.Client/GameRenderer.cpp index 0e4dfbf6..a4269292 100644 --- a/Minecraft.Client/GameRenderer.cpp +++ b/Minecraft.Client/GameRenderer.cpp @@ -1546,7 +1546,7 @@ void GameRenderer::renderLevel(float a, int64_t until) if (visibleWaterChunks > 0) { PIXBeginNamedEvent(0,"Fancy second pass - actual rendering"); - levelRenderer->render(cameraEntity, 1, a, updateChunks); // 4J - chanaged, used to be renderSameAsLast but we don't support that anymore + levelRenderer->renderChunksDirect(1, a); // Lightweight path — skips redundant allChanged/resortChunks checks PIXEndNamedEvent(); } diff --git a/Minecraft.Client/Gui.cpp b/Minecraft.Client/Gui.cpp index 582f25bd..b2e91764 100644 --- a/Minecraft.Client/Gui.cpp +++ b/Minecraft.Client/Gui.cpp @@ -499,11 +499,10 @@ void Gui::render(float a, bool mouseFree, int xMouse, int yMouse) int y0 = 0; - // No hardcore on console - /*if (minecraft->level.getLevelData().isHardcore()) - { - y0 = 5; - }*/ + //if (minecraft->level->getLevelData()->isHardcore()) + //{ + // y0 = 5; + //} blit(xo, yo, 16 + bg * 9, 9 * y0, 9, 9); if (blink) @@ -854,10 +853,11 @@ void Gui::render(float a, bool mouseFree, int xMouse, int yMouse) // font.draw(str, x + 1, y, 0xffffff); // } + lastTickA = a; // 4J Stu - This is now displayed in a xui scene #if 0 - // Jukebox CD message +// Jukebox CD message if (overlayMessageTime > 0) { float t = overlayMessageTime - a; @@ -1064,6 +1064,13 @@ void Gui::render(float a, bool mouseFree, int xMouse, int yMouse) vector lines; + // Only show version/branch for player 0 to avoid cluttering each splitscreen viewport + if (iPad == 0 && ClientConstants::SHOW_VERSION_WATERMARK) + { + lines.push_back(ClientConstants::VERSION_STRING); + lines.push_back(ClientConstants::BRANCH_STRING); + } + if (minecraft->options->renderDebug && minecraft->player != nullptr && minecraft->level != nullptr) { lines.push_back(minecraft->fpsString); @@ -1418,6 +1425,9 @@ void Gui::clearMessages(int iPad) void Gui::addMessage(const wstring& _string,int iPad,bool bIsDeathMessage) { + { char buf[32]; sprintf_s(buf, "[CHAT] Display (pad=%d): ", iPad); OutputDebugStringA(buf); } + OutputDebugStringW(_string.c_str()); + OutputDebugStringA("\n"); wstring string = _string; // 4J - Take copy of input as it is const //int iScale=1; diff --git a/Minecraft.Client/GuiComponent.cpp b/Minecraft.Client/GuiComponent.cpp index c6b5f518..f104f0b7 100644 --- a/Minecraft.Client/GuiComponent.cpp +++ b/Minecraft.Client/GuiComponent.cpp @@ -110,6 +110,11 @@ void GuiComponent::drawStringLiteral(Font *font, const wstring& str, int x, int font->drawShadowLiteral(str, x, y, color); } +void GuiComponent::drawStringPreshaped(Font *font, const wstring& str, int x, int y, int color) +{ + font->drawShadowLiteralPreshaped(str, x, y, color); +} + void GuiComponent::blit(int x, int y, int sx, int sy, int w, int h) { float us = 1 / 256.0f; diff --git a/Minecraft.Client/GuiComponent.h b/Minecraft.Client/GuiComponent.h index 41496664..8959582e 100644 --- a/Minecraft.Client/GuiComponent.h +++ b/Minecraft.Client/GuiComponent.h @@ -16,5 +16,6 @@ public: void drawCenteredString(Font *font, const wstring& str, int x, int y, int color); void drawString(Font *font, const wstring& str, int x, int y, int color); void drawStringLiteral(Font* font, const wstring& str, int x, int y, int color); + void drawStringPreshaped(Font* font, const wstring& str, int x, int y, int color); void blit(int x, int y, int sx, int sy, int w, int h); }; diff --git a/Minecraft.Client/HumanoidModel.cpp b/Minecraft.Client/HumanoidModel.cpp index adc970ef..b82d745d 100644 --- a/Minecraft.Client/HumanoidModel.cpp +++ b/Minecraft.Client/HumanoidModel.cpp @@ -442,7 +442,7 @@ void HumanoidModel::setupAnim(float time, float r, float bob, float yRot, float if (riding) { - if(uiBitmaskOverrideAnim&(1<xRot += -HALF_PI * 0.4f; arm1->xRot += -HALF_PI * 0.4f; diff --git a/Minecraft.Client/ItemInHandRenderer.cpp b/Minecraft.Client/ItemInHandRenderer.cpp index 64e792b9..052123ac 100644 --- a/Minecraft.Client/ItemInHandRenderer.cpp +++ b/Minecraft.Client/ItemInHandRenderer.cpp @@ -289,6 +289,20 @@ void ItemInHandRenderer::renderItem(shared_ptr mob, shared_ptrgetAnimOverrideBitmask() & (1 << HumanoidModel::eAnim_SmallModel)) + { + if (mob->isRiding()) + { + std::shared_ptr ridingEntity = mob->riding; + if (ridingEntity != nullptr) // Safety check; + { + yo += 0.3f; // reverts the change in Boat.cpp for smaller models. + } + } + } glEnable(GL_RESCALE_NORMAL); glTranslatef(-xo, -yo, 0); diff --git a/Minecraft.Client/LevelRenderer.cpp b/Minecraft.Client/LevelRenderer.cpp index 0a18d936..e1e89b58 100644 --- a/Minecraft.Client/LevelRenderer.cpp +++ b/Minecraft.Client/LevelRenderer.cpp @@ -157,6 +157,11 @@ LevelRenderer::LevelRenderer(Minecraft *mc, Textures *textures) dirtyChunkPresent = false; lastDirtyChunkFound = 0; + visibleLists_layer0 = nullptr; + visibleLists_layer1 = nullptr; + visibleCount_layer0 = 0; + visibleCount_layer1 = 0; + this->mc = mc; this->textures = textures; @@ -455,6 +460,12 @@ void LevelRenderer::allChanged(int playerIndex) // delete sortedChunks[playerIndex]; // 4J - removed - not sorting our chunks anymore } + // Free old visible chunk lists + delete[] visibleLists_layer0; + delete[] visibleLists_layer1; + visibleLists_layer0 = nullptr; + visibleLists_layer1 = nullptr; + chunks[playerIndex] = ClipChunkArray(xChunks * yChunks * zChunks); // sortedChunks[playerIndex] = new vector(xChunks * yChunks * zChunks); // 4J - removed - not sorting our chunks anymore int id = 0; @@ -487,6 +498,13 @@ void LevelRenderer::allChanged(int playerIndex) } nonStackDirtyChunksAdded(); + // Allocate visible chunk lists (worst case: all chunks visible) + int totalChunkCount = xChunks * yChunks * zChunks; + visibleLists_layer0 = new int[totalChunkCount]; + visibleLists_layer1 = new int[totalChunkCount]; + visibleCount_layer0 = 0; + visibleCount_layer1 = 0; + if (level != nullptr) { shared_ptr player = mc->cameraTargetPlayer; @@ -705,42 +723,42 @@ int LevelRenderer::render(shared_ptr player, int layer, double alp { int playerIndex = mc->player->GetXboxPad(); - // 4J - added - if the number of players has changed, we need to rebuild things for the new draw distance this will require - if( lastPlayerCount[playerIndex] != activePlayers() ) - { - allChanged(); - } - else if (mc->options->viewDistance != lastViewDistance) - { - allChanged(); - } - + // Only check allChanged/resortChunks on layer 0 — they only need to run once per frame if (layer == 0) { + // 4J - added - if the number of players has changed, we need to rebuild things for the new draw distance this will require + if( lastPlayerCount[playerIndex] != activePlayers() ) + { + allChanged(); + } + else if (mc->options->viewDistance != lastViewDistance) + { + allChanged(); + } + totalChunks = 0; offscreenChunks = 0; occludedChunks = 0; renderedChunks = 0; emptyChunks = 0; + + double xd = player->x - xOld[playerIndex]; + double yd = player->y - yOld[playerIndex]; + double zd = player->z - zOld[playerIndex]; + + if (xd * xd + yd * yd + zd * zd > 4 * 4) + { + xOld[playerIndex] = player->x; + yOld[playerIndex] = player->y; + zOld[playerIndex] = player->z; + + resortChunks(Mth::floor(player->x), Mth::floor(player->y), Mth::floor(player->z)); + } } double xOff = player->xOld + (player->x - player->xOld) * alpha; double yOff = player->yOld + (player->y - player->yOld) * alpha; double zOff = player->zOld + (player->z - player->zOld) * alpha; - - double xd = player->x - xOld[playerIndex]; - double yd = player->y - yOld[playerIndex]; - double zd = player->z - zOld[playerIndex]; - - if (xd * xd + yd * yd + zd * zd > 4 * 4) - { - xOld[playerIndex] = player->x; - yOld[playerIndex] = player->y; - zOld[playerIndex] = player->z; - - resortChunks(Mth::floor(player->x), Mth::floor(player->y), Mth::floor(player->z)); - // sort(sortedChunks[playerIndex]->begin(),sortedChunks[playerIndex]->end(), DistanceChunkSorter(player)); // 4J - removed - not sorting our chunks anymore - } Lighting::turnOff(); int count = renderChunks(0, static_cast(chunks[playerIndex].length), layer, alpha); @@ -749,6 +767,38 @@ int LevelRenderer::render(shared_ptr player, int layer, double alp } +// Lightweight render path for the second layer 1 pass — skips allChanged/resortChunks checks +// and just does GL setup + visible list iteration + cleanup. +// Assumes Lighting::turnOff() was already called by the prior render() invocation. +void LevelRenderer::renderChunksDirect(int layer, double alpha) +{ + shared_ptr player = mc->cameraTargetPlayer; + if (player == nullptr) return; + + mc->gameRenderer->turnOnLightLayer(alpha); + double xOff = player->xOld + (player->x - player->xOld) * alpha; + double yOff = player->yOld + (player->y - player->yOld) * alpha; + double zOff = player->zOld + (player->z - player->zOld) * alpha; + + glPushMatrix(); + glTranslatef(static_cast(-xOff), static_cast(-yOff), static_cast(-zOff)); + + int *lists = (layer == 0) ? visibleLists_layer0 : visibleLists_layer1; + int numVisible = (layer == 0) ? visibleCount_layer0 : visibleCount_layer1; + bool first = true; + if (lists != nullptr) + { + for (int i = 0; i < numVisible; i++) + { + if (RenderManager.CBuffCall(lists[i], first)) + first = false; + } + } + + glPopMatrix(); + mc->gameRenderer->turnOffLightLayer(alpha); +} + #ifdef __PSVITA__ #include @@ -819,23 +869,35 @@ int LevelRenderer::renderChunks(int from, int to, int layer, double alpha) bool first = true; int count = 0; - ClipChunk *pClipChunk = chunks[playerIndex].data; - unsigned char emptyFlag = LevelRenderer::CHUNK_FLAG_EMPTY0 << layer; - for( int i = 0; i < chunks[playerIndex].length; i++, pClipChunk++ ) + + // Use compact visible lists built during cull() instead of iterating all chunks + int *lists = (layer == 0) ? visibleLists_layer0 : visibleLists_layer1; + int numVisible = (layer == 0) ? visibleCount_layer0 : visibleCount_layer1; + if (lists != nullptr) { - if( !pClipChunk->visible ) continue; // This will be set if the chunk isn't visible, or isn't compiled, or has both empty flags set - if( pClipChunk->globalIdx == -1 ) continue; // Not sure if we should ever encounter this... TODO check - if( ( globalChunkFlags[pClipChunk->globalIdx] & emptyFlag ) == emptyFlag ) continue; // Check that this particular layer isn't empty - - // List can be calculated directly from the chunk's global idex - int list = pClipChunk->globalIdx * 2 + layer; - list += chunkLists; - - if(RenderManager.CBuffCall(list, first)) + for (int i = 0; i < numVisible; i++) { - first = false; + if (RenderManager.CBuffCall(lists[i], first)) + first = false; + count++; + } + } + else + { + // Fallback: iterate all chunks (before visible lists are allocated) + ClipChunk *pClipChunk = chunks[playerIndex].data; + unsigned char emptyFlag = LevelRenderer::CHUNK_FLAG_EMPTY0 << layer; + for( int i = 0; i < chunks[playerIndex].length; i++, pClipChunk++ ) + { + if( !pClipChunk->visible ) continue; + if( pClipChunk->globalIdx == -1 ) continue; + if( ( globalChunkFlags[pClipChunk->globalIdx] & emptyFlag ) == emptyFlag ) continue; + int list = pClipChunk->globalIdx * 2 + layer; + list += chunkLists; + if(RenderManager.CBuffCall(list, first)) + first = false; + count++; } - count++; } #ifdef __PSVITA__ @@ -1968,6 +2030,9 @@ bool LevelRenderer::updateDirtyChunks() for( int y = 0; y < CHUNK_Y_COUNT; y++ ) { ClipChunk *pClipChunk = &chunks[p][(z * yChunks + y) * xChunks + x]; + // Early-out for non-dirty chunks - avoids distance calculation for the vast majority at steady state + if( !(globalChunkFlags[ pClipChunk->globalIdx ] & CHUNK_FLAG_DIRTY) ) + continue; // Get distance to this chunk - deliberately not calling the chunk's method of doing this to avoid overheads (passing entitie, type conversion etc.) that this involves int xd = pClipChunk->xm - px; int yd = pClipChunk->ym - py; @@ -2163,7 +2228,9 @@ bool LevelRenderer::updateDirtyChunks() else { // Nothing to do - clear flags that there are things to process, unless it's been a while since we found any dirty chunks in which case force a check next time through - if( ( System::currentTimeMillis() - lastDirtyChunkFound ) > FORCE_DIRTY_CHUNK_CHECK_PERIOD_MS ) + // Scale recheck period with render distance to reduce wasted full-scans at high distances + int recheckPeriod = (xChunks >= 60) ? 1000 : (xChunks >= 40) ? 500 : FORCE_DIRTY_CHUNK_CHECK_PERIOD_MS; + if( ( System::currentTimeMillis() - lastDirtyChunkFound ) > recheckPeriod ) { dirtyChunkPresent = true; } @@ -2564,32 +2631,81 @@ void LevelRenderer::cull(Culler *culler, float a) fdraw[i * 4 + 3] = static_cast(fd->m_Frustum[i][3] + (fx * -fc->xOff) + (fy * -fc->yOff) + (fz * -fc->zOff)); } - ClipChunk *pClipChunk = chunks[playerIndex].data; int vis = 0; int total = 0; int numWrong = 0; - for (unsigned int i = 0; i < chunks[playerIndex].length; i++) + + // Reset visible chunk lists for this frame + visibleCount_layer0 = 0; + visibleCount_layer1 = 0; + + // Column-level frustum culling: test one AABB per XZ column before testing individual Y chunks. + // At dist 64 this reduces ~278K clip() calls to ~17K column tests + per-chunk tests only for visible columns. + for (int x = 0; x < xChunks; x++) { - unsigned char flags = pClipChunk->globalIdx == -1 ? 0 : globalChunkFlags[ pClipChunk->globalIdx ]; + for (int z = 0; z < zChunks; z++) + { + // Build column AABB from bottom and top chunks in this column + ClipChunk *bottomChunk = &chunks[playerIndex][(z * yChunks + 0) * xChunks + x]; + ClipChunk *topChunk = &chunks[playerIndex][(z * yChunks + (yChunks - 1)) * xChunks + x]; + float columnAABB[6] = { + bottomChunk->aabb[0], bottomChunk->aabb[1], bottomChunk->aabb[2], // minX, minY, minZ + bottomChunk->aabb[3], topChunk->aabb[4], bottomChunk->aabb[5] // maxX, maxY(top), maxZ + }; - // Always perform frustum cull test - bool clipres = clip(pClipChunk->aabb, fdraw); + // Test entire column against frustum + if (!clip(columnAABB, fdraw)) + { + // Entire column outside frustum — mark all Y chunks invisible + for (int y = 0; y < yChunks; y++) + { + ClipChunk *pClipChunk = &chunks[playerIndex][(z * yChunks + y) * xChunks + x]; + pClipChunk->visible = false; + } + continue; + } - if ( (flags & CHUNK_FLAG_COMPILED ) && ( ( flags & CHUNK_FLAG_EMPTYBOTH ) != CHUNK_FLAG_EMPTYBOTH ) ) - { - pClipChunk->visible = clipres; - if( pClipChunk->visible ) vis++; - total++; + // Column is (partially) in frustum — test individual chunks + for (int y = 0; y < yChunks; y++) + { + ClipChunk *pClipChunk = &chunks[playerIndex][(z * yChunks + y) * xChunks + x]; + unsigned char flags = pClipChunk->globalIdx == -1 ? 0 : globalChunkFlags[ pClipChunk->globalIdx ]; + + // Skip frustum test for confirmed-empty compiled chunks - they have nothing to render + if ((flags & CHUNK_FLAG_COMPILED) && (flags & CHUNK_FLAG_EMPTYBOTH) == CHUNK_FLAG_EMPTYBOTH) + { + pClipChunk->visible = false; + continue; + } + + bool clipres = clip(pClipChunk->aabb, fdraw); + + if ( (flags & CHUNK_FLAG_COMPILED ) && ( ( flags & CHUNK_FLAG_EMPTYBOTH ) != CHUNK_FLAG_EMPTYBOTH ) ) + { + pClipChunk->visible = clipres; + if( pClipChunk->visible ) vis++; + total++; + } + else if (clipres) + { + pClipChunk->visible = true; + } + else + { + pClipChunk->visible = false; + } + + // Build compact visible chunk lists for renderChunks() + if (pClipChunk->visible && pClipChunk->globalIdx != -1 && visibleLists_layer0 != nullptr) + { + int list = pClipChunk->globalIdx * 2 + chunkLists; + if (!((flags & CHUNK_FLAG_EMPTY0) == CHUNK_FLAG_EMPTY0)) + visibleLists_layer0[visibleCount_layer0++] = list; + if (!((flags & CHUNK_FLAG_EMPTY1) == CHUNK_FLAG_EMPTY1)) + visibleLists_layer1[visibleCount_layer1++] = list + 1; + } + } } - else if (clipres) - { - pClipChunk->visible = true; - } - else - { - pClipChunk->visible = false; - } - pClipChunk++; } } diff --git a/Minecraft.Client/LevelRenderer.h b/Minecraft.Client/LevelRenderer.h index 5c7b5e50..30db8c4c 100644 --- a/Minecraft.Client/LevelRenderer.h +++ b/Minecraft.Client/LevelRenderer.h @@ -83,6 +83,7 @@ private: void resortChunks(int xc, int yc, int zc); public: int render(shared_ptr player, int layer, double alpha, bool updateChunks); + void renderChunksDirect(int layer, double alpha); private: int renderChunks(int from, int to, int layer, double alpha); public: @@ -270,6 +271,12 @@ public: XLockFreeStack dirtyChunksLockFreeStack; + // Visible chunk lists built by cull(), consumed by renderChunks() + int *visibleLists_layer0; + int *visibleLists_layer1; + int visibleCount_layer0; + int visibleCount_layer1; + bool dirtyChunkPresent; int64_t lastDirtyChunkFound; static const int FORCE_DIRTY_CHUNK_CHECK_PERIOD_MS = 125; // decreased from 250 to 125 - updated by detectiveren diff --git a/Minecraft.Client/Minecraft.cpp b/Minecraft.Client/Minecraft.cpp index 85a60160..3c82e1ec 100644 --- a/Minecraft.Client/Minecraft.cpp +++ b/Minecraft.Client/Minecraft.cpp @@ -77,6 +77,11 @@ #include "Windows64/stb_image_write.h" #endif +#ifdef _WINDOWS64 +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include "Windows64/stb_image_write.h" +#endif + #ifdef __ORBIS__ #include "Orbis/Network/PsPlusUpsellWrapper_Orbis.h" #endif @@ -4338,8 +4343,8 @@ void Minecraft::setLevel(MultiPlayerLevel *level, int message /*=-1*/, shared_pt this->progressRenderer->progressStage(-1); } - // 4J-PB - since we now play music in the menu, just let it keep playing - //soundEngine->playStreaming(L"", 0, 0, 0, 0, 0); + // Stop menu music and transition to game music for the new level + soundEngine->playStreaming(L"", 0, 0, 0, 1, 1); // 4J - stop update thread from processing this level, which blocks until it is safe to move on - will be re-enabled if we set the level to be non-nullptr gameRenderer->DisableUpdateThread(); diff --git a/Minecraft.Client/MinecraftServer.cpp b/Minecraft.Client/MinecraftServer.cpp index 620308a3..2becee1e 100644 --- a/Minecraft.Client/MinecraftServer.cpp +++ b/Minecraft.Client/MinecraftServer.cpp @@ -34,6 +34,9 @@ #ifdef _WINDOWS64 #include "Windows64/Network/WinsockNetLayer.h" #endif +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) +#include "..\Minecraft.Server\ServerLogger.h" +#endif #include #ifdef SPLIT_SAVES #include "../Minecraft.World/ConsoleSaveFileSplit.h" @@ -561,6 +564,7 @@ MinecraftServer::MinecraftServer() m_serverPausedEvent = new C4JThread::Event; m_saveOnExit = false; + m_deleteWorldOnExit = false; m_suspending = false; m_ugcPlayersVersion = 0; @@ -734,10 +738,11 @@ bool MinecraftServer::initServer(int64_t seed, NetworkGameInitData *initData, DW if( findSeed ) { + int worldSizeChunks = (initData && initData->xzSize > 0) ? (int)initData->xzSize : 54; #ifdef __PSVITA__ - seed = BiomeSource::findSeed(pLevelType, &running); + seed = BiomeSource::findSeed(pLevelType, &running, worldSizeChunks); #else - seed = BiomeSource::findSeed(pLevelType); + seed = BiomeSource::findSeed(pLevelType, worldSizeChunks); #endif } @@ -887,6 +892,15 @@ bool MinecraftServer::loadLevel(LevelStorageSource *storageSource, const wstring // } ProgressRenderer *mcprogress = Minecraft::GetInstance()->progressRenderer; + // 4J Added - store save folder name for potential hardcore world deletion + { + char szSaveFolder[MAX_SAVEFILENAME_LENGTH] = {}; + StorageManager.GetSaveUniqueFilename(szSaveFolder); + wchar_t wSaveFolder[MAX_SAVEFILENAME_LENGTH] = {}; + mbstowcs(wSaveFolder, szSaveFolder, MAX_SAVEFILENAME_LENGTH - 1); + m_saveFolderName = wSaveFolder; + } + // 4J TODO - free levels here if there are already some? levels = ServerLevelArray(3); @@ -997,6 +1011,12 @@ bool MinecraftServer::loadLevel(LevelStorageSource *storageSource, const wstring #endif levels[i]->getLevelData()->setGameType(gameType); +#ifdef MINECRAFT_SERVER_BUILD + // Dedicated server: server.properties hardcore flag is authoritative + levels[i]->getLevelData()->setHardcore(isHardcore()); +#endif + // Offline/client-hosted: keep the world's saved hardcore flag from NBT + if(app.getLevelGenerationOptions() != nullptr) { LevelGenerationOptions *mapOptions = app.getLevelGenerationOptions(); @@ -1642,7 +1662,7 @@ bool MinecraftServer::isNetherEnabled() bool MinecraftServer::isHardcore() { - return false; + return app.GetGameHostOption(eGameHostOption_Hardcore) > 0; } int MinecraftServer::getOperatorUserPermissionLevel() @@ -1795,7 +1815,6 @@ void MinecraftServer::run(int64_t seed, void *lpParameter) chunkPacketManagement_PostTick(); } - lastTime = getCurrentTimeMillis(); // int64_t afterall = System::currentTimeMillis(); // PIXReportCounter(L"Server time all",(float)(afterall-beforeall)); // PIXReportCounter(L"Server ticks",(float)tickcount); @@ -1864,11 +1883,23 @@ void MinecraftServer::run(int64_t seed, void *lpParameter) QueryPerformanceCounter(&qwTime); #endif +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + LARGE_INTEGER asTicksPerSec, asT0, asT1; + QueryPerformanceFrequency(&asTicksPerSec); + double asSecsPerTick = 1.0 / (double)asTicksPerSec.QuadPart; + QueryPerformanceCounter(&asT0); + LARGE_INTEGER asAfterPlayers, asAfterLevels, asAfterRules, asAfterFlush; +#endif + if (players != nullptr) { players->saveAll(nullptr); } +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + QueryPerformanceCounter(&asAfterPlayers); +#endif + for (unsigned int j = 0; j < levels.length; j++) { if( s_bServerHalted ) break; @@ -1884,6 +1915,11 @@ void MinecraftServer::run(int64_t seed, void *lpParameter) PIXEndNamedEvent(); #endif } + +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + QueryPerformanceCounter(&asAfterLevels); +#endif + if (!s_bServerHalted) { #if defined(_XBOX_ONE) || defined(__ORBIS__) @@ -1895,7 +1931,24 @@ void MinecraftServer::run(int64_t seed, void *lpParameter) PIXBeginNamedEvent(0, "Save to disc"); #endif + +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + QueryPerformanceCounter(&asAfterRules); +#endif + levels[0]->saveToDisc(Minecraft::GetInstance()->progressRenderer, true); + +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + QueryPerformanceCounter(&asAfterFlush); + ServerRuntime::LogInfof("world-io", + "autosave breakdown: players=%.0fms levels=%.0fms rules=%.0fms flush=%.0fms total=%.0fms", + (asAfterPlayers.QuadPart - asT0.QuadPart) * asSecsPerTick * 1000.0, + (asAfterLevels.QuadPart - asAfterPlayers.QuadPart) * asSecsPerTick * 1000.0, + (asAfterRules.QuadPart - asAfterLevels.QuadPart) * asSecsPerTick * 1000.0, + (asAfterFlush.QuadPart - asAfterRules.QuadPart) * asSecsPerTick * 1000.0, + (asAfterFlush.QuadPart - asT0.QuadPart) * asSecsPerTick * 1000.0); +#endif + #if defined(_XBOX_ONE) || defined(__ORBIS__) PIXEndNamedEvent(); #endif diff --git a/Minecraft.Client/MinecraftServer.h b/Minecraft.Client/MinecraftServer.h index 259b3e39..aa0c4a37 100644 --- a/Minecraft.Client/MinecraftServer.h +++ b/Minecraft.Client/MinecraftServer.h @@ -265,6 +265,8 @@ private: private: // 4J Added bool m_saveOnExit; + bool m_deleteWorldOnExit; // 4J Added - for hardcore mode world deletion + wstring m_saveFolderName; // 4J Added - stored for hardcore world deletion bool m_suspending; public: @@ -278,6 +280,9 @@ public: void chunkPacketManagement_PostTick(); void setSaveOnExit(bool save) { m_saveOnExit = save; s_bSaveOnExitAnswered = true; } + void setDeleteWorldOnExit(bool del) { m_deleteWorldOnExit = del; } + bool getDeleteWorldOnExit() const { return m_deleteWorldOnExit; } + const wstring& getSaveFolderName() const { return m_saveFolderName; } void Suspend(); bool IsSuspending(); diff --git a/Minecraft.Client/MultiPlayerChunkCache.cpp b/Minecraft.Client/MultiPlayerChunkCache.cpp index 3b6d0155..302ff119 100644 --- a/Minecraft.Client/MultiPlayerChunkCache.cpp +++ b/Minecraft.Client/MultiPlayerChunkCache.cpp @@ -139,18 +139,24 @@ bool MultiPlayerChunkCache::reallyHasChunk(int x, int z) return hasData[idx]; } -void MultiPlayerChunkCache::drop(int x, int z) +void MultiPlayerChunkCache::drop(const int x, const int z) { - // 4J Stu - We do want to drop any entities in the chunks, especially for the case when a player is dead as they will - // not get the RemoveEntity packet if an entity is removed. - LevelChunk *chunk = getChunk(x, z); - if (!chunk->isEmpty()) + const int ix = x + XZOFFSET; + const int iz = z + XZOFFSET; + if ((ix < 0) || (ix >= XZSIZE)) return; + if ((iz < 0) || (iz >= XZSIZE)) return; + const int idx = ix * XZSIZE + iz; + LevelChunk* chunk = cache[idx]; + + if (chunk != nullptr && !chunk->isEmpty()) { - // Added parameter here specifies that we don't want to delete tile entities, as they won't get recreated unless they've got update packets - // The tile entities are in general only created on the client by virtue of the chunk rebuild + // Drop entities in the chunks, especially for the case when a player is dead + // as they will not get the RemoveEntity packet if an entity is removed. + // Don't delete tile entities, as they won't get recreated unless they've got + // update packets. Tile entities are created on the client by the chunk rebuild. chunk->unload(false); - // 4J - We just want to clear out the entities in the chunk, but everything else should be valid + // Keep chunk in cache with structural data intact. chunk->loaded = true; } } diff --git a/Minecraft.Client/MultiPlayerGameMode.h b/Minecraft.Client/MultiPlayerGameMode.h index fb21bb67..cc371e50 100644 --- a/Minecraft.Client/MultiPlayerGameMode.h +++ b/Minecraft.Client/MultiPlayerGameMode.h @@ -27,6 +27,7 @@ public: void adjustPlayer(shared_ptr player); bool isCutScene(); void setLocalMode(GameType *mode); + GameType* getLocalPlayerMode() const { return localPlayerMode; } virtual void initPlayer(shared_ptr player); virtual bool canHurtPlayer(); virtual bool destroyBlock(int x, int y, int z, int face); diff --git a/Minecraft.Client/PendingConnection.cpp b/Minecraft.Client/PendingConnection.cpp index 88608cb1..3b1f0a45 100644 --- a/Minecraft.Client/PendingConnection.cpp +++ b/Minecraft.Client/PendingConnection.cpp @@ -17,7 +17,8 @@ #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) #include "../Minecraft.Server/ServerLogManager.h" #include "../Minecraft.Server/Access/Access.h" -#include "../Minecraft.World/Socket.h" +#include "..\Minecraft.Server/Security/SecurityConfig.h" +#include "..\Minecraft.World\Socket.h" #endif // #ifdef __PS3__ // #include "PS3/Network/NetworkPlayerSony.h" @@ -150,6 +151,20 @@ void PendingConnection::sendPreLoginResponse() } } +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Security: strip real XUIDs from pre-login response to prevent unauthenticated enumeration. + // The client receives the correct player count but cannot identify who is connected. + // Real XUID data is sent post-login via PlayerInfoPacket broadcasts. + if (ServerRuntime::Security::GetSettings().hidePlayerListPreLogin) + { + for (DWORD i = 0; i < ugcXuidCount; ++i) + { + ugcXuids[i] = INVALID_XUID; + } + ugcFriendsOnlyBits = 0; + } +#endif + #if 0 if (false)// server->onlineMode) // 4J - removed { @@ -203,6 +218,56 @@ void PendingConnection::handleLogin(shared_ptr packet) duplicateXuid = true; } +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Cross-reference: if someone claims the same XUID as an existing player from a different IP, + // log and reject as a potential spoofing attempt. + // Note: this runs on the main tick thread (via PendingConnection::tick -> Connection::tick -> + // handleLogin), same thread that mutates the player list, so no lock is needed. + if (!duplicateXuid && loginXuid != INVALID_XUID) + { + std::string newIp; + unsigned char newSmallId = GetPendingConnectionSmallId(connection); + bool hasNewIp = ServerRuntime::ServerLogManager::TryGetConnectionRemoteIp(newSmallId, &newIp); + + for (auto &existingPlayer : server->getPlayers()->players) + { + if (existingPlayer == nullptr) continue; + PlayerUID existingXuid = existingPlayer->connection->m_offlineXUID; + if (existingXuid == INVALID_XUID) existingXuid = existingPlayer->connection->m_onlineXUID; + if (existingXuid == loginXuid) + { + if (hasNewIp) + { + std::string existingIp; + INetworkPlayer *np = existingPlayer->connection->getNetworkPlayer(); + if (np != nullptr) + { + unsigned char existingSmallId = np->GetSmallId(); + if (ServerRuntime::ServerLogManager::TryGetConnectionRemoteIp(existingSmallId, &existingIp)) + { + if (existingIp != newIp) + { + app.DebugPrintf("SECURITY: XUID spoofing suspected - XUID 0x%016llx claimed from IP %s while already connected from IP %s\n", + (unsigned long long)loginXuid, newIp.c_str(), existingIp.c_str()); + ServerRuntime::ServerLogManager::OnXuidSpoofDetected(newSmallId, name, newIp.c_str(), existingIp.c_str()); + duplicateXuid = true; + } + } + } + } + else + { + // Cannot verify IP -- treat same-XUID connection as suspicious + app.DebugPrintf("SECURITY: XUID 0x%016llx claimed but could not verify source IP\n", + (unsigned long long)loginXuid); + duplicateXuid = true; + } + break; + } + } + } +#endif + bool bannedXuid = false; if (loginXuid != INVALID_XUID) { @@ -243,7 +308,11 @@ void PendingConnection::handleLogin(shared_ptr packet) else if (!whitelistSatisfied) { #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Cache name->XUID so `whitelist add ` can resolve the XUID + ServerRuntime::ServerLogManager::CachePlayerXuid(name, loginXuid); ServerRuntime::ServerLogManager::OnRejectedPlayerLogin(GetPendingConnectionSmallId(connection), name, ServerRuntime::ServerLogManager::eLoginRejectReason_NotWhitelisted); + app.DebugPrintf("WHITELIST: Rejected %ls (XUID: 0x%016llx) - use 'whitelist add %ls' to allow\n", + name.c_str(), (unsigned long long)loginXuid, name.c_str()); #endif disconnect(DisconnectPacket::eDisconnect_Banned); } @@ -330,11 +399,17 @@ void PendingConnection::handleAcceptedLogin(shared_ptr packet) PlayerUID playerXuid = packet->m_offlineXuid; if(playerXuid == INVALID_XUID) playerXuid = packet->m_onlineXuid; +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Cache name->XUID for console commands (whitelist add, revoketoken, etc.) + ServerRuntime::ServerLogManager::CachePlayerXuid(name, playerXuid); +#endif + shared_ptr playerEntity = server->getPlayers()->getPlayerForLogin(this, name, playerXuid,packet->m_onlineXuid); if (playerEntity != nullptr) { #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) - ServerRuntime::ServerLogManager::OnAcceptedPlayerLogin(GetPendingConnectionSmallId(connection), name); + ServerRuntime::ServerLogManager::OnAcceptedPlayerLogin(GetPendingConnectionSmallId(connection), name, + packet->m_offlineXuid, packet->m_onlineXuid, packet->m_isGuest); #endif server->getPlayers()->placeNewPlayer(connection, playerEntity, packet); connection = nullptr; // We've moved responsibility for this over to the new PlayerConnection, nullptr so we don't delete our reference to it here in our dtor diff --git a/Minecraft.Client/PlayerChunkMap.cpp b/Minecraft.Client/PlayerChunkMap.cpp index e005982b..be8143d8 100644 --- a/Minecraft.Client/PlayerChunkMap.cpp +++ b/Minecraft.Client/PlayerChunkMap.cpp @@ -487,37 +487,52 @@ void PlayerChunkMap::getChunkAndRemovePlayer(int x, int z, shared_ptr player) { if( addRequests.size() ) { - // Find the nearest chunk request to the player int px = static_cast(player->x); int pz = static_cast(player->z); - int minDistSq = -1; - auto itNearest = addRequests.end(); - for (auto it = addRequests.begin(); it != addRequests.end(); it++) - { - if( it->player == player ) + for (int processed = 0; processed < CHUNKS_PER_PLAYER_PER_TICK; processed++) + { + // Find the nearest chunk request to the player + int minDistSq = -1; + auto itNearest = addRequests.end(); + for (auto it = addRequests.begin(); it != addRequests.end(); it++) { - int xm = ( it->x * 16 ) + 8; - int zm = ( it->z * 16 ) + 8; - int distSq = (xm - px) * (xm - px) + - (zm - pz) * (zm - pz); - if( ( minDistSq == -1 ) || ( distSq < minDistSq ) ) + if( it->player == player ) { - minDistSq = distSq; - itNearest = it; + int xm = ( it->x * 16 ) + 8; + int zm = ( it->z * 16 ) + 8; + int distSq = (xm - px) * (xm - px) + + (zm - pz) * (zm - pz); + if( ( minDistSq == -1 ) || ( distSq < minDistSq ) ) + { + minDistSq = distSq; + itNearest = it; + } } } - } - // If we found one at all, then do this one - if( itNearest != addRequests.end() ) - { - getChunk(itNearest->x, itNearest->z, true)->add(itNearest->player); - addRequests.erase(itNearest); + // If we found one, process it and continue; otherwise done + if( itNearest != addRequests.end() ) + { + getChunk(itNearest->x, itNearest->z, true)->add(itNearest->player); + addRequests.erase(itNearest); + } + else + { + break; + } } } } @@ -792,6 +807,14 @@ void PlayerChunkMap::setRadius(int newRadius) int xc = static_cast(player->x) >> 4; int zc = static_cast(player->z) >> 4; + for (auto it = addRequests.begin(); it != addRequests.end(); ) + { + if (it->player == player) + it = addRequests.erase(it); + else + ++it; + } + for (int x = xc - newRadius; x <= xc + newRadius; x++) for (int z = zc - newRadius; z <= zc + newRadius; z++) { @@ -801,6 +824,18 @@ void PlayerChunkMap::setRadius(int newRadius) getChunkAndAddPlayer(x, z, player); } } + + // Remove chunks that are outside the new radius + for (int x = xc - radius; x <= xc + radius; x++) + { + for (int z = zc - radius; z <= zc + radius; z++) + { + if (x < xc - newRadius || x > xc + newRadius || z < zc - newRadius || z > zc + newRadius) + { + getChunkAndRemovePlayer(x, z, player); + } + } + } } } diff --git a/Minecraft.Client/PlayerConnection.cpp b/Minecraft.Client/PlayerConnection.cpp index ebe06835..690cdc11 100644 --- a/Minecraft.Client/PlayerConnection.cpp +++ b/Minecraft.Client/PlayerConnection.cpp @@ -16,27 +16,28 @@ #include "../Minecraft.World/net.minecraft.world.level.tile.entity.h" #include "../Minecraft.World/net.minecraft.world.level.saveddata.h" #include "../Minecraft.World/net.minecraft.world.entity.animal.h" -#include "../Minecraft.World/net.minecraft.network.h" #include "../Minecraft.World/net.minecraft.world.food.h" #include "../Minecraft.World/AABB.h" -#include "../Minecraft.World/Pos.h" #include "../Minecraft.World/SharedConstants.h" #include "../Minecraft.World/ChatPacket.h" #include "../Minecraft.World/StringHelpers.h" #include "../Minecraft.World/Socket.h" -#include "../Minecraft.World/Achievements.h" #include "../Minecraft.World/net.minecraft.h" -#include "EntityTracker.h" +#include "../Minecraft.World/LevelData.h" #include "ServerConnection.h" #include "../Minecraft.World/GenericStats.h" #include "../Minecraft.World/JavaMath.h" -#include "..\Minecraft.World\ListTag.h" // 4J Added #include "../Minecraft.World/net.minecraft.world.item.crafting.h" #include "Options.h" #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) #include "../Minecraft.Server/ServerLogManager.h" +#include "../Minecraft.Server/Access/Access.h" +#include "../Minecraft.Server/Security/IdentityTokenManager.h" +#include "../Minecraft.Server/Security/SecurityConfig.h" +#include "../Minecraft.Server/Security/ConnectionCipher.h" +extern bool g_Win64DedicatedServer; #endif namespace @@ -85,6 +86,9 @@ PlayerConnection::PlayerConnection(MinecraftServer *server, Connection *connecti m_onlineXUID = INVALID_XUID; m_bHasClientTickedOnce = false; m_logSmallId = 0; + m_identityVerified = false; + m_identityChallengeTick = -1; + m_securityGateOpen = true; // default open; closed when cipher is required // Cache the first valid transport smallId because disconnect teardown can clear it before the server logger runs. if (this->connection != NULL && this->connection->getSocket() != NULL) @@ -143,6 +147,14 @@ void PlayerConnection::tick() { dropSpamTickCount--; } + + // Ensure server-side player tick runs even when no move packet was received this tick. + // Without this, environmental damage (drowning, fire, lava) is never applied to clients + // that don't send frequent move packets. + if (!didTick && player != nullptr) + { + player->doTick(false); + } } void PlayerConnection::disconnect(DisconnectPacket::eDisconnectReason reason) @@ -483,9 +495,9 @@ void PlayerConnection::handlePlayerAction(shared_ptr packet) } else if (packet->action == PlayerActionPacket::STOP_DESTROY_BLOCK) { - player->gameMode->stopDestroyBlock(x, y, z); + bool destroyed = player->gameMode->stopDestroyBlock(x, y, z); server->getPlayers()->prioritiseTileChanges(x, y, z, level->dimension->id); // 4J added - make sure that the update packets for this get prioritised over other general world updates - if (level->getTile(x, y, z) != 0) player->connection->send(std::make_shared(x, y, z, level)); + if (!destroyed && level->getTile(x, y, z) != 0) player->connection->send(std::make_shared(x, y, z, level)); } else if (packet->action == PlayerActionPacket::ABORT_DESTROY_BLOCK) { @@ -612,6 +624,22 @@ void PlayerConnection::onDisconnect(DisconnectPacket::eDisconnectReason reason, LeaveCriticalSection(&done_cs); } +void PlayerConnection::openSecurityGate() +{ + if (m_securityGateOpen) + return; + + m_securityGateOpen = true; + + // Flush all buffered packets now that the cipher is active + for (auto &buffered : m_securityBuffer) + { + send(buffered); + } + m_securityBuffer.clear(); + m_securityBuffer.shrink_to_fit(); +} + void PlayerConnection::onUnhandledPacket(shared_ptr packet) { // logger.warning(getClass() + " wasn't prepared to deal with a " + packet.getClass()); @@ -622,6 +650,39 @@ void PlayerConnection::send(shared_ptr packet) { if( connection->getSocket() != nullptr ) { +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Security gate: when require-secure-client is enabled, buffer ALL outgoing + // packets until the cipher handshake completes. Only the cipher handshake + // CustomPayloadPacket (MC|CKey) is sent immediately. Once the cipher activates, + // openSecurityGate() flushes the buffer. This prevents unsecured/old clients + // from receiving any game data (PlayerInfoPackets, XUIDs, etc.) before being kicked. + if (!m_securityGateOpen) + { + // Allow cipher handshake packets through immediately + if (packet->getId() == 250) + { + auto cpp = dynamic_pointer_cast(packet); + if (cpp != nullptr && + (cpp->identifier == CustomPayloadPacket::CIPHER_KEY_CHANNEL || + cpp->identifier == CustomPayloadPacket::CIPHER_ACK_CHANNEL || + cpp->identifier == CustomPayloadPacket::CIPHER_ON_CHANNEL)) + { + // Fall through to send + } + else + { + m_securityBuffer.push_back(packet); + return; + } + } + else + { + m_securityBuffer.push_back(packet); + return; + } + } +#endif + if( !server->getPlayers()->canReceiveAllPackets( player ) ) { // Check if we are allowed to send this packet type @@ -817,9 +878,7 @@ void PlayerConnection::handleInteract(shared_ptr packet) { if ((target->GetType() == eTYPE_ITEMENTITY) || (target->GetType() == eTYPE_EXPERIENCEORB) || (target->GetType() == eTYPE_ARROW) || target == player) { - //disconnect("Attempting to attack an invalid entity"); - //server.warn("Player " + player.getName() + " tried to attack an invalid entity"); - return; + return; } player->attack(target); } @@ -1064,10 +1123,19 @@ void PlayerConnection::handleServerSettingsChanged(shared_ptraction==ServerSettingsChangedPacket::HOST_IN_GAME_SETTINGS) { - // Need to check that this player has permission to change each individual setting? - INetworkPlayer *networkPlayer = getNetworkPlayer(); - if( (networkPlayer != nullptr && networkPlayer->IsHost()) || player->isModerator()) + bool isHost = (networkPlayer != nullptr && networkPlayer->IsHost()); +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // On dedicated servers, only the host can change server settings. + // Moderators (OPs) should not be able to alter game rules. + if (!isHost) + { + app.DebugPrintf("SECURITY: Non-host player %ls attempted to change server settings\n", + player->getName().c_str()); + return; + } +#endif + if( isHost || player->isModerator()) { app.SetGameHostOption(eGameHostOption_FireSpreads, app.GetGameHostOption(packet->data,eGameHostOption_FireSpreads)); app.SetGameHostOption(eGameHostOption_TNT, app.GetGameHostOption(packet->data,eGameHostOption_TNT)); @@ -1090,14 +1158,81 @@ void PlayerConnection::handleServerSettingsChanged(shared_ptr packet) { INetworkPlayer *networkPlayer = getNetworkPlayer(); - if( (networkPlayer != nullptr && networkPlayer->IsHost()) || player->isModerator()) + bool isHost = (networkPlayer != nullptr && networkPlayer->IsHost()); +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Live ops.json check for non-host players + if (!isHost) { + PlayerUID kickerXuid = m_offlineXUID; + if (kickerXuid == INVALID_XUID) kickerXuid = m_onlineXUID; + if (!ServerRuntime::Access::IsPlayerOp(kickerXuid)) + { + app.DebugPrintf("SECURITY: Non-OP player %ls attempted to kick\n", player->getName().c_str()); + { + INetworkPlayer *npLog = getNetworkPlayer(); + if (npLog != nullptr) + ServerRuntime::ServerLogManager::OnUnauthorizedCommand(npLog->GetSmallId(), player->getName(), "kick"); + } + return; + } + } +#endif + if( isHost || player->isModerator()) + { +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // On dedicated servers, non-host moderators cannot kick other moderators or the host. + if (!isHost) + { + for (auto &checkingPlayer : server->getPlayers()->players) + { + if (checkingPlayer != nullptr && + checkingPlayer->connection->getNetworkPlayer() != nullptr && + checkingPlayer->connection->getNetworkPlayer()->GetSmallId() == packet->m_networkSmallId) + { + if (checkingPlayer->isModerator() || + checkingPlayer->connection->getNetworkPlayer()->IsHost()) + { + app.DebugPrintf("SECURITY: Moderator %ls tried to kick host/moderator %ls\n", + player->getName().c_str(), checkingPlayer->getName().c_str()); + return; + } + break; + } + } + } + app.DebugPrintf("CMD: Player %ls kicked player with smallId=%d\n", + player->getName().c_str(), packet->m_networkSmallId); +#endif server->getPlayers()->kickPlayerByShortId(packet->m_networkSmallId); } } void PlayerConnection::handleGameCommand(shared_ptr packet) { +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + INetworkPlayer *networkPlayer = getNetworkPlayer(); + bool isHost = (networkPlayer != nullptr && networkPlayer->IsHost()); + if (!isHost) + { + // Live ops.json check - in-memory isModerator() can be stale if ops.json was edited mid-session + PlayerUID cmdXuid = m_offlineXUID; + if (cmdXuid == INVALID_XUID) cmdXuid = m_onlineXUID; + if (!ServerRuntime::Access::IsPlayerOp(cmdXuid)) + { + app.DebugPrintf("SECURITY: Non-OP player %ls attempted server command id=%d\n", + player->getName().c_str(), static_cast(packet->command)); + { + INetworkPlayer *npLog = getNetworkPlayer(); + if (npLog != nullptr) + ServerRuntime::ServerLogManager::OnUnauthorizedCommand(npLog->GetSmallId(), player->getName(), "game-command"); + } + return; + } + } + app.DebugPrintf("CMD: Player %ls (OP=%d, Host=%d) executed command id=%d\n", + player->getName().c_str(), player->isModerator() ? 1 : 0, isHost ? 1 : 0, + static_cast(packet->command)); +#endif MinecraftServer::getInstance()->getCommandDispatcher()->performCommand(player, packet->command, packet->data); } @@ -1110,22 +1245,12 @@ void PlayerConnection::handleClientCommand(shared_ptr packe { player = server->getPlayers()->respawn(player, player->m_enteredEndExitPortal?0:player->dimension, true); } - //else if (player.getLevel().getLevelData().isHardcore()) - //{ - // if (server.isSingleplayer() && player.name.equals(server.getSingleplayerName())) - // { - // player.connection.disconnect("You have died. Game over, man, it's game over!"); - // server.selfDestruct(); - // } - // else - // { - // BanEntry ban = new BanEntry(player.name); - // ban.setReason("Death in Hardcore"); - - // server.getPlayers().getBans().add(ban); - // player.connection.disconnect("You have died. Game over, man, it's game over!"); - // } - //} + else if (player->level->getLevelData()->isHardcore()) + { + // Hardcore mode — server rejects respawn. Ban and disconnect are already + // handled in ServerPlayer::die() via banPlayerForHardcoreDeath(). + return; + } else { if (player->getHealth() > 0) return; @@ -1377,10 +1502,21 @@ void PlayerConnection::handleKeepAlive(shared_ptr packet) void PlayerConnection::handlePlayerInfo(shared_ptr packet) { - // Need to check that this player has permission to change each individual setting? - INetworkPlayer *networkPlayer = getNetworkPlayer(); - if( (networkPlayer != nullptr && networkPlayer->IsHost()) || player->isModerator() ) + bool isHost = (networkPlayer != nullptr && networkPlayer->IsHost()); +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Live ops.json check for non-host players + if (!isHost) + { + PlayerUID infoXuid = m_offlineXUID; + if (infoXuid == INVALID_XUID) infoXuid = m_onlineXUID; + if (!ServerRuntime::Access::IsPlayerOp(infoXuid)) + { + return; + } + } +#endif + if( isHost || player->isModerator() ) { shared_ptr serverPlayer; // Find the player being edited @@ -1458,7 +1594,24 @@ void PlayerConnection::handlePlayerInfo(shared_ptr packet) serverPlayer->setPlayerGamePrivilege(Player::ePlayerGamePrivilege_CanToggleClassicHunger,Player::getPlayerGamePrivilege(packet->m_playerPrivileges,Player::ePlayerGamePrivilege_CanToggleClassicHunger) ); serverPlayer->setPlayerGamePrivilege(Player::ePlayerGamePrivilege_CanTeleport,Player::getPlayerGamePrivilege(packet->m_playerPrivileges,Player::ePlayerGamePrivilege_CanTeleport) ); } +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // On dedicated servers, OP can only be granted/revoked if the target is in ops.json. + // This prevents runtime OP escalation via crafted PlayerInfoPackets. + bool wantsOp = Player::getPlayerGamePrivilege(packet->m_playerPrivileges, Player::ePlayerGamePrivilege_Op) != 0; + PlayerUID targetXuid = serverPlayer->connection->m_offlineXUID; + if (targetXuid == INVALID_XUID) targetXuid = serverPlayer->connection->m_onlineXUID; + if (wantsOp && !ServerRuntime::Access::IsPlayerOp(targetXuid)) + { + app.DebugPrintf("SECURITY: Host tried to OP player %ls who is not in ops.json\n", + serverPlayer->getName().c_str()); + } + else + { + serverPlayer->setPlayerGamePrivilege(Player::ePlayerGamePrivilege_Op, wantsOp ? 1u : 0u); + } +#else serverPlayer->setPlayerGamePrivilege(Player::ePlayerGamePrivilege_Op,Player::getPlayerGamePrivilege(packet->m_playerPrivileges,Player::ePlayerGamePrivilege_Op) ); +#endif } } @@ -1496,6 +1649,46 @@ void PlayerConnection::handlePlayerAbilities(shared_ptr p void PlayerConnection::handleCustomPayload(shared_ptr customPayloadPacket) { +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Identity token response from client + if (CustomPayloadPacket::IDENTITY_TOKEN_RESPONSE.compare(customPayloadPacket->identifier) == 0) + { + PlayerUID xuid = m_offlineXUID; + if (xuid == INVALID_XUID) xuid = m_onlineXUID; + + bool tokenValid = false; + if (customPayloadPacket->length == ServerRuntime::Security::IdentityTokenManager::TOKEN_SIZE && + customPayloadPacket->data.length == ServerRuntime::Security::IdentityTokenManager::TOKEN_SIZE && + customPayloadPacket->data.data != nullptr) + { + tokenValid = ServerRuntime::Security::GetIdentityTokenManager().VerifyToken(xuid, customPayloadPacket->data.data); + } + + if (tokenValid) + { + m_identityVerified = true; + app.DebugPrintf("SECURITY: Identity token verified for player %ls\n", player->getName().c_str()); + INetworkPlayer *npLog = getNetworkPlayer(); + if (npLog != nullptr) + ServerRuntime::ServerLogManager::OnIdentityTokenVerified(npLog->GetSmallId()); + } + else + { + app.DebugPrintf("SECURITY: Identity token MISMATCH for player %ls - will disconnect\n", player->getName().c_str()); + app.DebugPrintf("SECURITY: If this player lost their token, use: revoketoken %ls\n", player->getName().c_str()); + INetworkPlayer *npLog = getNetworkPlayer(); + if (npLog != nullptr) + ServerRuntime::ServerLogManager::OnIdentityTokenMismatch(npLog->GetSmallId(), player->getName()); + // Defer disconnect to avoid re-entrancy issues during packet dispatch + setWasKicked(); + closeOnTick(); + } + return; + } +#endif + +#if 0 + if (CustomPayloadPacket.CUSTOM_BOOK_PACKET.equals(customPayloadPacket.identifier)) if (CustomPayloadPacket::CUSTOM_BOOK_PACKET.compare(customPayloadPacket->identifier) == 0) { ByteArrayInputStream bais(customPayloadPacket->data); @@ -1535,7 +1728,9 @@ void PlayerConnection::handleCustomPayload(shared_ptr custo player->inventory->setItem(player->inventory->selected, sentItem); } } - else if (CustomPayloadPacket::TRADER_SELECTION_PACKET.compare(customPayloadPacket->identifier) == 0) + else +#endif + if (CustomPayloadPacket::TRADER_SELECTION_PACKET.compare(customPayloadPacket->identifier) == 0) { ByteArrayInputStream bais(customPayloadPacket->data); DataInputStream input(&bais); diff --git a/Minecraft.Client/PlayerConnection.h b/Minecraft.Client/PlayerConnection.h index 2abca94f..a7218070 100644 --- a/Minecraft.Client/PlayerConnection.h +++ b/Minecraft.Client/PlayerConnection.h @@ -1,6 +1,7 @@ #include "ConsoleInputSource.h" #include "../Minecraft.World/PacketListener.h" #include "../Minecraft.World/JavaIntHash.h" +#include class MinecraftServer; class Connection; @@ -130,15 +131,30 @@ public: void setShowOnMaps(bool bVal); - void setWasKicked() { m_bWasKicked = true; } - bool getWasKicked() { return m_bWasKicked; } + void setWasKicked() { m_bWasKicked.store(true); } + bool getWasKicked() { return m_bWasKicked.load(); } // 4J Added bool hasClientTickedOnce() { return m_bHasClientTickedOnce; } + // Identity token verification state (accessed from both recv and main threads) + std::atomic m_identityVerified; + std::atomic m_identityChallengeTick; + + // Security gate: buffer packets until cipher handshake completes + bool m_securityGateOpen; + vector> m_securityBuffer; + + bool isIdentityVerified() const { return m_identityVerified; } + int getIdentityChallengeTick() const { return m_identityChallengeTick; } + void setIdentityChallengeTick(int tick) { m_identityChallengeTick = tick; } + void setIdentityVerified(bool v) { m_identityVerified = v; } + bool isSecurityGateOpen() const { return m_securityGateOpen; } + void openSecurityGate(); + private: bool m_bCloseOnTick; vector m_texturesRequested; - bool m_bWasKicked; + std::atomic m_bWasKicked{false}; }; \ No newline at end of file diff --git a/Minecraft.Client/PlayerList.cpp b/Minecraft.Client/PlayerList.cpp index b0ab1ef6..05672b94 100644 --- a/Minecraft.Client/PlayerList.cpp +++ b/Minecraft.Client/PlayerList.cpp @@ -39,7 +39,17 @@ #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) #include "../Minecraft.Server/Access/Access.h" +#include "../Minecraft.Server/Common\StringUtils.h" +#include "../Minecraft.Server/ServerLogger.h" +#include "../Minecraft.Server/ServerLogManager.h" +#include "../Minecraft.Server/ServerProperties.h" +#include "../Minecraft.Server/Security/SecurityConfig.h" +#include "../Minecraft.Server/Security/ConnectionCipher.h" +#include "../Minecraft.Server/Security/CipherHandshakeEnforcer.h" +#include "../Minecraft.Server/Security/IdentityTokenManager.h" extern bool g_Win64DedicatedServer; +static unsigned int s_playerListTickCount = 0; +static const int kIdentityResponseGraceTicks = 200; // 10 seconds at 20 TPS #endif // 4J - this class is fairly substantially altered as there didn't seem any point in porting code for banning, whitelisting, ops etc. @@ -67,6 +77,7 @@ PlayerList::PlayerList(MinecraftServer *server) int rawMax = server->settings->getInt(L"max-players", 8); maxPlayers = static_cast(Mth::clamp(rawMax, 1, MINECRAFT_NET_MAX_PLAYERS)); doWhiteList = false; + InitializeCriticalSection(&m_banCS); InitializeCriticalSection(&m_kickPlayersCS); InitializeCriticalSection(&m_closePlayersCS); } @@ -80,6 +91,7 @@ PlayerList::~PlayerList() player->gameMode = nullptr; } + DeleteCriticalSection(&m_banCS); DeleteCriticalSection(&m_kickPlayersCS); DeleteCriticalSection(&m_closePlayersCS); } @@ -271,12 +283,19 @@ bool PlayerList::placeNewPlayer(Connection *connection, shared_ptr static_cast(playerIndex), level->useNewSeaLevel(), player->getAllPlayerGamePrivileges(), level->getLevelData()->getXZSize(), - level->getLevelData()->getHellScale())); + level->getLevelData()->getHellScale(), + level->getLevelData()->isHardcore())); playerConnection->send(std::make_shared(spawnPos->x, spawnPos->y, spawnPos->z)); playerConnection->send(std::make_shared(&player->abilities)); playerConnection->send(std::make_shared(player->inventory->selected)); delete spawnPos; + // Identify this server as a fork so the client can enable extended + // features (e.g. render-distance-independent player list). Upstream + // clients will silently ignore the unknown channel. + playerConnection->send(std::make_shared( + CustomPayloadPacket::FORK_HELLO_CHANNEL, byteArray())); + updateEntireScoreboard(reinterpret_cast(level->getScoreboard()), player); sendLevelInfo(player, level); @@ -331,6 +350,49 @@ bool PlayerList::placeNewPlayer(Connection *connection, shared_ptr } } } + +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Initiate stream cipher handshake if enabled. + // Send MC|CKey with the generated key. Old clients will ignore the unknown channel. + if (g_Win64DedicatedServer && ServerRuntime::Security::GetSettings().enableStreamCipher) + { + BYTE smallId = 0; + Socket *cipherSock = connection->getSocket(); + INetworkPlayer *cipherNp = cipherSock ? cipherSock->getPlayer() : nullptr; + if (cipherNp != nullptr && !cipherNp->IsLocal()) + { + smallId = cipherNp->GetSmallId(); + uint8_t key[ServerRuntime::Security::StreamCipher::KEY_SIZE]; + if (ServerRuntime::Security::GetCipherRegistry().PrepareKey(smallId, key)) + { + byteArray keyData(ServerRuntime::Security::StreamCipher::KEY_SIZE); + memcpy(keyData.data, key, ServerRuntime::Security::StreamCipher::KEY_SIZE); + playerConnection->send(std::make_shared( + CustomPayloadPacket::CIPHER_KEY_CHANNEL, keyData)); + SecureZeroMemory(key, sizeof(key)); + app.DebugPrintf("Server: Sent MC|CKey to player %ls (smallId=%d)\n", + player->getName().c_str(), smallId); + + // Register with enforcer for timeout tracking + if (ServerRuntime::Security::GetSettings().requireSecureClient) + { + ServerRuntime::Security::GetHandshakeEnforcer().OnCipherKeySent(smallId, s_playerListTickCount); + } + } + + // Close the security gate AFTER sending the essential login sequence + // and MC|CKey. The login setup packets (LoginPacket, spawn position, + // abilities, chunks, teleport) must arrive in plaintext before the + // cipher handshake completes. Only subsequent tick data is buffered + // until the handshake finishes and openSecurityGate() flushes. + if (ServerRuntime::Security::GetSettings().requireSecureClient) + { + playerConnection->m_securityGateOpen = false; + } + } + } +#endif + return true; } @@ -563,6 +625,16 @@ void PlayerList::move(shared_ptr player) void PlayerList::remove(shared_ptr player) { +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + if (g_Win64DedicatedServer && player->connection != nullptr) + { + INetworkPlayer *np = player->connection->getNetworkPlayer(); + if (np != nullptr) + { + ServerRuntime::Security::GetHandshakeEnforcer().OnDisconnected(np->GetSmallId()); + } + } +#endif save(player); //4J Stu - We don't want to save the map data for guests, so when we are sure that the player is gone delete the map if(player->isGuest()) playerIo->deleteMapFilesForPlayer(player); @@ -588,7 +660,17 @@ if (player->riding != nullptr) { players.erase(it); } - //broadcastAll(shared_ptr( new PlayerInfoPacket(player->name, false, 9999) ) ); + // Notify fork clients that this player has left the server so they can + // clean up IQNet/Tab list entries. Uses a custom payload channel so the + // wire format of existing packets is unchanged (upstream clients simply + // ignore the unknown channel). + { + const wstring& name = player->getName(); + byteArray payload(static_cast(name.size() * sizeof(wchar_t))); + memcpy(payload.data, name.c_str(), payload.length); + broadcastAll(std::make_shared( + CustomPayloadPacket::FORK_PLAYER_LEAVE_CHANNEL, payload)); + } removePlayerFromReceiving(player); player->connection = nullptr; // Must remove reference to connection, or else there is a circular dependency @@ -754,6 +836,13 @@ shared_ptr PlayerList::respawn(shared_ptr serverPlay // necessary) updatePlayerGameMode(player, serverPlayer, level); + // 4J Added: Hardcore mode — force Adventure mode on respawn + if (server->getLevel(0)->getLevelData()->isHardcore()) + { + player->gameMode->setGameModeForPlayer(GameType::ADVENTURE); + player->connection->send(std::make_shared(GameEventPacket::CHANGE_GAME_MODE, GameType::ADVENTURE->getId())); + } + if(serverPlayer->wonGame && targetDimension == oldDimension && serverPlayer->getHealth() > 0) { // If the player is still alive and respawning to the same dimension, they are just being added back from someone else viewing the Win screen @@ -791,7 +880,7 @@ shared_ptr PlayerList::respawn(shared_ptr serverPlay player->connection->send( std::make_shared( static_cast(player->dimension), player->level->getSeed(), player->level->getMaxBuildHeight(), player->gameMode->getGameModeForPlayer(), level->difficulty, level->getLevelData()->getGenerator(), - player->level->useNewSeaLevel(), player->entityId, level->getLevelData()->getXZSize(), level->getLevelData()->getHellScale() ) ); + player->level->useNewSeaLevel(), player->entityId, level->getLevelData()->getXZSize(), level->getLevelData()->getHellScale(), level->getLevelData()->isHardcore() ) ); player->connection->teleport(player->x, player->y, player->z, player->yRot, player->xRot); player->connection->send( std::make_shared( player->experienceProgress, player->totalExperience, player->experienceLevel) ); @@ -908,7 +997,7 @@ void PlayerList::toggleDimension(shared_ptr player, int targetDime player->connection->send(std::make_shared(static_cast(player->dimension), newLevel->getSeed(), newLevel->getMaxBuildHeight(), player->gameMode->getGameModeForPlayer(), newLevel->difficulty, newLevel->getLevelData()->getGenerator(), - newLevel->useNewSeaLevel(), player->entityId, newLevel->getLevelData()->getXZSize(), newLevel->getLevelData()->getHellScale())); + newLevel->useNewSeaLevel(), player->entityId, newLevel->getLevelData()->getXZSize(), newLevel->getLevelData()->getHellScale(), newLevel->getLevelData()->isHardcore())); oldLevel->removeEntityImmediately(player); player->removed = false; @@ -1003,15 +1092,16 @@ void PlayerList::repositionAcrossDimension(shared_ptr entity, int lastDi addPlayerToReceiving(player); } - if (lastDimension != 1) + xt = static_cast(Mth::clamp(static_cast(xt), -Level::MAX_LEVEL_SIZE + 128, Level::MAX_LEVEL_SIZE - 128)); + zt = static_cast(Mth::clamp(static_cast(zt), -Level::MAX_LEVEL_SIZE + 128, Level::MAX_LEVEL_SIZE - 128)); + if (entity->isAlive()) { - xt = static_cast(Mth::clamp(static_cast(xt), -Level::MAX_LEVEL_SIZE + 128, Level::MAX_LEVEL_SIZE - 128)); - zt = static_cast(Mth::clamp(static_cast(zt), -Level::MAX_LEVEL_SIZE + 128, Level::MAX_LEVEL_SIZE - 128)); - if (entity->isAlive()) + newLevel->addEntity(entity); + entity->moveTo(xt, entity->y, zt, entity->yRot, entity->xRot); + newLevel->tick(entity, false); + // Portal forcing only for non-End exits (End exits go to spawn, not a portal) + if (lastDimension != 1) { - newLevel->addEntity(entity); - entity->moveTo(xt, entity->y, zt, entity->yRot, entity->xRot); - newLevel->tick(entity, false); newLevel->cache->autoCreate = true; newLevel->getPortalForcer()->force(entity, xOriginal, yOriginal, zOriginal, yRotOriginal); newLevel->cache->autoCreate = false; @@ -1023,6 +1113,131 @@ void PlayerList::repositionAcrossDimension(shared_ptr entity, int lastDi void PlayerList::tick() { +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + ++s_playerListTickCount; + + // Cipher handshake enforcement: kick clients that haven't completed the handshake + if (g_Win64DedicatedServer && + ServerRuntime::Security::GetSettings().enableStreamCipher && + ServerRuntime::Security::GetSettings().requireSecureClient) + { + std::vector expired; + std::vector completed; + ServerRuntime::Security::GetHandshakeEnforcer().CheckTimeouts(s_playerListTickCount, expired, completed); + + for (unsigned char smallId : expired) + { + app.DebugPrintf("SECURITY: Kicking unsecured client (smallId=%d) - cipher handshake timed out\n", smallId); + ServerRuntime::ServerLogManager::OnUnsecuredClientKicked(smallId); + EnterCriticalSection(&m_closePlayersCS); + m_smallIdsToClose.push_back(smallId); + LeaveCriticalSection(&m_closePlayersCS); + } + + // Report cipher completion and open security gate for all completed handshakes + for (unsigned char smallId : completed) + { + // Open the security gate -- flush buffered game packets now that cipher is active + for (auto &p : players) + { + if (p == nullptr || p->connection == nullptr) continue; + INetworkPlayer *np = p->connection->getNetworkPlayer(); + if (np != nullptr && np->GetSmallId() == smallId) + { + if (!p->connection->isSecurityGateOpen()) + { + p->connection->openSecurityGate(); + } + break; + } + } + + if (ServerRuntime::Security::GetSettings().requireChallengeToken) + { + ServerRuntime::ServerLogManager::OnCipherHandshakeCompleted(smallId); + } + else + { + ServerRuntime::ServerLogManager::OnCipherCompletedNoTokenRequired(smallId); + } + } + + // For newly-completed cipher handshakes, initiate identity token exchange + if (ServerRuntime::Security::GetSettings().requireChallengeToken) + { + for (unsigned char smallId : completed) + { + // Find the player by smallId + for (auto &p : players) + { + if (p == nullptr || p->connection == nullptr) continue; + INetworkPlayer *np = p->connection->getNetworkPlayer(); + if (np == nullptr || np->GetSmallId() != smallId) continue; + + PlayerUID xuid = p->connection->m_offlineXUID; + if (xuid == INVALID_XUID) xuid = p->connection->m_onlineXUID; + + if (p->connection->getIdentityChallengeTick() >= 0) + { + // Already challenged, skip + } + else if (ServerRuntime::Security::GetIdentityTokenManager().HasToken(xuid)) + { + // Returning player - challenge them + p->connection->send(std::make_shared( + CustomPayloadPacket::IDENTITY_TOKEN_CHALLENGE, byteArray())); + p->connection->setIdentityChallengeTick(s_playerListTickCount); + app.DebugPrintf("Server: Sent identity challenge to %ls (smallId=%d)\n", + p->getName().c_str(), smallId); + } + else + { + // New player - issue a token over the encrypted channel + uint8_t token[ServerRuntime::Security::IdentityTokenManager::TOKEN_SIZE]; + if (ServerRuntime::Security::GetIdentityTokenManager().IssueToken(xuid, token)) + { + byteArray tokenData(ServerRuntime::Security::IdentityTokenManager::TOKEN_SIZE); + memcpy(tokenData.data, token, ServerRuntime::Security::IdentityTokenManager::TOKEN_SIZE); + p->connection->send(std::make_shared( + CustomPayloadPacket::IDENTITY_TOKEN_ISSUE, tokenData)); + SecureZeroMemory(token, sizeof(token)); + p->connection->setIdentityVerified(true); + app.DebugPrintf("Server: Issued identity token to %ls (smallId=%d)\n", + p->getName().c_str(), smallId); + ServerRuntime::ServerLogManager::OnIdentityTokenIssued(smallId); + } + } + break; + } + } + + // Enforce identity token response timeout + for (auto &p : players) + { + if (p == nullptr || p->connection == nullptr) continue; + int challengeTick = p->connection->getIdentityChallengeTick(); + if (challengeTick >= 0 && !p->connection->isIdentityVerified() && + (s_playerListTickCount - challengeTick) > kIdentityResponseGraceTicks) + { + app.DebugPrintf("SECURITY: Kicking %ls - identity token response timed out\n", + p->getName().c_str()); + INetworkPlayer *npLog = p->connection->getNetworkPlayer(); + if (npLog != nullptr) + ServerRuntime::ServerLogManager::OnIdentityTokenTimeout(npLog->GetSmallId(), p->getName()); + p->connection->setIdentityChallengeTick(-1); // prevent re-queuing + INetworkPlayer *np = p->connection->getNetworkPlayer(); + if (np != nullptr) + { + EnterCriticalSection(&m_closePlayersCS); + m_smallIdsToClose.push_back(np->GetSmallId()); + LeaveCriticalSection(&m_closePlayersCS); + } + } + } + } + } +#endif + // 4J - brought changes to how often this is sent forward from 1.2.3 if (++sendAllPlayerInfoIn > SEND_PLAYER_INFO_INTERVAL) { @@ -1722,6 +1937,7 @@ bool PlayerList::isXuidBanned(PlayerUID xuid) bool banned = false; + EnterCriticalSection(&m_banCS); for(PlayerUID it : m_bannedXuids) { if( ProfileManager.AreXUIDSEqual( xuid, it ) ) @@ -1730,6 +1946,7 @@ bool PlayerList::isXuidBanned(PlayerUID xuid) break; } } + LeaveCriticalSection(&m_banCS); #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) if (!banned && g_Win64DedicatedServer) @@ -1741,8 +1958,118 @@ bool PlayerList::isXuidBanned(PlayerUID xuid) return banned; } +void PlayerList::banXuid(PlayerUID xuid) +{ + // 4J Added - for hardcore mode ban-on-death + // Ban a player's XUID. Used when a player dies in a hardcore multiplayer world. + if(xuid == INVALID_XUID) return; + + EnterCriticalSection(&m_banCS); + + // Check if already banned + bool alreadyBanned = false; + for(PlayerUID it : m_bannedXuids) + { + if( ProfileManager.AreXUIDSEqual( xuid, it ) ) + { + alreadyBanned = true; + break; + } + } + + if(!alreadyBanned) + { + m_bannedXuids.push_back(xuid); + app.DebugPrintf("PlayerList::banXuid - Player XUID banned for hardcore death\n"); + } + + LeaveCriticalSection(&m_banCS); +} + +void PlayerList::banPlayerForHardcoreDeath(ServerPlayer *player) +{ + if (player == nullptr) return; + + // Always apply the in-memory XUID ban (works for both client-hosted and dedicated) + banXuid(player->getOnlineXuid()); + +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + if (g_Win64DedicatedServer) + { + const std::string playerName = ServerRuntime::StringUtils::WideToUtf8(player->getName()); + + ServerRuntime::Access::BanMetadata metadata = ServerRuntime::Access::BanManager::BuildDefaultMetadata("Hardcore Death"); + metadata.reason = "Died in hardcore mode"; + + // Ban online XUID + ServerRuntime::Access::AddPlayerBan(player->getOnlineXuid(), playerName, metadata); + + // Also ban offline XUID if it differs (follows CliCommandBan pattern) + PlayerUID offlineXuid = player->getXuid(); + if (offlineXuid != INVALID_XUID && offlineXuid != player->getOnlineXuid()) + { + ServerRuntime::Access::AddPlayerBan(offlineXuid, playerName, metadata); + } + + // Ban the player's IP address (uses same access path as CliCommandBanIp) + auto serverConfig = ServerRuntime::LoadServerPropertiesConfig(); + if (serverConfig.hardcoreBanIp) + { + if (player->connection != nullptr && player->connection->connection != nullptr && player->connection->connection->getSocket() != nullptr) + { + const unsigned char smallId = player->connection->connection->getSocket()->getSmallId(); + std::string ip; + if (smallId != 0 && ServerRuntime::ServerLogManager::TryGetConnectionRemoteIp(smallId, &ip)) + { + ServerRuntime::Access::AddIpBan(ip, metadata); + ServerRuntime::LogInfof("Hardcore", "Player %s banned (XUID + IP %s) for dying in hardcore mode.", playerName.c_str(), ip.c_str()); + } + else + { + ServerRuntime::LogInfof("Hardcore", "Player %s banned (XUID only, IP not available) for dying in hardcore mode.", playerName.c_str()); + } + } + else + { + ServerRuntime::LogInfof("Hardcore", "Player %s banned (XUID only, no connection) for dying in hardcore mode.", playerName.c_str()); + } + } + else + { + ServerRuntime::LogInfof("Hardcore", "Player %s banned (XUID only, IP ban disabled) for dying in hardcore mode.", playerName.c_str()); + } + + // Send ban reason then defer the actual close to the next tick, because this + // method runs mid-tick inside ServerPlayer::die(). A synchronous disconnect + // can invalidate the player/connection while the tick is still executing. + if (player->connection != nullptr) + { + player->connection->send(std::make_shared(DisconnectPacket::eDisconnect_Banned)); + player->connection->closeOnTick(); + } + + } + else +#endif + { + // Client-hosted: force-save so the host cannot circumvent death by quitting without saving. + // On dedicated server the autosave handles persistence, so skip the forced save to avoid + // the client getting stuck on a "host is saving" screen during disconnect. + app.SetXuiServerAction(ProfileManager.GetPrimaryPad(), eXuiServerAction_SaveGame); + } +} + // AP added for Vita so the range can be increased once the level starts -void PlayerList::setViewDistance(int newViewDistance) +void PlayerList::setViewDistance(const int newViewDistance) { viewDistance = newViewDistance; + + for (size_t i = 0; i < server->levels.length; i++) + { + ServerLevel* level = server->levels[i]; + if (level != nullptr) + { + level->getChunkMap()->setRadius(newViewDistance); + } + } } diff --git a/Minecraft.Client/PlayerList.h b/Minecraft.Client/PlayerList.h index 0ca0582f..2cafe77e 100644 --- a/Minecraft.Client/PlayerList.h +++ b/Minecraft.Client/PlayerList.h @@ -31,6 +31,7 @@ private: // 4J Added vector m_bannedXuids; + CRITICAL_SECTION m_banCS; // 4J Added - protects m_bannedXuids for concurrent access deque m_smallIdsToKick; CRITICAL_SECTION m_kickPlayersCS; deque m_smallIdsToClose; @@ -135,6 +136,8 @@ public: void closePlayerConnectionBySmallId(BYTE networkSmallId); void queueSmallIdForRecycle(BYTE smallId); bool isXuidBanned(PlayerUID xuid); + void banXuid(PlayerUID xuid); // 4J Added - for hardcore mode ban-on-death + void banPlayerForHardcoreDeath(ServerPlayer *player); // Persistent XUID + IP ban on hardcore death // AP added for Vita so the range can be increased once the level starts void setViewDistance(int newViewDistance); }; diff --git a/Minecraft.Client/PlayerRenderer.cpp b/Minecraft.Client/PlayerRenderer.cpp index 9bd8c9de..a0d965e2 100644 --- a/Minecraft.Client/PlayerRenderer.cpp +++ b/Minecraft.Client/PlayerRenderer.cpp @@ -234,11 +234,26 @@ void PlayerRenderer::render(shared_ptr _mob, double x, double y, double armorParts1->sneaking = armorParts2->sneaking = resModel->sneaking = mob->isSneaking(); double yp = y - mob->heightOffset; - if (mob->isSneaking() && !mob->instanceof(eTYPE_LOCALPLAYER)) + if (mob->isSneaking()) { yp -= 2 / 16.0f; } + if (mob->getAnimOverrideBitmask() & (1 << HumanoidModel::eAnim_SmallModel)) + { + if (mob->isRiding()) + { + std::shared_ptr ridingEntity = mob->riding; + if (ridingEntity != nullptr) // Safety check; + { + if (ridingEntity->instanceof(eTYPE_BOAT)) + { + yp += 0.25f; // reverts the change in Boat.cpp for smaller models. + } + } + } + } + // Check if an idle animation is needed if(mob->getAnimOverrideBitmask()&(1<conversionLang + L" " + info; } + // 4J Added: Show [Hardcore] badge for hardcore worlds + if (levelSummary->isHardcore()) + { + name = name + L" [Hardcore]"; + } + parent->drawString(parent->font, name, x + 2, y + 1, 0xffffff); parent->drawString(parent->font, id, x + 2, y + 12, 0x808080); parent->drawString(parent->font, info, x + 2, y + 12 + 10, 0x808080); diff --git a/Minecraft.Client/ServerChunkCache.cpp b/Minecraft.Client/ServerChunkCache.cpp index 03b3b23b..881acabf 100644 --- a/Minecraft.Client/ServerChunkCache.cpp +++ b/Minecraft.Client/ServerChunkCache.cpp @@ -80,54 +80,27 @@ vector *ServerChunkCache::getLoadedChunkList() return &m_loadedChunkList; } -void ServerChunkCache::drop(int x, int z) +void ServerChunkCache::drop(const int x, const int z) { - // 4J - we're not dropping things anymore now that we have a fixed sized cache -#ifdef _LARGE_WORLDS + const int ix = x + XZOFFSET; + const int iz = z + XZOFFSET; + if ((ix < 0) || (ix >= XZSIZE)) return; + if ((iz < 0) || (iz >= XZSIZE)) return; + const int idx = ix * XZSIZE + iz; + LevelChunk* chunk = cache[idx]; - bool canDrop = false; -// if (level->dimension->mayRespawn()) -// { -// Pos *spawnPos = level->getSharedSpawnPos(); -// int xd = x * 16 + 8 - spawnPos->x; -// int zd = z * 16 + 8 - spawnPos->z; -// delete spawnPos; -// int r = 128; -// if (xd < -r || xd > r || zd < -r || zd > r) -// { -// canDrop = true; -//} -// } -// else + if (chunk != nullptr) { - canDrop = true; + m_toDrop.push_back(chunk); } - if(canDrop) - { - int ix = x + XZOFFSET; - int iz = z + XZOFFSET; - // Check we're in range of the stored level - if( ( ix < 0 ) || ( ix >= XZSIZE ) ) return; - if( ( iz < 0 ) || ( iz >= XZSIZE ) ) return; - int idx = ix * XZSIZE + iz; - LevelChunk *chunk = cache[idx]; - - if(chunk) - { - m_toDrop.push_back(chunk); - } - } -#endif } void ServerChunkCache::dropAll() { -#ifdef _LARGE_WORLDS for (LevelChunk *chunk : m_loadedChunkList) { drop(chunk->x, chunk->z); -} -#endif + } } // 4J - this is the original (and virtual) interface to create @@ -954,9 +927,14 @@ bool ServerChunkCache::tick() int ix = chunk->x + XZOFFSET; int iz = chunk->z + XZOFFSET; int idx = ix * XZSIZE + iz; + delete m_unloadedCache[idx]; m_unloadedCache[idx] = chunk; cache[idx] = nullptr; } + else + { + continue; + } } m_toDrop.pop_front(); } diff --git a/Minecraft.Client/ServerConnection.cpp b/Minecraft.Client/ServerConnection.cpp index 5198dba6..d2738bad 100644 --- a/Minecraft.Client/ServerConnection.cpp +++ b/Minecraft.Client/ServerConnection.cpp @@ -10,6 +10,10 @@ #include "../Minecraft.World/Socket.h" #include "../Minecraft.World/net.minecraft.world.level.h" #include "MultiPlayerLevel.h" +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) +#include "..\Minecraft.Server\Security\SecurityConfig.h" +#include "..\Minecraft.Server\ServerLogManager.h" +#endif ServerConnection::ServerConnection(MinecraftServer *server) { @@ -40,6 +44,17 @@ void ServerConnection::addPlayerConnection(shared_ptr uc) void ServerConnection::handleConnection(shared_ptr uc) { EnterCriticalSection(&pending_cs); +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + int maxPending = ServerRuntime::Security::GetSettings().maxPendingConnections; + if (maxPending > 0 && static_cast(pending.size()) >= maxPending) + { + LeaveCriticalSection(&pending_cs); + app.DebugPrintf("SECURITY: Rejecting connection, too many pending (%d/%d)\n", + static_cast(pending.size()), maxPending); + uc->disconnect(DisconnectPacket::eDisconnect_ServerFull); + return; + } +#endif pending.push_back(uc); LeaveCriticalSection(&pending_cs); } diff --git a/Minecraft.Client/ServerLevel.cpp b/Minecraft.Client/ServerLevel.cpp index 3d2eb586..e9092ee9 100644 --- a/Minecraft.Client/ServerLevel.cpp +++ b/Minecraft.Client/ServerLevel.cpp @@ -1052,9 +1052,14 @@ void ServerLevel::entityAdded(shared_ptr e) vector > *es = e->getSubEntities(); if (es) { + // Reassign sub-entity IDs to be sequential from the parent's ID. + // The client assumes this layout when it applies an offset in handleAddMob. + int offset = 1; for(auto& i : *es) { + i->entityId = e->entityId + offset; entitiesById.emplace(i->entityId, i); + offset++; } } entityAddedExtra(e); // 4J added diff --git a/Minecraft.Client/ServerPlayer.cpp b/Minecraft.Client/ServerPlayer.cpp index ebf76ec0..5a3f3797 100644 --- a/Minecraft.Client/ServerPlayer.cpp +++ b/Minecraft.Client/ServerPlayer.cpp @@ -569,6 +569,17 @@ void ServerPlayer::die(DamageSource *source) { server->getPlayers()->broadcastAll(getCombatTracker()->getDeathMessagePacket()); + // 4J Added: Hardcore mode — switch to Adventure mode on death (can look but not break/place blocks) + if (level->getLevelData()->isHardcore()) + { + setGameMode(GameType::ADVENTURE); + + // Ban this player's XUID and queue disconnect. + // The force-save is triggered inside banPlayerForHardcoreDeath after the + // disconnect is queued, so the client doesn't get stuck on a save screen. + server->getPlayers()->banPlayerForHardcoreDeath(this); + } + if (!level->getGameRules()->getBoolean(GameRules::RULE_KEEPINVENTORY)) { inventory->dropAll(); @@ -1610,9 +1621,9 @@ bool ServerPlayer::hasPermission(EGameCommand command) // // // 4J - Don't need // //if (server.isSingleplayer() && server.getSingleplayerName().equals(name)) -// //{ +/// //{ // // server.setDifficulty(packet.getDifficulty()); -// //} +/// //} //} int ServerPlayer::getViewDistance() diff --git a/Minecraft.Client/ServerPlayerGameMode.cpp b/Minecraft.Client/ServerPlayerGameMode.cpp index ffba1501..2416dd82 100644 --- a/Minecraft.Client/ServerPlayerGameMode.cpp +++ b/Minecraft.Client/ServerPlayerGameMode.cpp @@ -171,7 +171,7 @@ void ServerPlayerGameMode::startDestroyBlock(int x, int y, int z, int face) } } -void ServerPlayerGameMode::stopDestroyBlock(int x, int y, int z) +bool ServerPlayerGameMode::stopDestroyBlock(int x, int y, int z) { if (x == xDestroyBlock && y == yDestroyBlock && z == zDestroyBlock) { @@ -187,6 +187,7 @@ void ServerPlayerGameMode::stopDestroyBlock(int x, int y, int z) isDestroyingBlock = false; level->destroyTileProgress(player->entityId, x, y, z, -1); destroyBlock(x, y, z); + return true; } else if (!hasDelayedDestroy) { @@ -197,9 +198,11 @@ void ServerPlayerGameMode::stopDestroyBlock(int x, int y, int z) delayedDestroyY = y; delayedDestroyZ = z; delayedTickStart = destroyProgressStart; + return true; } } } + return false; } void ServerPlayerGameMode::abortDestroyBlock(int x, int y, int z) diff --git a/Minecraft.Client/ServerPlayerGameMode.h b/Minecraft.Client/ServerPlayerGameMode.h index 509e1676..6b18debc 100644 --- a/Minecraft.Client/ServerPlayerGameMode.h +++ b/Minecraft.Client/ServerPlayerGameMode.h @@ -45,7 +45,7 @@ public: void tick(); void startDestroyBlock(int x, int y, int z, int face); - void stopDestroyBlock(int x, int y, int z); + bool stopDestroyBlock(int x, int y, int z); void abortDestroyBlock(int x, int y, int z); private: diff --git a/Minecraft.Client/TrackedEntity.cpp b/Minecraft.Client/TrackedEntity.cpp index 38d62eb9..161c5b0c 100644 --- a/Minecraft.Client/TrackedEntity.cpp +++ b/Minecraft.Client/TrackedEntity.cpp @@ -23,6 +23,10 @@ #include +#ifdef _WINDOWS64 +extern bool g_Win64DedicatedServer; +#endif + TrackedEntity::TrackedEntity(shared_ptr e, int range, int updateInterval, bool trackDelta) { // 4J added initialisers @@ -351,6 +355,19 @@ void TrackedEntity::sendDirtyEntityData() void TrackedEntity::broadcast(shared_ptr packet) { +#ifdef _WINDOWS64 + // On dedicated servers, IsSameSystem() always returns false for remote players + // (no split-screen), so the dedup loop never filters anything. Skip straight + // to sending to all players in seenBy to avoid O(seenBy^2) overhead. + if (g_Win64DedicatedServer) + { + for (auto& player : seenBy) + { + player->connection->send(packet); + } + return; + } +#endif if( Packet::canSendToAnyClient( packet ) ) { // 4J-PB - due to the knockback on a player being hit, we need to send to all players, but limit the network traffic here to players that have not already had it sent to their system @@ -462,7 +479,13 @@ TrackedEntity::eVisibility TrackedEntity::isVisible(EntityTracker *tracker, shar // 4J - added. Try and find other players who are in the same dimension as this one and on the same machine, and extend our visibility // so things are consider visible to this player if they are near the other one. This is because we only send entity tracking info to // players who canReceiveAllPackets(). - if(!bVisible) + // NOTE: On dedicated servers, all real players are remote so IsSameSystem() + // always returns false. Skip this O(players) loop entirely. +#ifdef _WINDOWS64 + if (!bVisible && !g_Win64DedicatedServer) +#else + if (!bVisible) +#endif { MinecraftServer *server = MinecraftServer::getInstance(); INetworkPlayer *thisPlayer = sp->connection->getNetworkPlayer(); diff --git a/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp b/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp index bbf993cc..e2971436 100644 --- a/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp +++ b/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp @@ -8,12 +8,21 @@ #include "WinsockNetLayer.h" #include "../../Common/Network/PlatformNetworkManagerStub.h" #include "../../../Minecraft.World/Socket.h" + #if defined(MINECRAFT_SERVER_BUILD) #include "../../../Minecraft.Server/Access/Access.h" #include "../../../Minecraft.Server/ServerLogManager.h" +#include "../../../Minecraft.Server/ServerLogger.h" +#include "../../../Minecraft.Server/Security/SecurityConfig.h" +#include "../../../Minecraft.Server/Security/RateLimiter.h" +#include "../../../Minecraft.Server/Security/ConnectionCipher.h" #endif + #include "../../../Minecraft.World/DisconnectPacket.h" #include "../../Minecraft.h" + +#include +#pragma comment(lib, "Dnsapi.lib") #include "../4JLibs/inc/4J_Profile.h" #include @@ -23,6 +32,28 @@ static bool RecvExact(SOCKET sock, BYTE* buf, int len); static bool TryGetNumericRemoteIp(const sockaddr_in &remoteAddress, std::string *outIp); #endif +// Raw serialized byte patterns for cipher handshake packets (CustomPayloadPacket ID 250). +// Used by recv threads to detect handshake messages at the byte level before packet parsing, +// enabling atomic cipher activation at the exact byte boundary. + +// MC|CAck: 7-char channel, empty payload. Client sends this; server recv thread matches it. +static const BYTE kCipherAckPattern[] = { + 0xFA, // packet ID 250 + 0x00, 0x07, // channel length = 7 + 0x00, 0x4D, 0x00, 0x43, 0x00, 0x7C, 0x00, 0x43, 0x00, 0x41, 0x00, 0x63, 0x00, 0x6B, // "MC|CAck" UTF-16BE + 0x00, 0x00 // data length = 0 +}; +static const int kCipherAckPatternSize = sizeof(kCipherAckPattern); // 19 + +// MC|COn: 6-char channel, empty payload. Client recv thread matches this from server. +static const BYTE kCipherOnPattern[] = { + 0xFA, // packet ID 250 + 0x00, 0x06, // channel length = 6 + 0x00, 0x4D, 0x00, 0x43, 0x00, 0x7C, 0x00, 0x43, 0x00, 0x4F, 0x00, 0x6E, // "MC|COn" UTF-16BE + 0x00, 0x00 // data length = 0 +}; +static const int kCipherOnPatternSize = sizeof(kCipherOnPattern); // 17 + SOCKET WinsockNetLayer::s_listenSocket = INVALID_SOCKET; SOCKET WinsockNetLayer::s_hostConnectionSocket = INVALID_SOCKET; HANDLE WinsockNetLayer::s_acceptThread = nullptr; @@ -67,7 +98,6 @@ SOCKET WinsockNetLayer::s_splitScreenSocket[XUSER_MAX_COUNT] = { INVALID_SOCKET, BYTE WinsockNetLayer::s_splitScreenSmallId[XUSER_MAX_COUNT] = { 0xFF, 0xFF, 0xFF, 0xFF }; HANDLE WinsockNetLayer::s_splitScreenRecvThread[XUSER_MAX_COUNT] = {nullptr, nullptr, nullptr, nullptr}; -// async stuff HANDLE WinsockNetLayer::s_joinThread = nullptr; volatile WinsockNetLayer::eJoinState WinsockNetLayer::s_joinState = WinsockNetLayer::eJoinState_Idle; volatile int WinsockNetLayer::s_joinAttempt = 0; @@ -77,6 +107,12 @@ int WinsockNetLayer::s_joinPort = 0; BYTE WinsockNetLayer::s_joinAssignedSmallId = 0; DisconnectPacket::eDisconnectReason WinsockNetLayer::s_joinRejectReason = DisconnectPacket::eDisconnect_Quitting; +ServerRuntime::Security::StreamCipher WinsockNetLayer::s_clientSendCipher; +ServerRuntime::Security::StreamCipher WinsockNetLayer::s_clientRecvCipher; +CRITICAL_SECTION WinsockNetLayer::s_clientCipherLock; +uint8_t WinsockNetLayer::s_clientPendingKey[ServerRuntime::Security::StreamCipher::KEY_SIZE] = {}; +bool WinsockNetLayer::s_clientKeyStored = false; + bool g_Win64MultiplayerHost = false; bool g_Win64MultiplayerJoin = false; int g_Win64MultiplayerPort = WIN64_NET_DEFAULT_PORT; @@ -105,6 +141,7 @@ bool WinsockNetLayer::Initialize() InitializeCriticalSection(&s_disconnectLock); InitializeCriticalSection(&s_freeSmallIdLock); InitializeCriticalSection(&s_smallIdToSocketLock); + InitializeCriticalSection(&s_clientCipherLock); for (int i = 0; i < 256; i++) s_smallIdToSocket[i] = INVALID_SOCKET; @@ -218,6 +255,8 @@ void WinsockNetLayer::Shutdown() s_freeSmallIds.clear(); LeaveCriticalSection(&s_freeSmallIdLock); + ResetClientCipher(); + DeleteCriticalSection(&s_clientCipherLock); DeleteCriticalSection(&s_sendLock); DeleteCriticalSection(&s_connectionsLock); DeleteCriticalSection(&s_advertiseLock); @@ -230,6 +269,163 @@ void WinsockNetLayer::Shutdown() } } +void WinsockNetLayer::StoreClientCipherKey(const uint8_t key[ServerRuntime::Security::StreamCipher::KEY_SIZE]) +{ + EnterCriticalSection(&s_clientCipherLock); + memcpy(s_clientPendingKey, key, ServerRuntime::Security::StreamCipher::KEY_SIZE); + s_clientKeyStored = true; + LeaveCriticalSection(&s_clientCipherLock); +} + +bool WinsockNetLayer::SendAckAndActivateClientSendCipher() +{ + if (s_hostConnectionSocket == INVALID_SOCKET) + return false; + + // Atomic: send the MC|CAck plaintext then activate the send cipher, all under s_sendLock. + // No other send can interleave between the ack and cipher activation. + EnterCriticalSection(&s_sendLock); + + // Write framed packet: 4-byte length header + ack pattern + BYTE header[4]; + header[0] = static_cast((kCipherAckPatternSize >> 24) & 0xFF); + header[1] = static_cast((kCipherAckPatternSize >> 16) & 0xFF); + header[2] = static_cast((kCipherAckPatternSize >> 8) & 0xFF); + header[3] = static_cast(kCipherAckPatternSize & 0xFF); + + bool ok = true; + int totalSent = 0; + while (ok && totalSent < 4) + { + int sent = send(s_hostConnectionSocket, (const char *)header + totalSent, 4 - totalSent, 0); + if (sent == SOCKET_ERROR || sent == 0) { ok = false; break; } + totalSent += sent; + } + totalSent = 0; + while (ok && totalSent < kCipherAckPatternSize) + { + int sent = send(s_hostConnectionSocket, (const char *)kCipherAckPattern + totalSent, kCipherAckPatternSize - totalSent, 0); + if (sent == SOCKET_ERROR || sent == 0) { ok = false; break; } + totalSent += sent; + } + + if (ok) + { + // Activate send cipher immediately after the ack is on the wire + EnterCriticalSection(&s_clientCipherLock); + s_clientSendCipher.Initialize(s_clientPendingKey, ServerRuntime::Security::StreamCipher::Client); + LeaveCriticalSection(&s_clientCipherLock); + app.DebugPrintf("Client: Send cipher activated (MC|CAck sent)\n"); + } + else + { + // Partial send corrupts the stream - force disconnect to prevent desync + app.DebugPrintf("Client: MC|CAck send failed, closing connection\n"); + closesocket(s_hostConnectionSocket); + s_hostConnectionSocket = INVALID_SOCKET; + } + + LeaveCriticalSection(&s_sendLock); + return ok; +} + +void WinsockNetLayer::ActivateClientRecvCipher() +{ + EnterCriticalSection(&s_clientCipherLock); + s_clientRecvCipher.Initialize(s_clientPendingKey, ServerRuntime::Security::StreamCipher::Client); + SecureZeroMemory(s_clientPendingKey, sizeof(s_clientPendingKey)); + s_clientKeyStored = false; + LeaveCriticalSection(&s_clientCipherLock); +} + +void WinsockNetLayer::ResetClientCipher() +{ + EnterCriticalSection(&s_clientCipherLock); + s_clientSendCipher.Reset(); + s_clientRecvCipher.Reset(); + SecureZeroMemory(s_clientPendingKey, sizeof(s_clientPendingKey)); + s_clientKeyStored = false; + LeaveCriticalSection(&s_clientCipherLock); +} + +bool WinsockNetLayer::TryEncryptClientOutgoing(uint8_t *data, int length) +{ + if (data == nullptr || length <= 0) + return false; + + EnterCriticalSection(&s_clientCipherLock); + bool active = s_clientSendCipher.IsActive(); + if (active) + { + s_clientSendCipher.Encrypt(data, length); + } + LeaveCriticalSection(&s_clientCipherLock); + return active; +} + +#if defined(MINECRAFT_SERVER_BUILD) +bool WinsockNetLayer::SendCOnAndCommitServerCipher(BYTE smallId) +{ + // Verify a pending key exists before sending MC|COn (prevents rogue ack from triggering spurious activation) + auto ®istry = ServerRuntime::Security::GetCipherRegistry(); + + SOCKET sock = GetSocketForSmallId(smallId); + if (sock == INVALID_SOCKET) + return false; + + // Verify a pending key exists before sending (rejects rogue acks) + if (!registry.HasPendingKey(smallId)) + { + app.DebugPrintf("Server: Ignoring MC|CAck for smallId=%d (no pending key)\n", smallId); + return false; + } + + // Atomic: send MC|COn plaintext then commit the cipher, all under s_sendLock. + // No other send to this smallId can happen between MC|COn and CommitCipher. + EnterCriticalSection(&s_sendLock); + + BYTE header[4]; + header[0] = static_cast((kCipherOnPatternSize >> 24) & 0xFF); + header[1] = static_cast((kCipherOnPatternSize >> 16) & 0xFF); + header[2] = static_cast((kCipherOnPatternSize >> 8) & 0xFF); + header[3] = static_cast(kCipherOnPatternSize & 0xFF); + + bool ok = true; + int totalSent = 0; + while (ok && totalSent < 4) + { + int sent = send(sock, (const char *)header + totalSent, 4 - totalSent, 0); + if (sent == SOCKET_ERROR || sent == 0) { ok = false; break; } + totalSent += sent; + } + totalSent = 0; + while (ok && totalSent < kCipherOnPatternSize) + { + int sent = send(sock, (const char *)kCipherOnPattern + totalSent, kCipherOnPatternSize - totalSent, 0); + if (sent == SOCKET_ERROR || sent == 0) { ok = false; break; } + totalSent += sent; + } + + if (ok) + { + // Commit AFTER the send - MC|COn is the last plaintext packet + registry.CommitCipher(smallId); + app.DebugPrintf("Server: Cipher committed for smallId=%d (MC|COn sent)\n", smallId); + } + else + { + // Partial send corrupts the stream - force close + app.DebugPrintf("Server: MC|COn send failed for smallId=%d, closing socket\n", smallId); + registry.CancelPending(smallId); + closesocket(sock); + ClearSocketForSmallId(smallId); + } + + LeaveCriticalSection(&s_sendLock); + return ok; +} +#endif + bool WinsockNetLayer::HostGame(int port, const char* bindIp) { if (!s_initialized && !Initialize()) return false; @@ -311,6 +507,44 @@ bool WinsockNetLayer::HostGame(int port, const char* bindIp) return true; } +// Resolve a Minecraft SRV record (_minecraft._tcp.) to get the actual host and port. +// Returns true if an SRV record was found and outHost/outPort were updated. +// Returns false (no changes) for numeric IPs, missing records, or DNS errors. +static bool ResolveSRV(const char* hostname, char* outHost, size_t outHostSize, int* outPort) +{ + // Skip numeric IPs (starts with digit, contains no letters) + if (hostname[0] >= '0' && hostname[0] <= '9') + { + bool hasLetter = false; + for (const char* p = hostname; *p; ++p) + if ((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z')) { hasLetter = true; break; } + if (!hasLetter) return false; + } + + char srvName[300]; + sprintf_s(srvName, "_minecraft._tcp.%s", hostname); + + DNS_RECORD* records = nullptr; + DNS_STATUS status = DnsQuery_A(srvName, DNS_TYPE_SRV, DNS_QUERY_STANDARD, nullptr, &records, nullptr); + if (status != 0 || records == nullptr) + return false; + + // Find the first SRV record + for (DNS_RECORD* r = records; r != nullptr; r = r->pNext) + { + if (r->wType == DNS_TYPE_SRV) + { + strncpy_s(outHost, outHostSize, r->Data.SRV.pNameTarget, _TRUNCATE); + *outPort = r->Data.SRV.wPort; + DnsRecordListFree(records, DnsFreeRecordList); + return true; + } + } + + DnsRecordListFree(records, DnsFreeRecordList); + return false; +} + bool WinsockNetLayer::JoinGame(const char* ip, int port) { if (!s_initialized && !Initialize()) return false; @@ -337,6 +571,13 @@ bool WinsockNetLayer::JoinGame(const char* ip, int port) s_clientRecvThread = nullptr; } + // Try SRV record resolution for hostnames + char resolvedHost[256]; + int resolvedPort = port; + strncpy_s(resolvedHost, sizeof(resolvedHost), ip, _TRUNCATE); + if (ResolveSRV(ip, resolvedHost, sizeof(resolvedHost), &resolvedPort)) + app.DebugPrintf("SRV resolved %s -> %s:%d\n", ip, resolvedHost, resolvedPort); + struct addrinfo hints = {}; struct addrinfo* result = nullptr; @@ -345,9 +586,9 @@ bool WinsockNetLayer::JoinGame(const char* ip, int port) hints.ai_protocol = IPPROTO_TCP; char portStr[16]; - sprintf_s(portStr, "%d", port); + sprintf_s(portStr, "%d", resolvedPort); - int iResult = getaddrinfo(ip, portStr, &hints, &result); + int iResult = getaddrinfo(resolvedHost, portStr, &hints, &result); if (iResult != 0) { app.DebugPrintf("getaddrinfo failed for %s:%d - %d\n", ip, port, iResult); @@ -356,10 +597,17 @@ bool WinsockNetLayer::JoinGame(const char* ip, int port) bool connected = false; BYTE assignedSmallId = 0; - const int maxAttempts = 12; + const int maxAttempts = 3; + const int connectTimeoutSec = 5; for (int attempt = 0; attempt < maxAttempts; ++attempt) { + if (s_joinCancel) + { + app.DebugPrintf("JoinGame cancelled by user\n"); + break; + } + s_hostConnectionSocket = socket(result->ai_family, result->ai_socktype, result->ai_protocol); if (s_hostConnectionSocket == INVALID_SOCKET) { @@ -370,17 +618,55 @@ bool WinsockNetLayer::JoinGame(const char* ip, int port) int noDelay = 1; setsockopt(s_hostConnectionSocket, IPPROTO_TCP, TCP_NODELAY, (const char*)&noDelay, sizeof(noDelay)); + // Use non-blocking connect with select() timeout so we don't freeze + // the game for the full OS TCP timeout when the server is unreachable. + u_long nonBlocking = 1; + ioctlsocket(s_hostConnectionSocket, FIONBIO, &nonBlocking); + iResult = connect(s_hostConnectionSocket, result->ai_addr, static_cast(result->ai_addrlen)); if (iResult == SOCKET_ERROR) { int err = WSAGetLastError(); - app.DebugPrintf("connect() to %s:%d failed (attempt %d/%d): %d\n", ip, port, attempt + 1, maxAttempts, err); - closesocket(s_hostConnectionSocket); - s_hostConnectionSocket = INVALID_SOCKET; - Sleep(200); - continue; + if (err == WSAEWOULDBLOCK) + { + fd_set writeSet, errorSet; + FD_ZERO(&writeSet); + FD_SET(s_hostConnectionSocket, &writeSet); + FD_ZERO(&errorSet); + FD_SET(s_hostConnectionSocket, &errorSet); + + struct timeval tv; + tv.tv_sec = connectTimeoutSec; + tv.tv_usec = 0; + + int selectResult = select(0, nullptr, &writeSet, &errorSet, &tv); + if (selectResult <= 0 || FD_ISSET(s_hostConnectionSocket, &errorSet)) + { + app.DebugPrintf("connect() to %s:%d timed out or failed (attempt %d/%d)\n", ip, port, attempt + 1, maxAttempts); + closesocket(s_hostConnectionSocket); + s_hostConnectionSocket = INVALID_SOCKET; + continue; + } + // Connection succeeded via non-blocking path + } + else + { + app.DebugPrintf("connect() to %s:%d failed (attempt %d/%d): %d\n", ip, port, attempt + 1, maxAttempts, err); + closesocket(s_hostConnectionSocket); + s_hostConnectionSocket = INVALID_SOCKET; + Sleep(200); + continue; + } } + // Restore blocking mode for normal socket I/O + u_long blocking = 0; + ioctlsocket(s_hostConnectionSocket, FIONBIO, &blocking); + + // Set a recv timeout so we don't block forever waiting for the small ID + DWORD recvTimeout = connectTimeoutSec * 1000; + setsockopt(s_hostConnectionSocket, SOL_SOCKET, SO_RCVTIMEO, (const char*)&recvTimeout, sizeof(recvTimeout)); + BYTE assignBuf[1]; int bytesRecv = recv(s_hostConnectionSocket, (char*)assignBuf, 1, 0); if (bytesRecv != 1) @@ -388,7 +674,6 @@ bool WinsockNetLayer::JoinGame(const char* ip, int port) app.DebugPrintf("Failed to receive small ID assignment from host (attempt %d/%d)\n", attempt + 1, maxAttempts); closesocket(s_hostConnectionSocket); s_hostConnectionSocket = INVALID_SOCKET; - Sleep(200); continue; } @@ -423,6 +708,12 @@ bool WinsockNetLayer::JoinGame(const char* ip, int port) { return false; } + + // Clear the recv timeout now that the handshake is complete. + // The recv thread should block indefinitely waiting for data. + DWORD noTimeout = 0; + setsockopt(s_hostConnectionSocket, SOL_SOCKET, SO_RCVTIMEO, (const char*)&noTimeout, sizeof(noTimeout)); + s_localSmallId = assignedSmallId; // Save the host IP and port so JoinSplitScreen can connect to the same host @@ -444,7 +735,7 @@ bool WinsockNetLayer::BeginJoinGame(const char* ip, int port) { if (!s_initialized && !Initialize()) return false; - // if there isnt any cleanup it sometime caused issues. Oops + // Clean up any prior join attempt CancelJoinGame(); if (s_joinThread != nullptr) { @@ -490,20 +781,24 @@ bool WinsockNetLayer::BeginJoinGame(const char* ip, int port) DWORD WINAPI WinsockNetLayer::JoinThreadProc(LPVOID param) { - struct addrinfo hints = {}; - struct addrinfo* result = nullptr; + // Try SRV record resolution for hostnames + char resolvedHost[256]; + int resolvedPort = s_joinPort; + strncpy_s(resolvedHost, sizeof(resolvedHost), s_joinIP, _TRUNCATE); + if (ResolveSRV(s_joinIP, resolvedHost, sizeof(resolvedHost), &resolvedPort)) + app.DebugPrintf("SRV resolved %s -> %s:%d\n", s_joinIP, resolvedHost, resolvedPort); + struct addrinfo hints = {}, *result = nullptr; hints.ai_family = AF_INET; hints.ai_socktype = SOCK_STREAM; hints.ai_protocol = IPPROTO_TCP; char portStr[16]; - sprintf_s(portStr, "%d", s_joinPort); + sprintf_s(portStr, "%d", resolvedPort); - int iResult = getaddrinfo(s_joinIP, portStr, &hints, &result); - if (iResult != 0) + if (getaddrinfo(resolvedHost, portStr, &hints, &result) != 0) { - app.DebugPrintf("getaddrinfo failed for %s:%d - %d\n", s_joinIP, s_joinPort, iResult); + app.DebugPrintf("getaddrinfo failed for %s:%d\n", resolvedHost, resolvedPort); s_joinState = eJoinState_Failed; return 0; } @@ -511,49 +806,65 @@ DWORD WINAPI WinsockNetLayer::JoinThreadProc(LPVOID param) bool connected = false; BYTE assignedSmallId = 0; SOCKET sock = INVALID_SOCKET; + const int connectTimeoutSec = 5; for (int attempt = 0; attempt < JOIN_MAX_ATTEMPTS; ++attempt) { - if (s_joinCancel) - { - freeaddrinfo(result); - s_joinState = eJoinState_Cancelled; - return 0; - } + if (s_joinCancel) { freeaddrinfo(result); s_joinState = eJoinState_Cancelled; return 0; } s_joinAttempt = attempt + 1; sock = socket(result->ai_family, result->ai_socktype, result->ai_protocol); - if (sock == INVALID_SOCKET) - { - app.DebugPrintf("socket() failed: %d\n", WSAGetLastError()); - break; - } + if (sock == INVALID_SOCKET) break; int noDelay = 1; setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (const char*)&noDelay, sizeof(noDelay)); - iResult = connect(sock, result->ai_addr, static_cast(result->ai_addrlen)); + // Non-blocking connect with select() timeout + u_long nonBlocking = 1; + ioctlsocket(sock, FIONBIO, &nonBlocking); + + int iResult = connect(sock, result->ai_addr, static_cast(result->ai_addrlen)); if (iResult == SOCKET_ERROR) { int err = WSAGetLastError(); - app.DebugPrintf("connect() to %s:%d failed (attempt %d/%d): %d\n", s_joinIP, s_joinPort, attempt + 1, JOIN_MAX_ATTEMPTS, err); - closesocket(sock); - sock = INVALID_SOCKET; - for (int w = 0; w < 4 && !s_joinCancel; w++) - Sleep(50); - continue; + if (err == WSAEWOULDBLOCK) + { + fd_set writeSet, errorSet; + FD_ZERO(&writeSet); FD_SET(sock, &writeSet); + FD_ZERO(&errorSet); FD_SET(sock, &errorSet); + struct timeval tv = { connectTimeoutSec, 0 }; + + int selectResult = select(0, nullptr, &writeSet, &errorSet, &tv); + if (selectResult <= 0 || FD_ISSET(sock, &errorSet)) + { + app.DebugPrintf("connect() to %s:%d timed out (attempt %d/%d)\n", s_joinIP, s_joinPort, attempt + 1, JOIN_MAX_ATTEMPTS); + closesocket(sock); sock = INVALID_SOCKET; + continue; + } + } + else + { + app.DebugPrintf("connect() to %s:%d failed (attempt %d/%d): %d\n", s_joinIP, s_joinPort, attempt + 1, JOIN_MAX_ATTEMPTS, err); + closesocket(sock); sock = INVALID_SOCKET; + for (int w = 0; w < 4 && !s_joinCancel; w++) Sleep(50); + continue; + } } + // Restore blocking mode + u_long blocking = 0; + ioctlsocket(sock, FIONBIO, &blocking); + + // Temporary recv timeout for the handshake only + DWORD recvTimeout = connectTimeoutSec * 1000; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)&recvTimeout, sizeof(recvTimeout)); + BYTE assignBuf[1]; - int bytesRecv = recv(sock, (char*)assignBuf, 1, 0); - if (bytesRecv != 1) + if (recv(sock, (char*)assignBuf, 1, 0) != 1) { - app.DebugPrintf("failed to receive small id assignment from host (attempt %d/%d)\n", attempt + 1, JOIN_MAX_ATTEMPTS); - closesocket(sock); - sock = INVALID_SOCKET; - for (int w = 0; w < 4 && !s_joinCancel; w++) - Sleep(50); + app.DebugPrintf("Failed to receive small ID assignment from host (attempt %d/%d)\n", attempt + 1, JOIN_MAX_ATTEMPTS); + closesocket(sock); sock = INVALID_SOCKET; continue; } @@ -562,11 +873,8 @@ DWORD WINAPI WinsockNetLayer::JoinThreadProc(LPVOID param) BYTE rejectBuf[5]; if (!RecvExact(sock, rejectBuf, 5)) { - app.DebugPrintf("failed to receive reject reason from host (?)\n"); - closesocket(sock); - sock = INVALID_SOCKET; - for (int w = 0; w < 4 && !s_joinCancel; w++) - Sleep(50); + app.DebugPrintf("Failed to receive reject reason from host\n"); + closesocket(sock); sock = INVALID_SOCKET; continue; } int reason = ((rejectBuf[1] & 0xff) << 24) | ((rejectBuf[2] & 0xff) << 16) | @@ -597,6 +905,10 @@ DWORD WINAPI WinsockNetLayer::JoinThreadProc(LPVOID param) return 0; } + // Clear recv timeout before handing socket to recv thread + DWORD noTimeout = 0; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)&noTimeout, sizeof(noTimeout)); + s_hostConnectionSocket = sock; s_joinAssignedSmallId = assignedSmallId; s_joinState = eJoinState_Success; @@ -605,18 +917,18 @@ DWORD WINAPI WinsockNetLayer::JoinThreadProc(LPVOID param) void WinsockNetLayer::CancelJoinGame() { - if (s_joinState == eJoinState_Connecting) + s_joinCancel = true; + + // Close socket to immediately unblock any in-progress connect/select/recv + SOCKET sock = s_hostConnectionSocket; + if (sock != INVALID_SOCKET) { - s_joinCancel = true; + s_hostConnectionSocket = INVALID_SOCKET; + closesocket(sock); } - else if (s_joinState == eJoinState_Success) + + if (s_joinState == eJoinState_Success || s_joinState == eJoinState_Connecting) { - // fix a race cond - if (s_hostConnectionSocket != INVALID_SOCKET) - { - closesocket(s_hostConnectionSocket); - s_hostConnectionSocket = INVALID_SOCKET; - } s_joinState = eJoinState_Cancelled; } } @@ -631,7 +943,8 @@ bool WinsockNetLayer::FinalizeJoin() strncpy_s(g_Win64MultiplayerIP, sizeof(g_Win64MultiplayerIP), s_joinIP, _TRUNCATE); g_Win64MultiplayerPort = s_joinPort; - app.DebugPrintf("connected to %s:%d, assigned smallId=%d\n", s_joinIP, s_joinPort, s_localSmallId); + app.DebugPrintf("Win64 LAN: Connected to %s:%d, assigned smallId=%d\n", + s_joinIP, s_joinPort, s_localSmallId); s_active = true; s_connected = true; @@ -649,6 +962,11 @@ bool WinsockNetLayer::FinalizeJoin() return true; } +WinsockNetLayer::eJoinState WinsockNetLayer::GetJoinState() { return s_joinState; } +int WinsockNetLayer::GetJoinAttempt() { return s_joinAttempt; } +int WinsockNetLayer::GetJoinMaxAttempts() { return JOIN_MAX_ATTEMPTS; } +DisconnectPacket::eDisconnectReason WinsockNetLayer::GetJoinRejectReason() { return s_joinRejectReason; } + bool WinsockNetLayer::SendOnSocket(SOCKET sock, const void* data, int dataSize) { if (sock == INVALID_SOCKET || dataSize <= 0 || dataSize > WIN64_NET_MAX_PACKET_SIZE) return false; @@ -705,10 +1023,37 @@ bool WinsockNetLayer::SendToSmallId(BYTE targetSmallId, const void* data, int da { SOCKET sock = GetSocketForSmallId(targetSmallId); if (sock == INVALID_SOCKET) return false; + +#if defined(MINECRAFT_SERVER_BUILD) + // Encrypt outgoing data if a cipher is active for this connection. + // TryEncryptOutgoing atomically checks and encrypts under a single lock + // to avoid TOCTOU races with DeactivateCipher on disconnect. + if (g_Win64DedicatedServer && dataSize > 0) + { + std::vector buf(static_cast(data), + static_cast(data) + dataSize); + if (ServerRuntime::Security::GetCipherRegistry().TryEncryptOutgoing( + targetSmallId, buf.data(), dataSize)) + { + return SendOnSocket(sock, buf.data(), dataSize); + } + } +#endif return SendOnSocket(sock, data, dataSize); } else { + // Client sending to server - encrypt if send cipher is active + EnterCriticalSection(&s_clientCipherLock); + if (s_clientSendCipher.IsActive() && dataSize > 0) + { + std::vector buf(static_cast(data), + static_cast(data) + dataSize); + s_clientSendCipher.Encrypt(buf.data(), dataSize); + LeaveCriticalSection(&s_clientCipherLock); + return SendOnSocket(s_hostConnectionSocket, buf.data(), dataSize); + } + LeaveCriticalSection(&s_clientCipherLock); return SendOnSocket(s_hostConnectionSocket, data, dataSize); } } @@ -773,6 +1118,128 @@ static bool TryGetNumericRemoteIp(const sockaddr_in &remoteAddress, std::string *outIp = ip; return true; } + +enum EProxyParseResult +{ + eProxyParse_Success, // Valid PROXY TCP4 header, IP extracted + eProxyParse_Unknown, // Valid PROXY UNKNOWN header, no IP available + eProxyParse_Malformed, // Invalid header format + eProxyParse_Timeout, // Recv timed out + eProxyParse_SocketError // Socket error during read +}; + +/** + * Parse a PROXY protocol v1 header from the socket. + * Must be called immediately after accept(), before any game data is read. + * Sets a 5-second recv timeout, reads the header, restores timeout on all paths. + */ +static EProxyParseResult TryReadProxyProtocolHeader(SOCKET sock, std::string *outSrcIp) +{ + if (outSrcIp != nullptr) + outSrcIp->clear(); + + // Set 5-second recv timeout for the header read + DWORD timeout = 5000; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char *)&timeout, sizeof(timeout)); + + auto restoreTimeout = [sock]() { + DWORD noTimeout = 0; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char *)&noTimeout, sizeof(noTimeout)); + }; + + // Peek at first 6 bytes to check for "PROXY " prefix + char peekBuf[6]; + int peekResult = recv(sock, peekBuf, 6, MSG_PEEK); + if (peekResult == 0) + { + restoreTimeout(); + return eProxyParse_SocketError; + } + if (peekResult == SOCKET_ERROR) + { + restoreTimeout(); + int err = WSAGetLastError(); + return (err == WSAETIMEDOUT) ? eProxyParse_Timeout : eProxyParse_SocketError; + } + if (peekResult < 6 || memcmp(peekBuf, "PROXY ", 6) != 0) + { + restoreTimeout(); + return eProxyParse_Malformed; + } + + // Consume header byte-by-byte until \r\n (max 107 bytes per PROXY v1 spec) + char lineBuf[108] = {}; + int lineLen = 0; + bool foundEnd = false; + + while (lineLen < 107) + { + char ch; + int r = recv(sock, &ch, 1, 0); + if (r != 1) + { + restoreTimeout(); + int err = WSAGetLastError(); + return (r == SOCKET_ERROR && err == WSAETIMEDOUT) ? eProxyParse_Timeout : eProxyParse_SocketError; + } + lineBuf[lineLen++] = ch; + + if (lineLen >= 2 && lineBuf[lineLen - 2] == '\r' && lineBuf[lineLen - 1] == '\n') + { + foundEnd = true; + lineBuf[lineLen - 2] = '\0'; // null-terminate, strip \r\n + break; + } + } + + restoreTimeout(); + + if (!foundEnd) + { + return eProxyParse_Malformed; + } + + // Parse: "PROXY TCP4 " + // or: "PROXY UNKNOWN" + char *tokens[6] = {}; + int tokenCount = 0; + char *ctx = nullptr; + char *tok = strtok_s(lineBuf, " ", &ctx); + while (tok != nullptr && tokenCount < 6) + { + tokens[tokenCount++] = tok; + tok = strtok_s(nullptr, " ", &ctx); + } + + if (tokenCount < 2 || strcmp(tokens[0], "PROXY") != 0) + { + return eProxyParse_Malformed; + } + + if (strcmp(tokens[1], "UNKNOWN") == 0) + { + return eProxyParse_Unknown; + } + + if (strcmp(tokens[1], "TCP4") != 0 || tokenCount < 6) + { + return eProxyParse_Malformed; + } + + // Validate src_ip with inet_pton + struct in_addr addr; + if (inet_pton(AF_INET, tokens[2], &addr) != 1) + { + return eProxyParse_Malformed; + } + + if (outSrcIp != nullptr) + { + *outSrcIp = tokens[2]; + } + + return eProxyParse_Success; +} #endif void WinsockNetLayer::HandleDataReceived(BYTE fromSmallId, BYTE toSmallId, unsigned char* data, unsigned int dataSize) @@ -825,7 +1292,36 @@ DWORD WINAPI WinsockNetLayer::AcceptThreadProc(LPVOID param) #if defined(MINECRAFT_SERVER_BUILD) std::string remoteIp; - const bool hasRemoteIp = TryGetNumericRemoteIp(remoteAddress, &remoteIp); + bool hasRemoteIp = TryGetNumericRemoteIp(remoteAddress, &remoteIp); + + // PROXY protocol v1: parse real client IP from tunnel header + if (g_Win64DedicatedServer && ServerRuntime::Security::GetSettings().proxyProtocol) + { + std::string proxiedIp; + EProxyParseResult proxyResult = TryReadProxyProtocolHeader(clientSocket, &proxiedIp); + if (proxyResult == eProxyParse_Success) + { + ServerRuntime::LogInfof("network", "PROXY: real client IP %s (tunnel: %s)", + proxiedIp.c_str(), hasRemoteIp ? remoteIp.c_str() : "unknown"); + remoteIp = proxiedIp; + hasRemoteIp = true; + } + else if (proxyResult == eProxyParse_Unknown) + { + ServerRuntime::LogInfof("network", "PROXY: UNKNOWN header, keeping tunnel IP"); + } + else + { + ServerRuntime::LogWarnf("network", "PROXY: header parse failed (result=%d) from %s", + (int)proxyResult, hasRemoteIp ? remoteIp.c_str() : "unknown"); + const char *rejectIp = hasRemoteIp ? remoteIp.c_str() : "unknown"; + ServerRuntime::ServerLogManager::OnRejectedTcpConnection(rejectIp, + ServerRuntime::ServerLogManager::eTcpRejectReason_InvalidProxyHeader); + closesocket(clientSocket); + continue; + } + } + const char *remoteIpForLog = hasRemoteIp ? remoteIp.c_str() : "unknown"; if (g_Win64DedicatedServer) { @@ -837,6 +1333,22 @@ DWORD WINAPI WinsockNetLayer::AcceptThreadProc(LPVOID param) closesocket(clientSocket); continue; } + + // Rate limiting: reject connections that exceed the per-IP sliding window + if (hasRemoteIp) + { + const auto &secSettings = ServerRuntime::Security::GetSettings(); + bool allowed = ServerRuntime::Security::GetGlobalRateLimiter().AllowConnection( + remoteIp, + secSettings.rateLimitConnectionsPerWindow, + secSettings.rateLimitWindowSeconds * 1000); + if (!allowed) + { + ServerRuntime::ServerLogManager::OnRejectedTcpConnection(remoteIpForLog, ServerRuntime::ServerLogManager::eTcpRejectReason_RateLimited); + closesocket(clientSocket); + continue; + } + } } #endif @@ -1015,6 +1527,25 @@ DWORD WINAPI WinsockNetLayer::RecvThreadProc(LPVOID param) break; } +#if defined(MINECRAFT_SERVER_BUILD) + // Check for MC|CAck cipher handshake (raw byte match, before decryption). + // The ack is always plaintext - it's the last plaintext packet from the client. + if (g_Win64DedicatedServer && + packetSize == kCipherAckPatternSize && + memcmp(&recvBuf[0], kCipherAckPattern, kCipherAckPatternSize) == 0) + { + // Atomically send MC|COn plaintext then commit the cipher + SendCOnAndCommitServerCipher(clientSmallId); + continue; // consumed - do not pass to game packet handler + } + + // Decrypt incoming data if a cipher is active for this connection + if (g_Win64DedicatedServer) + { + ServerRuntime::Security::GetCipherRegistry().DecryptIncoming(clientSmallId, &recvBuf[0], packetSize); + } +#endif + HandleDataReceived(clientSmallId, s_hostSmallId, &recvBuf[0], packetSize); } @@ -1057,6 +1588,14 @@ bool WinsockNetLayer::PopDisconnectedSmallId(BYTE* outSmallId) void WinsockNetLayer::PushFreeSmallId(BYTE smallId) { +#if defined(MINECRAFT_SERVER_BUILD) + // Clean up any active cipher for this connection + if (g_Win64DedicatedServer) + { + ServerRuntime::Security::GetCipherRegistry().DeactivateCipher(smallId); + } +#endif + // SmallIds 0..(XUSER_MAX_COUNT-1) are permanently reserved for the host's // local pads and must never be recycled to remote clients. if (smallId < (BYTE)XUSER_MAX_COUNT) @@ -1293,10 +1832,29 @@ DWORD WINAPI WinsockNetLayer::ClientRecvThreadProc(LPVOID param) break; } + // Check for MC|COn cipher activation signal (raw byte match, before decryption). + // This is always sent in plaintext as the last plaintext packet from the server. + if (packetSize == kCipherOnPatternSize && + memcmp(&recvBuf[0], kCipherOnPattern, kCipherOnPatternSize) == 0) + { + ActivateClientRecvCipher(); + app.DebugPrintf("Client: Recv cipher activated (MC|COn received)\n"); + continue; // consumed - do not pass to game packet handler + } + + // Decrypt incoming data if recv cipher is active + EnterCriticalSection(&s_clientCipherLock); + if (s_clientRecvCipher.IsActive()) + { + s_clientRecvCipher.Decrypt(&recvBuf[0], packetSize); + } + LeaveCriticalSection(&s_clientCipherLock); + HandleDataReceived(s_hostSmallId, s_localSmallId, &recvBuf[0], packetSize); } s_connected = false; + ResetClientCipher(); return 0; } @@ -1562,25 +2120,4 @@ DWORD WINAPI WinsockNetLayer::DiscoveryThreadProc(LPVOID param) return 0; } -// some lazy helper funcs -WinsockNetLayer::eJoinState WinsockNetLayer::GetJoinState() -{ - return s_joinState; -} - -int WinsockNetLayer::GetJoinAttempt() -{ - return s_joinAttempt; -} - -int WinsockNetLayer::GetJoinMaxAttempts() -{ - return JOIN_MAX_ATTEMPTS; -} - -DisconnectPacket::eDisconnectReason WinsockNetLayer::GetJoinRejectReason() -{ - return s_joinRejectReason; -} - #endif diff --git a/Minecraft.Client/Windows64/Network/WinsockNetLayer.h b/Minecraft.Client/Windows64/Network/WinsockNetLayer.h index 5ecd8acf..a4eb2fae 100644 --- a/Minecraft.Client/Windows64/Network/WinsockNetLayer.h +++ b/Minecraft.Client/Windows64/Network/WinsockNetLayer.h @@ -8,6 +8,8 @@ #include #include #include "../../Common/Network/NetworkPlayerInterface.h" +#include "../../..\Minecraft.World\DisconnectPacket.h" +#include "../../..\Minecraft.Server\Security\StreamCipher.h" #pragma comment(lib, "Ws2_32.lib") @@ -21,8 +23,6 @@ class Socket; -#include "..\..\..\Minecraft.World\DisconnectPacket.h" - #pragma pack(push, 1) struct Win64LANBroadcast { @@ -71,6 +71,7 @@ public: static bool HostGame(int port, const char* bindIp = nullptr); static bool JoinGame(const char* ip, int port); + // Async join: runs connection on a background thread so the UI stays responsive enum eJoinState { eJoinState_Idle, @@ -82,11 +83,11 @@ public: }; static bool BeginJoinGame(const char* ip, int port); static void CancelJoinGame(); + static bool FinalizeJoin(); static eJoinState GetJoinState(); static int GetJoinAttempt(); static int GetJoinMaxAttempts(); static DisconnectPacket::eDisconnectReason GetJoinRejectReason(); - static bool FinalizeJoin(); static bool SendToSmallId(BYTE targetSmallId, const void* data, int dataSize); static bool SendOnSocket(SOCKET sock, const void* data, int dataSize); @@ -133,16 +134,6 @@ private: static DWORD WINAPI DiscoveryThreadProc(LPVOID param); static DWORD WINAPI JoinThreadProc(LPVOID param); - static HANDLE s_joinThread; - static volatile eJoinState s_joinState; - static volatile int s_joinAttempt; - static volatile bool s_joinCancel; - static char s_joinIP[256]; - static int s_joinPort; - static BYTE s_joinAssignedSmallId; - static DisconnectPacket::eDisconnectReason s_joinRejectReason; - static const int JOIN_MAX_ATTEMPTS = 4; - static SOCKET s_listenSocket; static SOCKET s_hostConnectionSocket; static HANDLE s_acceptThread; @@ -184,13 +175,54 @@ private: static SOCKET s_smallIdToSocket[256]; static CRITICAL_SECTION s_smallIdToSocketLock; + // Async join state + static const int JOIN_MAX_ATTEMPTS = 3; + static HANDLE s_joinThread; + static volatile eJoinState s_joinState; + static volatile int s_joinAttempt; + static volatile bool s_joinCancel; + static char s_joinIP[256]; + static int s_joinPort; + static BYTE s_joinAssignedSmallId; + static DisconnectPacket::eDisconnectReason s_joinRejectReason; + // Per-pad split-screen TCP connections (client-side, non-host only) static SOCKET s_splitScreenSocket[XUSER_MAX_COUNT]; static BYTE s_splitScreenSmallId[XUSER_MAX_COUNT]; static HANDLE s_splitScreenRecvThread[XUSER_MAX_COUNT]; + // Client-side stream cipher (non-host only, one connection to server) + static ServerRuntime::Security::StreamCipher s_clientSendCipher; + static ServerRuntime::Security::StreamCipher s_clientRecvCipher; + static CRITICAL_SECTION s_clientCipherLock; + static uint8_t s_clientPendingKey[ServerRuntime::Security::StreamCipher::KEY_SIZE]; + static bool s_clientKeyStored; // protected by s_clientCipherLock + public: static void ClearSocketForSmallId(BYTE smallId); + + /** Store the cipher key received from the server. Does not activate yet. */ + static void StoreClientCipherKey(const uint8_t key[ServerRuntime::Security::StreamCipher::KEY_SIZE]); + + /** Send MC|CAck directly to socket then activate client send cipher. Atomic under s_sendLock. */ + static bool SendAckAndActivateClientSendCipher(); + + /** Activate client recv cipher. Called from ClientRecvThreadProc on MC|COn detection. */ + static void ActivateClientRecvCipher(); + + /** Reset client ciphers on disconnect. */ + static void ResetClientCipher(); + + /** + * Encrypt data in-place for client->server send if the client send cipher is active. + * Returns true if data was encrypted. Thread-safe. + */ + static bool TryEncryptClientOutgoing(uint8_t *data, int length); + +#if defined(MINECRAFT_SERVER_BUILD) + /** Atomically send MC|COn plaintext then commit server cipher. Called from RecvThreadProc. */ + static bool SendCOnAndCommitServerCipher(BYTE smallId); +#endif }; extern bool g_Win64MultiplayerHost; diff --git a/Minecraft.Client/Windows64/Windows64_Minecraft.cpp b/Minecraft.Client/Windows64/Windows64_Minecraft.cpp index 6b1c371c..b51d712a 100644 --- a/Minecraft.Client/Windows64/Windows64_Minecraft.cpp +++ b/Minecraft.Client/Windows64/Windows64_Minecraft.cpp @@ -49,6 +49,7 @@ #include "Network/WinsockNetLayer.h" #include "Windows64_Xuid.h" #include "Common/UI/UI.h" +#include "stb_image_write.h" // Forward-declare the internal Renderer class and its global instance from 4J_Render_PC_d.lib. // C4JRender (RenderManager) is a stateless wrapper — all D3D state lives in InternalRenderManager. @@ -469,6 +470,123 @@ D3D_FEATURE_LEVEL g_featureLevel = D3D_FEATURE_LEVEL_11_0; ID3D11Device* g_pd3dDevice = nullptr; ID3D11DeviceContext* g_pImmediateContext = nullptr; IDXGISwapChain* g_pSwapChain = nullptr; +bool g_bVSync = false; +static bool g_bTearingSupported = false; +static bool g_bPendingExclusiveFullscreen = false; +static bool g_bPendingExclusiveFullscreenValue = false; + +// Captures the D3D11 back buffer and saves it as a PNG screenshot. +// Returns true on success and sets outFilename to the saved filename. +static bool TakeScreenshot(wstring& outFilename) +{ + if (!g_pSwapChain || !g_pd3dDevice || !g_pImmediateContext) + return false; + + ID3D11Texture2D* pBackBuffer = nullptr; + HRESULT hr = g_pSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&pBackBuffer); + if (FAILED(hr)) + return false; + + D3D11_TEXTURE2D_DESC desc; + pBackBuffer->GetDesc(&desc); + desc.Usage = D3D11_USAGE_STAGING; + desc.BindFlags = 0; + desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + desc.MiscFlags = 0; + + bool success = false; + ID3D11Texture2D* pStaging = nullptr; + hr = g_pd3dDevice->CreateTexture2D(&desc, nullptr, &pStaging); + if (SUCCEEDED(hr)) + { + g_pImmediateContext->CopyResource(pStaging, pBackBuffer); + + wchar_t exePath[MAX_PATH]; + GetModuleFileNameW(NULL, exePath, MAX_PATH); + wchar_t* lastSlash = wcsrchr(exePath, L'\\'); + if (lastSlash) *(lastSlash + 1) = L'\0'; + wstring screenshotDirPath = wstring(exePath) + L"screenshots"; + CreateDirectoryW(screenshotDirPath.c_str(), NULL); + + SYSTEMTIME st; + GetLocalTime(&st); + wchar_t filename[128]; + swprintf_s(filename, L"%04d-%02d-%02d_%02d.%02d.%02d.png", + st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); + wstring screenshotPath = screenshotDirPath + L"\\" + filename; + + D3D11_MAPPED_SUBRESOURCE mapped; + hr = g_pImmediateContext->Map(pStaging, 0, D3D11_MAP_READ, 0, &mapped); + if (SUCCEEDED(hr)) + { + unsigned char* rgba = new unsigned char[desc.Width * desc.Height * 4]; + for (UINT row = 0; row < desc.Height; row++) + { + unsigned char* src = (unsigned char*)mapped.pData + row * mapped.RowPitch; + unsigned char* dst = rgba + row * desc.Width * 4; + memcpy(dst, src, desc.Width * 4); + for (UINT x = 0; x < desc.Width; x++) + dst[x * 4 + 3] = 0xFF; + } + g_pImmediateContext->Unmap(pStaging, 0); + + string narrowPath(screenshotPath.begin(), screenshotPath.end()); + int writeResult = stbi_write_png(narrowPath.c_str(), desc.Width, desc.Height, 4, rgba, desc.Width * 4); + delete[] rgba; + + if (writeResult) + { + outFilename = filename; + success = true; + } + } + pStaging->Release(); + } + pBackBuffer->Release(); + return success; +} + +// COM proxy for IDXGISwapChain — delegates all calls to the real swap chain, +// but overrides Present() to set SyncInterval=1 when VSync is enabled. +// Avoids vtable patching, which conflicts with the D3D11 debug layer. +static class SwapChainVSyncProxy : public IDXGISwapChain +{ +public: + void SetTarget(IDXGISwapChain* pReal) { m_pReal = pReal; } + + // IUnknown + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppvObject) override { return m_pReal->QueryInterface(riid, ppvObject); } + ULONG STDMETHODCALLTYPE AddRef() override { return m_pReal->AddRef(); } + ULONG STDMETHODCALLTYPE Release() override { return m_pReal->Release(); } + + // IDXGIObject + HRESULT STDMETHODCALLTYPE SetPrivateData(REFGUID Name, UINT DataSize, const void* pData) override { return m_pReal->SetPrivateData(Name, DataSize, pData); } + HRESULT STDMETHODCALLTYPE SetPrivateDataInterface(REFGUID Name, const IUnknown* pUnknown) override { return m_pReal->SetPrivateDataInterface(Name, pUnknown); } + HRESULT STDMETHODCALLTYPE GetPrivateData(REFGUID Name, UINT* pDataSize, void* pData) override { return m_pReal->GetPrivateData(Name, pDataSize, pData); } + HRESULT STDMETHODCALLTYPE GetParent(REFIID riid, void** ppParent) override { return m_pReal->GetParent(riid, ppParent); } + + // IDXGIDeviceSubObject + HRESULT STDMETHODCALLTYPE GetDevice(REFIID riid, void** ppDevice) override { return m_pReal->GetDevice(riid, ppDevice); } + + // IDXGISwapChain + // NOTE: The 4J RenderManager library hardcodes SyncInterval=1 and does NOT + // dispatch Present through this proxy's vtable. VSync control is handled + // directly in the main loop (see the Present-the-frame block) instead. + HRESULT STDMETHODCALLTYPE Present(UINT SyncInterval, UINT Flags) override { return m_pReal->Present(SyncInterval, Flags); } + HRESULT STDMETHODCALLTYPE GetBuffer(UINT Buffer, REFIID riid, void** ppSurface) override { return m_pReal->GetBuffer(Buffer, riid, ppSurface); } + HRESULT STDMETHODCALLTYPE SetFullscreenState(BOOL Fullscreen, IDXGIOutput* pTarget) override { return m_pReal->SetFullscreenState(Fullscreen, pTarget); } + HRESULT STDMETHODCALLTYPE GetFullscreenState(BOOL* pFullscreen, IDXGIOutput** ppTarget) override { return m_pReal->GetFullscreenState(pFullscreen, ppTarget); } + HRESULT STDMETHODCALLTYPE GetDesc(DXGI_SWAP_CHAIN_DESC* pDesc) override { return m_pReal->GetDesc(pDesc); } + HRESULT STDMETHODCALLTYPE ResizeBuffers(UINT BufferCount, UINT Width, UINT Height, DXGI_FORMAT NewFormat, UINT SwapChainFlags) override { return m_pReal->ResizeBuffers(BufferCount, Width, Height, NewFormat, SwapChainFlags); } + HRESULT STDMETHODCALLTYPE ResizeTarget(const DXGI_MODE_DESC* pNewTargetParameters) override { return m_pReal->ResizeTarget(pNewTargetParameters); } + HRESULT STDMETHODCALLTYPE GetContainingOutput(IDXGIOutput** ppOutput) override { return m_pReal->GetContainingOutput(ppOutput); } + HRESULT STDMETHODCALLTYPE GetFrameStatistics(DXGI_FRAME_STATISTICS* pStats) override { return m_pReal->GetFrameStatistics(pStats); } + HRESULT STDMETHODCALLTYPE GetLastPresentCount(UINT* pLastPresentCount) override { return m_pReal->GetLastPresentCount(pLastPresentCount); } + +private: + IDXGISwapChain* m_pReal = nullptr; +} g_swapChainProxy; + ID3D11RenderTargetView* g_pRenderTargetView = nullptr; ID3D11DepthStencilView* g_pDepthStencilView = nullptr; ID3D11Texture2D* g_pDepthStencilBuffer = nullptr; @@ -508,7 +626,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) break; default: - return DefWindowProc(hWnd, message, wParam, lParam); + return DefWindowProcW(hWnd, message, wParam, lParam); } break; case WM_PAINT: @@ -565,7 +683,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) else if (vk == VK_MENU) vk = (lParam & (1 << 24)) ? VK_RMENU : VK_LMENU; g_KBMInput.OnKeyDown(vk); - return DefWindowProc(hWnd, message, wParam, lParam); + return DefWindowProcW(hWnd, message, wParam, lParam); } case WM_KEYUP: case WM_SYSKEYUP: @@ -663,7 +781,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) } break; default: - return DefWindowProc(hWnd, message, wParam, lParam); + return DefWindowProcW(hWnd, message, wParam, lParam); } return 0; } @@ -675,23 +793,23 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) // ATOM MyRegisterClass(HINSTANCE hInstance) { - WNDCLASSEX wcex; + WNDCLASSEXW wcex; - wcex.cbSize = sizeof(WNDCLASSEX); + wcex.cbSize = sizeof(WNDCLASSEXW); wcex.style = CS_HREDRAW | CS_VREDRAW; wcex.lpfnWndProc = WndProc; wcex.cbClsExtra = 0; wcex.cbWndExtra = 0; wcex.hInstance = hInstance; - wcex.hIcon = LoadIcon(hInstance, "Minecraft"); + wcex.hIcon = LoadIconW(hInstance, L"Minecraft"); wcex.hCursor = LoadCursor(nullptr, IDC_ARROW); wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW+1); - wcex.lpszMenuName = "Minecraft"; - wcex.lpszClassName = "MinecraftClass"; + wcex.lpszMenuName = L"Minecraft"; + wcex.lpszClassName = L"MinecraftClass"; wcex.hIconSm = LoadIcon(wcex.hInstance, MAKEINTRESOURCE(IDI_MINECRAFTWINDOWS)); - return RegisterClassEx(&wcex); + return RegisterClassExW(&wcex); } // @@ -711,8 +829,8 @@ BOOL InitInstance(HINSTANCE hInstance, int nCmdShow) RECT wr = {0, 0, g_rScreenWidth, g_rScreenHeight}; // set the size, but not the position AdjustWindowRect(&wr, WS_OVERLAPPEDWINDOW, FALSE); // adjust the size - g_hWnd = CreateWindow( "MinecraftClass", - "Minecraft", + g_hWnd = CreateWindowW( L"MinecraftClass", + L"Minecraft", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, 0, @@ -819,19 +937,33 @@ HRESULT InitDevice() }; UINT numFeatureLevels = ARRAYSIZE( featureLevels ); + // Check tearing support before device/swap chain creation + { + IDXGIFactory5* factory5 = nullptr; + if (SUCCEEDED(CreateDXGIFactory1(__uuidof(IDXGIFactory5), (void**)&factory5))) + { + BOOL allowTearing = FALSE; + if (SUCCEEDED(factory5->CheckFeatureSupport(DXGI_FEATURE_PRESENT_ALLOW_TEARING, &allowTearing, sizeof(allowTearing)))) + g_bTearingSupported = (allowTearing == TRUE); + factory5->Release(); + } + } + DXGI_SWAP_CHAIN_DESC sd; ZeroMemory( &sd, sizeof( sd ) ); - sd.BufferCount = 1; + sd.BufferCount = 2; sd.BufferDesc.Width = width; sd.BufferDesc.Height = height; sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; - sd.BufferDesc.RefreshRate.Numerator = 60; - sd.BufferDesc.RefreshRate.Denominator = 1; - sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT | DXGI_USAGE_SHADER_INPUT; + sd.BufferDesc.RefreshRate.Numerator = 0; + sd.BufferDesc.RefreshRate.Denominator = 0; + sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; sd.OutputWindow = g_hWnd; sd.SampleDesc.Count = 1; sd.SampleDesc.Quality = 0; sd.Windowed = TRUE; + sd.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD; + sd.Flags = g_bTearingSupported ? DXGI_SWAP_CHAIN_FLAG_ALLOW_TEARING : 0; for( UINT driverTypeIndex = 0; driverTypeIndex < numDriverTypes; driverTypeIndex++ ) { @@ -892,7 +1024,8 @@ HRESULT InitDevice() vp.TopLeftY = 0; g_pImmediateContext->RSSetViewports( 1, &vp ); - RenderManager.Initialise(g_pd3dDevice, g_pSwapChain); + g_swapChainProxy.SetTarget(g_pSwapChain); + RenderManager.Initialise(g_pd3dDevice, &g_swapChainProxy); PostProcesser::GetInstance().Init(); @@ -908,7 +1041,7 @@ void Render() const float ClearColor[4] = { 0.0f, 0.125f, 0.3f, 1.0f }; //red,green,blue,alpha g_pImmediateContext->ClearRenderTargetView( g_pRenderTargetView, ClearColor ); - g_pSwapChain->Present( 0, 0 ); + g_pSwapChain->Present(0, g_bTearingSupported ? DXGI_PRESENT_ALLOW_TEARING : 0); } //-------------------------------------------------------------------------------------- @@ -948,11 +1081,11 @@ static bool ResizeD3D(int newW, int newH) // Verify offsets by checking device and swap chain pointers ID3D11Device** ppRM_Device = (ID3D11Device**)(pRM + 0x10); - if (*ppRM_Device != g_pd3dDevice || *ppRM_SC != g_pSwapChain) + if (*ppRM_Device != g_pd3dDevice || *ppRM_SC != (IDXGISwapChain*)&g_swapChainProxy) { app.DebugPrintf("[RESIZE] ERROR: RenderManager offset verification failed! " "device=%p (expected %p) swapchain=%p (expected %p)\n", - *ppRM_Device, g_pd3dDevice, *ppRM_SC, g_pSwapChain); + *ppRM_Device, g_pd3dDevice, *ppRM_SC, (IDXGISwapChain*)&g_swapChainProxy); return false; } @@ -983,59 +1116,61 @@ static bool ResizeD3D(int newW, int newH) gdraw_D3D11_PreReset(); - // Get IDXGIFactory from the existing device BEFORE destroying the old swap chain. - // If anything fails before we have a new swap chain, we abort without destroying - // the old one — leaving the Renderer in a valid (old-size) state. IDXGISwapChain* pOldSwapChain = g_pSwapChain; bool success = false; HRESULT hr; - IDXGIDevice* dxgiDevice = NULL; - IDXGIAdapter* dxgiAdapter = NULL; - IDXGIFactory* dxgiFactory = NULL; - hr = g_pd3dDevice->QueryInterface(__uuidof(IDXGIDevice), (void**)&dxgiDevice); - if (FAILED(hr)) goto postReset; - hr = dxgiDevice->GetParent(__uuidof(IDXGIAdapter), (void**)&dxgiAdapter); - if (FAILED(hr)) { dxgiDevice->Release(); goto postReset; } - hr = dxgiAdapter->GetParent(__uuidof(IDXGIFactory), (void**)&dxgiFactory); - dxgiAdapter->Release(); - dxgiDevice->Release(); - if (FAILED(hr)) goto postReset; - - // Create new swap chain at backbuffer size + // Create a brand-new swap chain instead of ResizeBuffers. + // ResizeBuffers requires ALL backbuffer refs released, but the closed-source + // Renderer holds hidden refs we can't track — causing DXGI_ERROR_INVALID_CALL + // and leaving the Renderer with NULL views (black screen). + // Creating a new swap chain orphans the old backbuffer (tiny leak) but avoids + // the need to release every hidden reference. { + IDXGIDevice* dxgiDevice = NULL; + IDXGIAdapter* dxgiAdapter = NULL; + IDXGIFactory* dxgiFactory = NULL; + hr = g_pd3dDevice->QueryInterface(__uuidof(IDXGIDevice), (void**)&dxgiDevice); + if (FAILED(hr)) goto postReset; + hr = dxgiDevice->GetParent(__uuidof(IDXGIAdapter), (void**)&dxgiAdapter); + dxgiDevice->Release(); + if (FAILED(hr)) goto postReset; + hr = dxgiAdapter->GetParent(__uuidof(IDXGIFactory), (void**)&dxgiFactory); + dxgiAdapter->Release(); + if (FAILED(hr)) goto postReset; + DXGI_SWAP_CHAIN_DESC sd = {}; - sd.BufferCount = 1; + sd.BufferCount = 2; sd.BufferDesc.Width = bbW; sd.BufferDesc.Height = bbH; sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; - sd.BufferDesc.RefreshRate.Numerator = 60; - sd.BufferDesc.RefreshRate.Denominator = 1; - sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT | DXGI_USAGE_SHADER_INPUT; + sd.BufferDesc.RefreshRate.Numerator = 0; + sd.BufferDesc.RefreshRate.Denominator = 0; + sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; sd.OutputWindow = g_hWnd; sd.SampleDesc.Count = 1; sd.SampleDesc.Quality = 0; sd.Windowed = TRUE; + sd.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD; + sd.Flags = g_bTearingSupported ? DXGI_SWAP_CHAIN_FLAG_ALLOW_TEARING : 0; IDXGISwapChain* pNewSwapChain = NULL; hr = dxgiFactory->CreateSwapChain(g_pd3dDevice, &sd, &pNewSwapChain); dxgiFactory->Release(); - if (FAILED(hr) || pNewSwapChain == NULL) + if (FAILED(hr)) { - app.DebugPrintf("[RESIZE] CreateSwapChain FAILED hr=0x%08X — keeping old swap chain\n", (unsigned)hr); + app.DebugPrintf("[RESIZE] CreateSwapChain FAILED hr=0x%08X\n", (unsigned)hr); goto postReset; } - // New swap chain created successfully — NOW destroy the old one. - // The Renderer's internal RTV/SRV still reference the old backbuffer — - // those COM objects become orphaned (tiny leak, harmless). We DON'T - // release them because unknown code may also hold refs. + // Destroy old, install new pOldSwapChain->Release(); g_pSwapChain = pNewSwapChain; + g_swapChainProxy.SetTarget(g_pSwapChain); } - // Patch Renderer's swap chain pointer - *ppRM_SC = g_pSwapChain; + // Patch Renderer's swap chain pointer (use proxy so VSync override stays active) + *ppRM_SC = &g_swapChainProxy; // Create render target views from new backbuffer { @@ -1220,6 +1355,29 @@ void ToggleFullscreen() g_KBMInput.SetWindowFocused(true); } +// Called from UI thread — defers the actual transition to the main game loop +void SetExclusiveFullscreen(bool enabled) +{ + if (enabled == g_isFullscreen) + return; + g_bPendingExclusiveFullscreen = true; + g_bPendingExclusiveFullscreenValue = enabled; +} + +// Uses borderless fullscreen (ToggleFullscreen) rather than DXGI SetFullscreenState. +// With DXGI_SWAP_EFFECT_FLIP_DISCARD + DXGI_PRESENT_ALLOW_TEARING, borderless +// fullscreen gets the same direct-flip path as exclusive fullscreen on Windows 10+ — +// identical latency and uncapped FPS. True DXGI exclusive fullscreen is blocked by +// the 4J Renderer holding hidden backbuffer references that prevent ResizeBuffers. +static void ApplyExclusiveFullscreen(bool enabled) +{ + // Toggle into/out of borderless fullscreen if state doesn't match + if (enabled && !g_isFullscreen) + ToggleFullscreen(); + else if (!enabled && g_isFullscreen) + ToggleFullscreen(); +} + //-------------------------------------------------------------------------------------- // Clean up the objects we've created //-------------------------------------------------------------------------------------- @@ -1747,7 +1905,20 @@ int APIENTRY _tWinMain(_In_ HINSTANCE hInstance, RenderManager.Set_matrixDirty(); #endif // Present the frame. - RenderManager.Present(); + // RenderManager.Present() hardcodes SyncInterval=1 internally. + // When VSync is off, bypass it and call the swap chain directly. + if (!g_bVSync && g_bTearingSupported && g_pSwapChain) + { + HRESULT hrPresent = g_pSwapChain->Present(0, DXGI_PRESENT_ALLOW_TEARING); + // If tearing Present fails (e.g. during fullscreen transition), + // fall back to the library's VSync'd Present for this frame. + if (FAILED(hrPresent)) + RenderManager.Present(); + } + else + { + RenderManager.Present(); + } ui.CheckMenuDisplayed(); @@ -1772,6 +1943,20 @@ int APIENTRY _tWinMain(_In_ HINSTANCE hInstance, } } + // F2 takes a screenshot (works in any context) + if (g_KBMInput.IsKeyPressed(KeyboardMouseInput::KEY_SCREENSHOT)) + { + wstring filename; + if (TakeScreenshot(filename)) + { + if (pMinecraft->gui && pMinecraft->player) + { + wstring msg = L"Saved screenshot to " + filename; + pMinecraft->gui->addMessage(msg, ProfileManager.GetPrimaryPad()); + } + } + } + // F1 toggles the HUD if (g_KBMInput.IsKeyPressed(KeyboardMouseInput::KEY_TOGGLE_HUD)) { @@ -1827,6 +2012,14 @@ int APIENTRY _tWinMain(_In_ HINSTANCE hInstance, if (g_KBMInput.IsKeyPressed(KeyboardMouseInput::KEY_FULLSCREEN)) { ToggleFullscreen(); + app.SetGameSettings(ProfileManager.GetPrimaryPad(), eGameSetting_ExclusiveFullscreen, g_isFullscreen ? 1 : 0); + } + + // Apply deferred exclusive fullscreen toggle + if (g_bPendingExclusiveFullscreen) + { + g_bPendingExclusiveFullscreen = false; + ApplyExclusiveFullscreen(g_bPendingExclusiveFullscreenValue); } // TAB opens game info menu. - Vvis :3 - Updated by detectiveren diff --git a/Minecraft.Client/Windows64Media/strings.h b/Minecraft.Client/Windows64Media/strings.h index 1ed1b4be..e837c715 100644 --- a/Minecraft.Client/Windows64Media/strings.h +++ b/Minecraft.Client/Windows64Media/strings.h @@ -1,7 +1,7 @@ #pragma once // Auto-generated by StringTable builder — do not edit manually. // Source language: en-US -// Total strings: 2425 +// Total strings: 2287 #define IDS_NULL 0 #define IDS_OK 1 @@ -2165,266 +2165,136 @@ #define IDS_TUTORIAL_TASK_MINECART_PUSHING 2159 #define IDS_CONNECTION_FAILED_NO_SD_SPLITSCREEN 2160 #define IDS_TOOLTIPS_CURE 2161 -#define IDS_TILE_LOG_ACACIA 2162 -#define IDS_TILE_LOG_DARK_OAK 2163 -#define IDS_TILE_ACACIA_PLANKS 2164 -#define IDS_TILE_DARK_OAK_PLANKS 2165 -#define IDS_TILE_STAIRS_ACACIAWOOD 2166 -#define IDS_TILE_STAIRS_DARKWOOD 2167 -#define IDS_TILE_STONESLAB_ACACIA 2168 -#define IDS_TILE_STONESLAB_DARK_OAK 2169 -#define IDS_TILE_IRON_TRAPDOOR 2170 -#define IDS_TILE_DOOR_SPRUCE 2171 -#define IDS_TILE_DOOR_BIRCH 2172 -#define IDS_TILE_DOOR_JUNGLE 2173 -#define IDS_TILE_DOOR_ACACIA 2174 -#define IDS_TILE_DOOR_DARK 2175 -#define IDS_TILE_SPRUCE_FENCE 2176 -#define IDS_TILE_BIRCH_FENCE 2177 -#define IDS_TILE_JUNGLE_FENCE 2178 -#define IDS_TILE_ACACIA_FENCE 2179 -#define IDS_TILE_DARK_FENCE 2180 -#define IDS_TILE_SPRUCE_GATE 2181 -#define IDS_TILE_BIRCH_GATE 2182 -#define IDS_TILE_JUNGLE_GATE 2183 -#define IDS_TILE_ACACIA_GATE 2184 -#define IDS_TILE_DARK_GATE 2185 -#define IDS_ITEM_DOOR_SPRUCE 2186 -#define IDS_ITEM_DOOR_BIRCH 2187 -#define IDS_ITEM_DOOR_JUNGLE 2188 -#define IDS_ITEM_DOOR_ACACIA 2189 -#define IDS_ITEM_DOOR_DARK 2190 -#define IDS_ITEM_ARMOR_STAND 2191 -#define IDS_DESC_ARMOR_STAND 2192 -#define IDS_RABBIT 2193 -#define IDS_DESC_RABBIT 2194 -#define IDS_ITEM_RABBIT_HIDE 2195 -#define IDS_DESC_RABBIT_HIDE 2196 -#define IDS_ITEM_RABBIT_FOOT 2197 -#define IDS_DESC_RABBIT_FOOT 2198 -#define IDS_ITEM_RABBIT_RAW 2199 -#define IDS_DESC_RABBIT_RAW 2200 -#define IDS_ITEM_RABBIT_COOKED 2201 -#define IDS_DESC_RABBIT_COOKED 2202 -#define IDS_ITEM_MUTTON_RAW 2203 -#define IDS_DESC_MUTTON_RAW 2204 -#define IDS_ITEM_MUTTON_COOKED 2205 -#define IDS_DESC_MUTTON_COOKED 2206 -#define IDS_TILE_SAPLING_ACACIA 2207 -#define IDS_TILE_SAPLING_DARK_OAK 2208 -#define IDS_TILE_LEAVES_ACACIA 2209 -#define IDS_TILE_LEAVES_DARK_OAK 2210 -#define IDS_TILE_RED_SANDSTONE 2211 -#define IDS_TILE_STAIRS_RED_SANDSTONE 2212 -#define IDS_ITEM_PRISMARINE_CRYSTAL 2213 -#define IDS_TILE_SEA_LANTERN 2214 -#define IDS_LANG_SYSTEM 2215 -#define IDS_LANG_ENGLISH 2216 -#define IDS_LANG_GERMAN 2217 -#define IDS_LANG_SPANISH 2218 -#define IDS_LANG_SPANISH_SPAIN 2219 -#define IDS_LANG_SPANISH_LATIN_AMERICA 2220 -#define IDS_LANG_FRENCH 2221 -#define IDS_LANG_ITALIAN 2222 -#define IDS_LANG_PORTUGUESE 2223 -#define IDS_LANG_PORTUGUESE_PORTUGAL 2224 -#define IDS_LANG_PORTUGUESE_BRAZIL 2225 -#define IDS_LANG_JAPANESE 2226 -#define IDS_LANG_KOREAN 2227 -#define IDS_LANG_CHINESE_TRADITIONAL 2228 -#define IDS_LANG_CHINESE_SIMPLIFIED 2229 -#define IDS_LANG_DANISH 2230 -#define IDS_LANG_FINISH 2231 -#define IDS_LANG_DUTCH 2232 -#define IDS_LANG_POLISH 2233 -#define IDS_LANG_RUSSIAN 2234 -#define IDS_LANG_SWEDISH 2235 -#define IDS_LANG_NORWEGIAN 2236 -#define IDS_LANG_GREEK 2237 -#define IDS_LANG_TURKISH 2238 -#define IDS_LEADERBOARD_KILLS_EASY 2239 -#define IDS_LEADERBOARD_KILLS_NORMAL 2240 -#define IDS_LEADERBOARD_KILLS_HARD 2241 -#define IDS_LEADERBOARD_MINING_BLOCKS_PEACEFUL 2242 -#define IDS_LEADERBOARD_MINING_BLOCKS_EASY 2243 -#define IDS_LEADERBOARD_MINING_BLOCKS_NORMAL 2244 -#define IDS_LEADERBOARD_MINING_BLOCKS_HARD 2245 -#define IDS_LEADERBOARD_FARMING_PEACEFUL 2246 -#define IDS_LEADERBOARD_FARMING_EASY 2247 -#define IDS_LEADERBOARD_FARMING_NORMAL 2248 -#define IDS_LEADERBOARD_FARMING_HARD 2249 -#define IDS_LEADERBOARD_TRAVELLING_PEACEFUL 2250 -#define IDS_LEADERBOARD_TRAVELLING_EASY 2251 -#define IDS_LEADERBOARD_TRAVELLING_NORMAL 2252 -#define IDS_LEADERBOARD_TRAVELLING_HARD 2253 -#define IDS_TIPS_GAMETIP_0 2254 -#define IDS_TIPS_GAMETIP_1 2255 -#define IDS_TIPS_GAMETIP_48 2256 -#define IDS_TIPS_GAMETIP_44 2257 -#define IDS_TIPS_GAMETIP_45 2258 -#define IDS_TIPS_TRIVIA_4 2259 -#define IDS_TIPS_TRIVIA_17 2260 -#define IDS_HOW_TO_PLAY_MULTIPLAYER 2261 -#define IDS_HOW_TO_PLAY_SOCIALMEDIA 2262 -#define IDS_HOW_TO_PLAY_CREATIVE 2263 -#define IDS_TUTORIAL_TASK_FLY 2264 -#define IDS_TOOLTIPS_SELECTDEVICE 2265 -#define IDS_TOOLTIPS_CHANGEDEVICE 2266 -#define IDS_TOOLTIPS_VIEW_GAMERCARD 2267 -#define IDS_TOOLTIPS_VIEW_GAMERPROFILE 2268 -#define IDS_TOOLTIPS_INVITE_PARTY 2269 -#define IDS_CONFIRM_START_CREATIVE 2270 -#define IDS_CONFIRM_START_SAVEDINCREATIVE 2271 -#define IDS_CONFIRM_START_SAVEDINCREATIVE_CONTINUE 2272 -#define IDS_CONFIRM_START_HOST_PRIVILEGES 2273 -#define IDS_CONNECTION_LOST_LIVE 2274 -#define IDS_CONNECTION_LOST_LIVE_NO_EXIT 2275 -#define IDS_AWARD_AVATAR1 2276 -#define IDS_AWARD_AVATAR2 2277 -#define IDS_AWARD_AVATAR3 2278 -#define IDS_AWARD_THEME 2279 -#define IDS_UNLOCK_ACHIEVEMENT_TEXT 2280 -#define IDS_UNLOCK_AVATAR_TEXT 2281 -#define IDS_UNLOCK_GAMERPIC_TEXT 2282 -#define IDS_UNLOCK_THEME_TEXT 2283 -#define IDS_UNLOCK_ACCEPT_INVITE 2284 -#define IDS_UNLOCK_GUEST_TEXT 2285 -#define IDS_LEADERBOARD_GAMERTAG 2286 -#define IDS_GROUPNAME_POTIONS_480 2287 -#define IDS_RETURNEDTOTITLESCREEN_TEXT 2288 -#define IDS_TRIALOVER_TEXT 2289 -#define IDS_FATAL_ERROR_TEXT 2290 -#define IDS_NO_MULTIPLAYER_PRIVILEGE_JOIN_TEXT 2291 -#define IDS_NO_MULTIPLAYER_PRIVILEGE_HOST_TEXT 2292 -#define IDS_NO_USER_CREATED_CONTENT_PRIVILEGE_SINGLE_LOCAL 2293 -#define IDS_NO_USER_CREATED_CONTENT_PRIVILEGE_ALL_LOCAL 2294 -#define IDS_NO_USER_CREATED_CONTENT_PRIVILEGE_REMOTE 2295 -#define IDS_NO_USER_CREATED_CONTENT_PRIVILEGE_CREATE 2296 -#define IDS_SAVE_ICON_MESSAGE 2297 -#define IDS_GAMEOPTION_HOST_PRIVILEGES 2298 -#define IDS_CHECKBOX_DISPLAY_SPLITSCREENGAMERTAGS 2299 -#define IDS_ACHIEVEMENTS 2300 -#define IDS_LABEL_GAMERTAGS 2301 -#define IDS_IN_GAME_GAMERTAGS 2302 -#define IDS_SOCIAL_DEFAULT_DESCRIPTION 2303 -#define IDS_TITLE_UPDATE_NAME 2304 -#define IDS_PLATFORM_NAME 2305 -#define IDS_BACK_BUTTON 2306 -#define IDS_HOST_OPTION_DISABLES_ACHIEVEMENTS 2307 -#define IDS_KICK_PLAYER_DESCRIPTION 2308 -#define IDS_USING_TRIAL_TEXUREPACK_WARNING 2309 -#define IDS_WORLD_SIZE_TITLE_SMALL 2310 -#define IDS_WORLD_SIZE_TITLE_MEDIUM 2311 -#define IDS_WORLD_SIZE_TITLE_LARGE 2312 -#define IDS_WORLD_SIZE_TITLE_CLASSIC 2313 -#define IDS_WORLD_SIZE 2314 -#define IDS_GAMEOPTION_WORLD_SIZE 2315 -#define IDS_DISABLE_SAVING 2316 -#define IDS_GAMEOPTION_DISABLE_SAVING 2317 -#define IDS_RICHPRESENCE_GAMESTATE 2318 -#define IDS_RICHPRESENCE_IDLE 2319 -#define IDS_RICHPRESENCE_MENUS 2320 -#define IDS_RICHPRESENCE_MULTIPLAYER 2321 -#define IDS_RICHPRESENCE_MULTIPLAYEROFFLINE 2322 -#define IDS_RICHPRESENCE_MULTIPLAYER_1P 2323 -#define IDS_RICHPRESENCE_MULTIPLAYER_1POFFLINE 2324 -#define IDS_RICHPRESENCESTATE_BLANK 2325 -#define IDS_RICHPRESENCESTATE_RIDING_PIG 2326 -#define IDS_RICHPRESENCESTATE_RIDING_MINECART 2327 -#define IDS_RICHPRESENCESTATE_BOATING 2328 -#define IDS_RICHPRESENCESTATE_FISHING 2329 -#define IDS_RICHPRESENCESTATE_CRAFTING 2330 -#define IDS_RICHPRESENCESTATE_FORGING 2331 -#define IDS_RICHPRESENCESTATE_NETHER 2332 -#define IDS_RICHPRESENCESTATE_CD 2333 -#define IDS_RICHPRESENCESTATE_MAP 2334 -#define IDS_RICHPRESENCESTATE_ENCHANTING 2335 -#define IDS_RICHPRESENCESTATE_BREWING 2336 -#define IDS_RICHPRESENCESTATE_ANVIL 2337 -#define IDS_RICHPRESENCESTATE_TRADING 2338 -#define IDS_TILE_PRISMARINE 2339 -#define IDS_TILE_PRISMARINE_DARK 2340 -#define IDS_TILE_PRISMARINE_BRICKS 2341 -#define IDS_ITEM_PRISMARINE_SHARD 2342 -#define IDS_ITEM_PRISMARINE_DESC 2343 -#define IDS_ITEM_PRISMARINE_DARK_DESC 2344 -#define IDS_ITEM_PRISMARINE_BRICK_DESC 2345 -#define IDS_ITEM_PRISMARINE_CRYSTAL_DESC 2346 -#define IDS_ITEM_PRISMARINE_SHARD_DESC 2347 -#define IDS_ITEM_RABBIT_STEW 2348 -#define IDS_TILE_DOUBLE_TALL_GRASS 2349 -#define IDS_TILE_LARGE_FERN 2350 -#define IDS_TILE_LILAC 2351 -#define IDS_TILE_ROSE_BUSH 2352 -#define IDS_TILE_PEONY 2353 -#define IDS_TILE_PACKED_ICE 2354 -#define IDS_TILE_SUNFLOWER 2355 -#define IDS_DESC_PACKED_ICE 2356 -#define IDS_DESC_RED_SANDSTONE 2357 -#define IDS_DESC_SEA_LANTERN 2358 -#define IDS_DESC_PRISMARINE 2359 -#define IDS_DESC_DOUBLE_TALL_GRASS 2360 -#define IDS_TILE_RED_SANDSTONE_CHISELED 2361 -#define IDS_TILE_RED_SANDSTONE_SMOOTH 2362 -#define IDS_WINDOWS_EXIT 2363 -#define IDS_TILE_PODZOL 2364 -#define IDS_TILE_COARSE_DIRT 2365 -#define IDS_DESC_PODZOL 2366 -#define IDS_DESC_COARSE_DIRT 2367 -#define IDS_TILE_GRANITE 2368 -#define IDS_TILE_POLISHED_GRANITE 2369 -#define IDS_TILE_ANDESITE 2370 -#define IDS_TILE_POLISHED_ANDESITE 2371 -#define IDS_TILE_DIORITE 2372 -#define IDS_TILE_POLISHED_DIORITE 2373 -#define IDS_DESC_GRANITE 2374 -#define IDS_DESC_POLISHED_GRANITE 2375 -#define IDS_DESC_ANDESITE 2376 -#define IDS_DESC_POLISHED_ANDESITE 2377 -#define IDS_DESC_DIORITE 2378 -#define IDS_DESC_POLISHED_DIORITE 2379 -#define IDS_TILE_RED_SAND 2380 -#define IDS_TILE_WET_SPONGE 2381 -#define IDS_DESC_WET_SPONGE 2382 -#define IDS_ITEM_SALMON_RAW 2383 -#define IDS_ITEM_SALMON_COOKED 2384 -#define IDS_ITEM_CLOWNFISH 2385 -#define IDS_ITEM_PUFFERFISH 2386 -#define IDS_DESC_SALMON_RAW 2387 -#define IDS_DESC_SALMON_COOKED 2388 -#define IDS_DESC_CLOWNFISH 2389 -#define IDS_DESC_PUFFERFISH 2390 -#define IDS_TILE_BLUE_ORCHID 2391 -#define IDS_TILE_ALLIUM 2392 -#define IDS_TILE_HOUSTONIA 2393 -#define IDS_TILE_TULIP_RED 2394 -#define IDS_TILE_TULIP_ORANGE 2395 -#define IDS_TILE_TULIP_WHITE 2396 -#define IDS_TILE_TULIP_PINK 2397 -#define IDS_TILE_OXEYE_DAISY 2398 -#define IDS_DESC_ROSE 2399 -#define IDS_DESC_BLUE_ORCHID 2400 -#define IDS_DESC_ALLIUM 2401 -#define IDS_DESC_HOUSTONIA 2402 -#define IDS_DESC_TULIP_RED 2403 -#define IDS_DESC_TULIP_ORANGE 2404 -#define IDS_DESC_TULIP_WHITE 2405 -#define IDS_DESC_TULIP_PINK 2406 -#define IDS_DESC_OXEYE_DAISY 2407 -#define IDS_DESC_SUNFLOWER 2408 -#define IDS_DESC_LILAC 2409 -#define IDS_DESC_LARGE_FERN 2410 -#define IDS_DESC_ROSE_BUSH 2411 -#define IDS_DESC_PEONY 2412 -#define IDS_ENDERMITE 2413 -#define IDS_ITEM_WRITTENBOOK 2414 -#define IDS_DESC_WRITTENBOOK 2415 -#define IDS_ITEM_WRITINGBOOK 2416 -#define IDS_DESC_WRITINGBOOK 2417 -#define IDS_TOOLTIPS_NEXTPAGE 2418 -#define IDS_TOOLTIPS_BACKPAGE 2419 -#define IDS_TOOLTIPS_ADDPAGE 2420 -#define IDS_TITLE_EXITBOOK 2421 -#define IDS_DESC_EXITBOOK 2422 -#define IDS_ENCHANTMENT_LURE 2423 -#define IDS_ENCHANTMENT_LUCK_OF_THE_SEA 2424 +#define IDS_WINDOWS_EXIT 2162 +#define IDS_LANG_SYSTEM 2163 +#define IDS_LANG_ENGLISH 2164 +#define IDS_LANG_GERMAN 2165 +#define IDS_LANG_SPANISH 2166 +#define IDS_LANG_SPANISH_SPAIN 2167 +#define IDS_LANG_SPANISH_LATIN_AMERICA 2168 +#define IDS_LANG_FRENCH 2169 +#define IDS_LANG_ITALIAN 2170 +#define IDS_LANG_PORTUGUESE 2171 +#define IDS_LANG_PORTUGUESE_PORTUGAL 2172 +#define IDS_LANG_PORTUGUESE_BRAZIL 2173 +#define IDS_LANG_JAPANESE 2174 +#define IDS_LANG_KOREAN 2175 +#define IDS_LANG_CHINESE_TRADITIONAL 2176 +#define IDS_LANG_CHINESE_SIMPLIFIED 2177 +#define IDS_LANG_DANISH 2178 +#define IDS_LANG_FINISH 2179 +#define IDS_LANG_DUTCH 2180 +#define IDS_LANG_POLISH 2181 +#define IDS_LANG_RUSSIAN 2182 +#define IDS_LANG_SWEDISH 2183 +#define IDS_LANG_NORWEGIAN 2184 +#define IDS_LANG_GREEK 2185 +#define IDS_LANG_TURKISH 2186 +#define IDS_LEADERBOARD_KILLS_EASY 2187 +#define IDS_LEADERBOARD_KILLS_NORMAL 2188 +#define IDS_LEADERBOARD_KILLS_HARD 2189 +#define IDS_LEADERBOARD_MINING_BLOCKS_PEACEFUL 2190 +#define IDS_LEADERBOARD_MINING_BLOCKS_EASY 2191 +#define IDS_LEADERBOARD_MINING_BLOCKS_NORMAL 2192 +#define IDS_LEADERBOARD_MINING_BLOCKS_HARD 2193 +#define IDS_LEADERBOARD_FARMING_PEACEFUL 2194 +#define IDS_LEADERBOARD_FARMING_EASY 2195 +#define IDS_LEADERBOARD_FARMING_NORMAL 2196 +#define IDS_LEADERBOARD_FARMING_HARD 2197 +#define IDS_LEADERBOARD_TRAVELLING_PEACEFUL 2198 +#define IDS_LEADERBOARD_TRAVELLING_EASY 2199 +#define IDS_LEADERBOARD_TRAVELLING_NORMAL 2200 +#define IDS_LEADERBOARD_TRAVELLING_HARD 2201 +#define IDS_TIPS_GAMETIP_0 2202 +#define IDS_TIPS_GAMETIP_1 2203 +#define IDS_TIPS_GAMETIP_48 2204 +#define IDS_TIPS_GAMETIP_44 2205 +#define IDS_TIPS_GAMETIP_45 2206 +#define IDS_TIPS_TRIVIA_4 2207 +#define IDS_TIPS_TRIVIA_17 2208 +#define IDS_HOW_TO_PLAY_MULTIPLAYER 2209 +#define IDS_HOW_TO_PLAY_SOCIALMEDIA 2210 +#define IDS_HOW_TO_PLAY_CREATIVE 2211 +#define IDS_TUTORIAL_TASK_FLY 2212 +#define IDS_TOOLTIPS_SELECTDEVICE 2213 +#define IDS_TOOLTIPS_CHANGEDEVICE 2214 +#define IDS_TOOLTIPS_VIEW_GAMERCARD 2215 +#define IDS_TOOLTIPS_VIEW_GAMERPROFILE 2216 +#define IDS_TOOLTIPS_INVITE_PARTY 2217 +#define IDS_CONFIRM_START_CREATIVE 2218 +#define IDS_CONFIRM_START_SAVEDINCREATIVE 2219 +#define IDS_CONFIRM_START_SAVEDINCREATIVE_CONTINUE 2220 +#define IDS_CONFIRM_START_HOST_PRIVILEGES 2221 +#define IDS_CONNECTION_LOST_LIVE 2222 +#define IDS_CONNECTION_LOST_LIVE_NO_EXIT 2223 +#define IDS_AWARD_AVATAR1 2224 +#define IDS_AWARD_AVATAR2 2225 +#define IDS_AWARD_AVATAR3 2226 +#define IDS_AWARD_THEME 2227 +#define IDS_UNLOCK_ACHIEVEMENT_TEXT 2228 +#define IDS_UNLOCK_AVATAR_TEXT 2229 +#define IDS_UNLOCK_GAMERPIC_TEXT 2230 +#define IDS_UNLOCK_THEME_TEXT 2231 +#define IDS_UNLOCK_ACCEPT_INVITE 2232 +#define IDS_UNLOCK_GUEST_TEXT 2233 +#define IDS_LEADERBOARD_GAMERTAG 2234 +#define IDS_GROUPNAME_POTIONS_480 2235 +#define IDS_RETURNEDTOTITLESCREEN_TEXT 2236 +#define IDS_TRIALOVER_TEXT 2237 +#define IDS_FATAL_ERROR_TEXT 2238 +#define IDS_NO_MULTIPLAYER_PRIVILEGE_JOIN_TEXT 2239 +#define IDS_NO_MULTIPLAYER_PRIVILEGE_HOST_TEXT 2240 +#define IDS_NO_USER_CREATED_CONTENT_PRIVILEGE_SINGLE_LOCAL 2241 +#define IDS_NO_USER_CREATED_CONTENT_PRIVILEGE_ALL_LOCAL 2242 +#define IDS_NO_USER_CREATED_CONTENT_PRIVILEGE_REMOTE 2243 +#define IDS_NO_USER_CREATED_CONTENT_PRIVILEGE_CREATE 2244 +#define IDS_SAVE_ICON_MESSAGE 2245 +#define IDS_GAMEOPTION_HOST_PRIVILEGES 2246 +#define IDS_CHECKBOX_DISPLAY_SPLITSCREENGAMERTAGS 2247 +#define IDS_ACHIEVEMENTS 2248 +#define IDS_LABEL_GAMERTAGS 2249 +#define IDS_IN_GAME_GAMERTAGS 2250 +#define IDS_SOCIAL_DEFAULT_DESCRIPTION 2251 +#define IDS_TITLE_UPDATE_NAME 2252 +#define IDS_PLATFORM_NAME 2253 +#define IDS_BACK_BUTTON 2254 +#define IDS_HOST_OPTION_DISABLES_ACHIEVEMENTS 2255 +#define IDS_KICK_PLAYER_DESCRIPTION 2256 +#define IDS_USING_TRIAL_TEXUREPACK_WARNING 2257 +#define IDS_WORLD_SIZE_TITLE_SMALL 2258 +#define IDS_WORLD_SIZE_TITLE_MEDIUM 2259 +#define IDS_WORLD_SIZE_TITLE_LARGE 2260 +#define IDS_WORLD_SIZE_TITLE_CLASSIC 2261 +#define IDS_WORLD_SIZE 2262 +#define IDS_GAMEOPTION_WORLD_SIZE 2263 +#define IDS_DISABLE_SAVING 2264 +#define IDS_GAMEOPTION_DISABLE_SAVING 2265 +#define IDS_RICHPRESENCE_GAMESTATE 2266 +#define IDS_RICHPRESENCE_IDLE 2267 +#define IDS_RICHPRESENCE_MENUS 2268 +#define IDS_RICHPRESENCE_MULTIPLAYER 2269 +#define IDS_RICHPRESENCE_MULTIPLAYEROFFLINE 2270 +#define IDS_RICHPRESENCE_MULTIPLAYER_1P 2271 +#define IDS_RICHPRESENCE_MULTIPLAYER_1POFFLINE 2272 +#define IDS_RICHPRESENCESTATE_BLANK 2273 +#define IDS_RICHPRESENCESTATE_RIDING_PIG 2274 +#define IDS_RICHPRESENCESTATE_RIDING_MINECART 2275 +#define IDS_RICHPRESENCESTATE_BOATING 2276 +#define IDS_RICHPRESENCESTATE_FISHING 2277 +#define IDS_RICHPRESENCESTATE_CRAFTING 2278 +#define IDS_RICHPRESENCESTATE_FORGING 2279 +#define IDS_RICHPRESENCESTATE_NETHER 2280 +#define IDS_RICHPRESENCESTATE_CD 2281 +#define IDS_RICHPRESENCESTATE_MAP 2282 +#define IDS_RICHPRESENCESTATE_ENCHANTING 2283 +#define IDS_RICHPRESENCESTATE_BREWING 2284 +#define IDS_RICHPRESENCESTATE_ANVIL 2285 +#define IDS_RICHPRESENCESTATE_TRADING 2286 +#define IDS_GAMEMODE_HARDCORE 2287 +#define IDS_HARDCORE 2288 +#define IDS_HARDCORE_TOOLTIP 2289 +#define IDS_HARDCORE_WARNING_TITLE 2290 +#define IDS_HARDCORE_WARNING_TEXT 2291 +#define IDS_HARDCORE_DEATH_MESSAGE 2292 +#define IDS_LABEL_HARDCORE 2293 +#define IDS_GAMEOPTION_HARDCORE 2294 diff --git a/Minecraft.Client/WitherBossRenderer.cpp b/Minecraft.Client/WitherBossRenderer.cpp index e358c6da..dc1d8f92 100644 --- a/Minecraft.Client/WitherBossRenderer.cpp +++ b/Minecraft.Client/WitherBossRenderer.cpp @@ -20,6 +20,10 @@ void WitherBossRenderer::render(shared_ptr entity, double x, double y, d shared_ptr mob = dynamic_pointer_cast(entity); BossMobGuiInfo::setBossHealth(mob, true); + if (!mob->getCustomName().empty()) + { + BossMobGuiInfo::name = mob->getCustomName(); + } int modelVersion = dynamic_cast(model)->modelVersion(); if (modelVersion != this->modelVersion) diff --git a/Minecraft.Client/cmake/sources/Common.cmake b/Minecraft.Client/cmake/sources/Common.cmake index 5f78d3a2..cbe3372d 100644 --- a/Minecraft.Client/cmake/sources/Common.cmake +++ b/Minecraft.Client/cmake/sources/Common.cmake @@ -414,6 +414,8 @@ source_group("Windows64/Iggy/gdraw" FILES ${_MINECRAFT_CLIENT_COMMON_WINDOWS64_I set(_MINECRAFT_CLIENT_COMMON_WINDOWS64_NETWORK "${CMAKE_CURRENT_SOURCE_DIR}/Windows64/Network/WinsockNetLayer.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Windows64/Network/WinsockNetLayer.h" + "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Server/Security/StreamCipher.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Server/Security/StreamCipher.h" ) source_group("Windows64/Network" FILES ${_MINECRAFT_CLIENT_COMMON_WINDOWS64_NETWORK}) diff --git a/Minecraft.Client/cmake/sources/Windows.cmake b/Minecraft.Client/cmake/sources/Windows.cmake index bb945280..fa499af8 100644 --- a/Minecraft.Client/cmake/sources/Windows.cmake +++ b/Minecraft.Client/cmake/sources/Windows.cmake @@ -24,6 +24,8 @@ set(_MINECRAFT_CLIENT_WINDOWS_COMMON_UI "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIBitmapFont.h" "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIController.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIController.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIUnicodeBitmapFont.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIUnicodeBitmapFont.h" "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIGroup.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIGroup.h" "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UILayer.cpp" diff --git a/Minecraft.Client/stdafx.h b/Minecraft.Client/stdafx.h index 6dda00a1..f5acd11f 100644 --- a/Minecraft.Client/stdafx.h +++ b/Minecraft.Client/stdafx.h @@ -31,6 +31,7 @@ // TODO: reference additional headers your program requires here #include #include +#include using namespace DirectX; #define HRESULT_SUCCEEDED(hr) (((HRESULT)(hr)) >= 0) @@ -221,6 +222,15 @@ typedef XUID GameSessionUID; #include "Common/Minecraft_Macros.h" #include "Common/BuildVer.h" +// Ensure fallback defines if BuildVer.h wasn't picked up by include paths +#ifndef VER_FILEVERSION_STR_W +#define VER_PRODUCTBUILD 560 +#define VER_PRODUCTVERSION_STR_W L"DEV (unknown version)" +#define VER_FILEVERSION_STR_W VER_PRODUCTVERSION_STR_W +#define VER_BRANCHVERSION_STR_W L"UNKNOWN BRANCH" +#define VER_NETWORK VER_PRODUCTBUILD +#endif + #ifdef _XBOX #include "Xbox/Xbox_App.h" #include "Xbox/Sentient/MinecraftTelemetry.h" diff --git a/Minecraft.Server/Access/Access.cpp b/Minecraft.Server/Access/Access.cpp index 9f3f7acc..5bf12bbd 100644 --- a/Minecraft.Server/Access/Access.cpp +++ b/Minecraft.Server/Access/Access.cpp @@ -27,6 +27,7 @@ namespace ServerRuntime std::mutex writeLock; std::shared_ptr banManager; std::shared_ptr whitelistManager; + std::shared_ptr opManager; bool whitelistEnabled = false; }; @@ -63,6 +64,18 @@ namespace ServerRuntime std::lock_guard stateLock(g_accessState.stateLock); g_accessState.whitelistManager = whitelistManager; } + + static std::shared_ptr GetOpManagerSnapshot() + { + std::lock_guard stateLock(g_accessState.stateLock); + return g_accessState.opManager; + } + + static void PublishOpManagerSnapshot(const std::shared_ptr &opManager) + { + std::lock_guard stateLock(g_accessState.stateLock); + g_accessState.opManager = opManager; + } } std::string FormatXuid(PlayerUID xuid) @@ -101,6 +114,7 @@ namespace ServerRuntime // Build the replacement manager privately so readers keep using the last published snapshot during disk I/O. std::shared_ptr banManager = std::make_shared(baseDirectory); std::shared_ptr whitelistManager = std::make_shared(baseDirectory); + std::shared_ptr opManager = std::make_shared(baseDirectory); if (!banManager->EnsureBanFilesExist()) { LogError("access", "failed to ensure dedicated server ban files exist"); @@ -111,6 +125,11 @@ namespace ServerRuntime LogError("access", "failed to ensure dedicated server whitelist file exists"); return false; } + if (!opManager->EnsureOpFileExists()) + { + LogError("access", "failed to ensure dedicated server ops file exists"); + return false; + } if (!banManager->Reload()) { @@ -122,15 +141,23 @@ namespace ServerRuntime LogError("access", "failed to load dedicated server whitelist file"); return false; } + if (!opManager->Reload()) + { + LogError("access", "failed to load dedicated server ops file"); + return false; + } std::vector playerEntries; std::vector ipEntries; std::vector whitelistEntries; + std::vector opEntries; banManager->SnapshotBannedPlayers(&playerEntries); banManager->SnapshotBannedIps(&ipEntries); whitelistManager->SnapshotWhitelistedPlayers(&whitelistEntries); + opManager->SnapshotOps(&opEntries); PublishBanManagerSnapshot(banManager); PublishWhitelistManagerSnapshot(whitelistManager); + PublishOpManagerSnapshot(opManager); { std::lock_guard stateLock(g_accessState.stateLock); g_accessState.whitelistEnabled = whitelistEnabled; @@ -138,10 +165,11 @@ namespace ServerRuntime LogInfof( "access", - "loaded %u player bans, %u ip bans, and %u whitelist entries (whitelist=%s)", + "loaded %u player bans, %u ip bans, %u whitelist entries, and %u ops (whitelist=%s)", (unsigned)playerEntries.size(), (unsigned)ipEntries.size(), (unsigned)whitelistEntries.size(), + (unsigned)opEntries.size(), whitelistEnabled ? "enabled" : "disabled"); return true; } @@ -151,6 +179,7 @@ namespace ServerRuntime std::lock_guard writeLock(g_accessState.writeLock); PublishBanManagerSnapshot(std::shared_ptr{}); PublishWhitelistManagerSnapshot(std::shared_ptr{}); + PublishOpManagerSnapshot(std::shared_ptr{}); std::lock_guard stateLock(g_accessState.stateLock); g_accessState.whitelistEnabled = false; } @@ -214,7 +243,9 @@ namespace ServerRuntime bool IsInitialized() { - return GetBanManagerSnapshot() != nullptr && GetWhitelistManagerSnapshot() != nullptr; + return GetBanManagerSnapshot() != nullptr + && GetWhitelistManagerSnapshot() != nullptr + && GetOpManagerSnapshot() != nullptr; } bool IsWhitelistEnabled() @@ -456,5 +487,111 @@ namespace ServerRuntime return whitelistManager->SnapshotWhitelistedPlayers(outEntries); } + + bool IsPlayerOp(PlayerUID xuid) + { + const std::string formatted = FormatXuid(xuid); + if (formatted.empty()) + { + return false; + } + + std::shared_ptr opManager = GetOpManagerSnapshot(); + return (opManager != nullptr) ? opManager->IsPlayerOp(formatted) : false; + } + + bool AddOp(PlayerUID xuid, const std::string &name, const OpMetadata &metadata) + { + const std::string formatted = FormatXuid(xuid); + if (formatted.empty()) + { + return false; + } + + std::lock_guard writeLock(g_accessState.writeLock); + std::shared_ptr current = GetOpManagerSnapshot(); + if (current == nullptr) + { + return false; + } + + auto opManager = std::make_shared(*current); + OpPlayerEntry entry; + entry.xuid = formatted; + entry.name = name; + entry.metadata = metadata; + if (!opManager->AddOp(entry)) + { + return false; + } + + PublishOpManagerSnapshot(opManager); + return true; + } + + bool RemoveOp(PlayerUID xuid) + { + const std::string formatted = FormatXuid(xuid); + if (formatted.empty()) + { + return false; + } + + std::lock_guard writeLock(g_accessState.writeLock); + std::shared_ptr current = GetOpManagerSnapshot(); + if (current == nullptr) + { + return false; + } + + auto opManager = std::make_shared(*current); + if (!opManager->RemoveOpByXuid(formatted)) + { + return false; + } + + PublishOpManagerSnapshot(opManager); + return true; + } + + bool ReloadOps() + { + std::lock_guard writeLock(g_accessState.writeLock); + const auto current = GetOpManagerSnapshot(); + if (current == nullptr) + { + return false; + } + + auto opManager = std::make_shared(*current); + if (!opManager->EnsureOpFileExists()) + { + return false; + } + if (!opManager->Reload()) + { + return false; + } + + PublishOpManagerSnapshot(opManager); + return true; + } + + bool SnapshotOps(std::vector *outEntries) + { + if (outEntries == nullptr) + { + return false; + } + + const auto opManager = GetOpManagerSnapshot(); + if (opManager == nullptr) + { + outEntries->clear(); + return false; + } + + return opManager->SnapshotOps(outEntries); + } } } diff --git a/Minecraft.Server/Access/Access.h b/Minecraft.Server/Access/Access.h index 80e61e55..17e66699 100644 --- a/Minecraft.Server/Access/Access.h +++ b/Minecraft.Server/Access/Access.h @@ -2,6 +2,7 @@ #include "BanManager.h" #include "WhitelistManager.h" +#include "OpManager.h" namespace ServerRuntime { @@ -14,6 +15,7 @@ namespace ServerRuntime void Shutdown(); bool Reload(); bool ReloadWhitelist(); + bool ReloadOps(); bool IsInitialized(); bool IsWhitelistEnabled(); void SetWhitelistEnabled(bool enabled); @@ -21,6 +23,7 @@ namespace ServerRuntime bool IsPlayerBanned(PlayerUID xuid); bool IsIpBanned(const std::string &ip); bool IsPlayerWhitelisted(PlayerUID xuid); + bool IsPlayerOp(PlayerUID xuid); bool AddPlayerBan(PlayerUID xuid, const std::string &name, const BanMetadata &metadata); bool AddIpBan(const std::string &ip, const BanMetadata &metadata); @@ -28,6 +31,8 @@ namespace ServerRuntime bool RemoveIpBan(const std::string &ip); bool AddWhitelistedPlayer(PlayerUID xuid, const std::string &name, const WhitelistMetadata &metadata); bool RemoveWhitelistedPlayer(PlayerUID xuid); + bool AddOp(PlayerUID xuid, const std::string &name, const OpMetadata &metadata); + bool RemoveOp(PlayerUID xuid); /** * Copies the current cached player bans for inspection or command output @@ -40,6 +45,7 @@ namespace ServerRuntime */ bool SnapshotBannedIps(std::vector *outEntries); bool SnapshotWhitelistedPlayers(std::vector *outEntries); + bool SnapshotOps(std::vector *outEntries); std::string FormatXuid(PlayerUID xuid); bool TryParseXuid(const std::string &text, PlayerUID *outXuid); diff --git a/Minecraft.Server/Access/OpManager.cpp b/Minecraft.Server/Access/OpManager.cpp new file mode 100644 index 00000000..b8b76d1e --- /dev/null +++ b/Minecraft.Server/Access/OpManager.cpp @@ -0,0 +1,284 @@ +#include "stdafx.h" + +#include "OpManager.h" + +#include "..\Common\AccessStorageUtils.h" +#include "..\Common\FileUtils.h" +#include "..\Common\StringUtils.h" +#include "..\ServerLogger.h" +#include "..\vendor\nlohmann\json.hpp" + +#include + +namespace ServerRuntime +{ + namespace Access + { + using OrderedJson = nlohmann::ordered_json; + + namespace + { + static const char *kOpFileName = "ops.json"; + } + + OpManager::OpManager(const std::string &baseDirectory) + : m_baseDirectory(baseDirectory.empty() ? "." : baseDirectory) + { + } + + bool OpManager::EnsureOpFileExists() const + { + const std::string path = GetOpFilePath(); + if (!AccessStorageUtils::EnsureJsonListFileExists(path)) + { + LogErrorf("access", "failed to create %s", path.c_str()); + return false; + } + return true; + } + + bool OpManager::Reload() + { + std::vector ops; + if (!LoadOps(&ops)) + { + return false; + } + + m_ops.swap(ops); + return true; + } + + bool OpManager::Save() const + { + std::vector ops; + return SnapshotOps(&ops) && SaveOps(ops); + } + + bool OpManager::LoadOps(std::vector *outEntries) const + { + if (outEntries == nullptr) + { + return false; + } + outEntries->clear(); + + std::string text; + const std::string path = GetOpFilePath(); + if (!FileUtils::ReadTextFile(path, &text)) + { + LogErrorf("access", "failed to read %s", path.c_str()); + return false; + } + + if (text.empty()) + { + text = "[]"; + } + + OrderedJson root; + try + { + root = OrderedJson::parse(StringUtils::StripUtf8Bom(text)); + } + catch (const nlohmann::json::exception &e) + { + LogErrorf("access", "failed to parse %s: %s", path.c_str(), e.what()); + return false; + } + + if (!root.is_array()) + { + LogErrorf("access", "failed to parse %s: root json value is not an array", path.c_str()); + return false; + } + + for (const auto &object : root) + { + if (!object.is_object()) + { + LogWarnf("access", "skipping op entry that is not an object in %s", path.c_str()); + continue; + } + + std::string rawXuid; + if (!AccessStorageUtils::TryGetStringField(object, "xuid", &rawXuid)) + { + LogWarnf("access", "skipping op entry without xuid in %s", path.c_str()); + continue; + } + + OpPlayerEntry entry; + entry.xuid = AccessStorageUtils::NormalizeXuid(rawXuid); + if (entry.xuid.empty()) + { + LogWarnf("access", "skipping op entry with empty xuid in %s", path.c_str()); + continue; + } + + AccessStorageUtils::TryGetStringField(object, "name", &entry.name); + AccessStorageUtils::TryGetStringField(object, "created", &entry.metadata.created); + AccessStorageUtils::TryGetStringField(object, "source", &entry.metadata.source); + + outEntries->push_back(entry); + } + + return true; + } + + bool OpManager::SaveOps(const std::vector &entries) const + { + OrderedJson root = OrderedJson::array(); + for (const auto &entry : entries) + { + OrderedJson object = OrderedJson::object(); + object["xuid"] = AccessStorageUtils::NormalizeXuid(entry.xuid); + object["name"] = entry.name; + object["created"] = entry.metadata.created; + object["source"] = entry.metadata.source; + root.push_back(object); + } + + const std::string path = GetOpFilePath(); + const std::string json = root.empty() ? std::string("[]\n") : (root.dump(2) + "\n"); + if (!FileUtils::WriteTextFileAtomic(path, json)) + { + LogErrorf("access", "failed to write %s", path.c_str()); + return false; + } + return true; + } + + const std::vector &OpManager::GetOps() const + { + return m_ops; + } + + bool OpManager::SnapshotOps(std::vector *outEntries) const + { + if (outEntries == nullptr) + { + return false; + } + + *outEntries = m_ops; + return true; + } + + bool OpManager::IsPlayerOp(const std::string &xuid) const + { + const auto normalized = AccessStorageUtils::NormalizeXuid(xuid); + if (normalized.empty()) + { + return false; + } + + return std::any_of( + m_ops.begin(), + m_ops.end(), + [&normalized](const OpPlayerEntry &entry) + { + return entry.xuid == normalized; + }); + } + + bool OpManager::AddOp(const OpPlayerEntry &entry) + { + std::vector updatedEntries; + if (!SnapshotOps(&updatedEntries)) + { + return false; + } + + auto normalized = entry; + normalized.xuid = AccessStorageUtils::NormalizeXuid(normalized.xuid); + if (normalized.xuid.empty()) + { + return false; + } + + const auto existing = std::find_if( + updatedEntries.begin(), + updatedEntries.end(), + [&normalized](const OpPlayerEntry &candidate) + { + return candidate.xuid == normalized.xuid; + }); + + if (existing != updatedEntries.end()) + { + *existing = normalized; + if (!SaveOps(updatedEntries)) + { + return false; + } + + m_ops.swap(updatedEntries); + return true; + } + + updatedEntries.push_back(normalized); + if (!SaveOps(updatedEntries)) + { + return false; + } + + m_ops.swap(updatedEntries); + return true; + } + + bool OpManager::RemoveOpByXuid(const std::string &xuid) + { + const auto normalized = AccessStorageUtils::NormalizeXuid(xuid); + if (normalized.empty()) + { + return false; + } + + std::vector updatedEntries; + if (!SnapshotOps(&updatedEntries)) + { + return false; + } + + const auto oldSize = updatedEntries.size(); + updatedEntries.erase( + std::remove_if( + updatedEntries.begin(), + updatedEntries.end(), + [&normalized](const OpPlayerEntry &entry) { return entry.xuid == normalized; }), + updatedEntries.end()); + + if (updatedEntries.size() == oldSize) + { + return false; + } + + if (!SaveOps(updatedEntries)) + { + return false; + } + + m_ops.swap(updatedEntries); + return true; + } + + std::string OpManager::GetOpFilePath() const + { + return BuildPath(kOpFileName); + } + + OpMetadata OpManager::BuildDefaultMetadata(const char *source) + { + OpMetadata metadata; + metadata.created = StringUtils::GetCurrentUtcTimestampIso8601(); + metadata.source = (source != nullptr) ? source : "Server"; + return metadata; + } + + std::string OpManager::BuildPath(const char *fileName) const + { + return AccessStorageUtils::BuildPathFromBaseDirectory(m_baseDirectory, fileName); + } + } +} diff --git a/Minecraft.Server/Access/OpManager.h b/Minecraft.Server/Access/OpManager.h new file mode 100644 index 00000000..22505f49 --- /dev/null +++ b/Minecraft.Server/Access/OpManager.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +namespace ServerRuntime +{ + namespace Access + { + struct OpMetadata + { + std::string created; + std::string source; + }; + + struct OpPlayerEntry + { + std::string xuid; + std::string name; + OpMetadata metadata; + }; + + /** + * Persistent OP (operator) list manager. + * + * Stores XUID-based operator entries in `ops.json`. + * Used as the authoritative source of truth for who has OP privileges, + * preventing in-memory-only OP escalation via crafted packets. + */ + class OpManager + { + public: + explicit OpManager(const std::string &baseDirectory = "."); + + bool EnsureOpFileExists() const; + bool Reload(); + bool Save() const; + + bool LoadOps(std::vector *outEntries) const; + bool SaveOps(const std::vector &entries) const; + + const std::vector &GetOps() const; + bool SnapshotOps(std::vector *outEntries) const; + + bool IsPlayerOp(const std::string &xuid) const; + bool AddOp(const OpPlayerEntry &entry); + bool RemoveOpByXuid(const std::string &xuid); + + std::string GetOpFilePath() const; + + static OpMetadata BuildDefaultMetadata(const char *source = "Server"); + + private: + std::string BuildPath(const char *fileName) const; + + private: + std::string m_baseDirectory; + std::vector m_ops; + }; + } +} diff --git a/Minecraft.Server/CMakeLists.txt b/Minecraft.Server/CMakeLists.txt index 52e5826e..a384f7b8 100644 --- a/Minecraft.Server/CMakeLists.txt +++ b/Minecraft.Server/CMakeLists.txt @@ -38,6 +38,7 @@ set_target_properties(Minecraft.Server PROPERTIES target_link_libraries(Minecraft.Server PRIVATE Minecraft.World d3d11 + dxgi d3dcompiler XInput9_1_0 wsock32 diff --git a/Minecraft.Server/Console/ServerCliEngine.cpp b/Minecraft.Server/Console/ServerCliEngine.cpp index b4c791ff..f1569607 100644 --- a/Minecraft.Server/Console/ServerCliEngine.cpp +++ b/Minecraft.Server/Console/ServerCliEngine.cpp @@ -23,6 +23,7 @@ #include "commands/tp/CliCommandTp.h" #include "commands/weather/CliCommandWeather.h" #include "commands/whitelist/CliCommandWhitelist.h" +#include "commands/revoketoken/CliCommandRevokeToken.h" #include "../Common/StringUtils.h" #include "../ServerShutdown.h" #include "../ServerLogger.h" @@ -100,6 +101,7 @@ namespace ServerRuntime m_registry->Register(std::unique_ptr(new CliCommandPardonIp())); m_registry->Register(std::unique_ptr(new CliCommandBanList())); m_registry->Register(std::unique_ptr(new CliCommandWhitelist())); + m_registry->Register(std::unique_ptr(new CliCommandRevokeToken())); m_registry->Register(std::unique_ptr(new CliCommandTp())); m_registry->Register(std::unique_ptr(new CliCommandTime())); m_registry->Register(std::unique_ptr(new CliCommandWeather())); diff --git a/Minecraft.Server/Console/commands/revoketoken/CliCommandRevokeToken.cpp b/Minecraft.Server/Console/commands/revoketoken/CliCommandRevokeToken.cpp new file mode 100644 index 00000000..9388b184 --- /dev/null +++ b/Minecraft.Server/Console/commands/revoketoken/CliCommandRevokeToken.cpp @@ -0,0 +1,85 @@ +#include "stdafx.h" + +#include "CliCommandRevokeToken.h" + +#include "..\..\ServerCliEngine.h" +#include "..\..\ServerCliParser.h" +#include "..\..\..\Access\Access.h" +#include "..\..\..\Security\IdentityTokenManager.h" +#include "..\..\..\ServerLogManager.h" + +namespace ServerRuntime +{ + const char *CliCommandRevokeToken::Name() const + { + return "revoketoken"; + } + + const char *CliCommandRevokeToken::Usage() const + { + return "revoketoken "; + } + + const char *CliCommandRevokeToken::Description() const + { + return "Revoke a player's identity token. They will be issued a new one on next login."; + } + + bool CliCommandRevokeToken::Execute(const ServerCliParsedLine &line, ServerCliEngine *engine) + { + if (line.tokens.size() < 2) + { + engine->LogWarn(std::string("Usage: ") + Usage()); + return false; + } + + PlayerUID xuid = INVALID_XUID; + + // Try parsing as XUID first + if (ServerRuntime::Access::TryParseXuid(line.tokens[1], &xuid)) + { + // Direct XUID + } + else + { + // Try name lookup from cache + std::vector cachedXuids; + int count = ServerRuntime::ServerLogManager::GetCachedXuids(line.tokens[1], &cachedXuids); + if (count == 0) + { + engine->LogWarn("Unknown player: " + line.tokens[1]); + engine->LogWarn("The player must have attempted to connect, or use: revoketoken "); + return false; + } + if (count > 1) + { + engine->LogWarn("Ambiguous: " + std::to_string(count) + " XUIDs seen for '" + line.tokens[1] + "':"); + for (size_t i = 0; i < cachedXuids.size(); ++i) + { + std::string label = (i == cachedXuids.size() - 1) ? " (most recent)" : ""; + engine->LogWarn(" " + ServerRuntime::Access::FormatXuid(cachedXuids[i]) + label); + } + engine->LogWarn("Re-run with the explicit XUID: revoketoken "); + return false; + } + xuid = cachedXuids.back(); + engine->LogInfo("Resolved '" + line.tokens[1] + "' to XUID " + ServerRuntime::Access::FormatXuid(xuid)); + } + + if (!ServerRuntime::Security::GetIdentityTokenManager().HasToken(xuid)) + { + engine->LogWarn("No identity token found for XUID " + ServerRuntime::Access::FormatXuid(xuid)); + return false; + } + + if (!ServerRuntime::Security::GetIdentityTokenManager().RevokeToken(xuid)) + { + engine->LogError("Failed to revoke token."); + return false; + } + + engine->LogInfo("Revoked identity token for XUID " + ServerRuntime::Access::FormatXuid(xuid) + + ". Player will receive a new token on next login."); + return true; + } +} diff --git a/Minecraft.Server/Console/commands/revoketoken/CliCommandRevokeToken.h b/Minecraft.Server/Console/commands/revoketoken/CliCommandRevokeToken.h new file mode 100644 index 00000000..89cf7ad1 --- /dev/null +++ b/Minecraft.Server/Console/commands/revoketoken/CliCommandRevokeToken.h @@ -0,0 +1,15 @@ +#pragma once + +#include "..\IServerCliCommand.h" + +namespace ServerRuntime +{ + class CliCommandRevokeToken : public IServerCliCommand + { + public: + virtual const char *Name() const; + virtual const char *Usage() const; + virtual const char *Description() const; + virtual bool Execute(const ServerCliParsedLine &line, ServerCliEngine *engine); + }; +} diff --git a/Minecraft.Server/Console/commands/whitelist/CliCommandWhitelist.cpp b/Minecraft.Server/Console/commands/whitelist/CliCommandWhitelist.cpp index 33f7b357..ae6e0204 100644 --- a/Minecraft.Server/Console/commands/whitelist/CliCommandWhitelist.cpp +++ b/Minecraft.Server/Console/commands/whitelist/CliCommandWhitelist.cpp @@ -7,6 +7,7 @@ #include "../../../Access/Access.h" #include "../../../Common/StringUtils.h" #include "../../../ServerProperties.h" +#include "../../../ServerLogManager.h" #include #include @@ -181,14 +182,44 @@ namespace ServerRuntime { if (line.tokens.size() < 3) { - engine->LogWarn("Usage: whitelist add [name ...]"); + engine->LogWarn("Usage: whitelist add [display name ...]"); return false; } PlayerUID xuid = INVALID_XUID; - if (!TryParseWhitelistXuid(line.tokens[2], engine, &xuid)) + std::string name; + + if (ServerRuntime::Access::TryParseXuid(line.tokens[2], &xuid)) { - return false; + // Argument is a XUID + name = StringUtils::JoinTokens(line.tokens, 3); + } + else + { + // Argument is a player name -- look up XUID from recent login cache + std::vector cachedXuids; + int count = ServerRuntime::ServerLogManager::GetCachedXuids(line.tokens[2], &cachedXuids); + if (count == 0) + { + engine->LogWarn("Unknown player: " + line.tokens[2]); + engine->LogWarn("The player must attempt to connect first so the server can learn their XUID."); + engine->LogWarn("Alternatively, use: whitelist add "); + return false; + } + if (count > 1) + { + engine->LogWarn("Ambiguous: " + std::to_string(count) + " different XUIDs have been seen for '" + line.tokens[2] + "':"); + for (size_t i = 0; i < cachedXuids.size(); ++i) + { + std::string label = (i == cachedXuids.size() - 1) ? " (most recent)" : ""; + engine->LogWarn(" " + ServerRuntime::Access::FormatXuid(cachedXuids[i]) + label); + } + engine->LogWarn("Re-run with the explicit XUID: whitelist add [name]"); + return false; + } + xuid = cachedXuids.back(); + name = line.tokens[2]; + engine->LogInfo("Resolved '" + name + "' to XUID " + ServerRuntime::Access::FormatXuid(xuid)); } if (ServerRuntime::Access::IsPlayerWhitelisted(xuid)) @@ -198,7 +229,6 @@ namespace ServerRuntime } const auto metadata = ServerRuntime::Access::WhitelistManager::BuildDefaultMetadata("Console"); - const auto name = StringUtils::JoinTokens(line.tokens, 3); if (!ServerRuntime::Access::AddWhitelistedPlayer(xuid, name, metadata)) { engine->LogError("Failed to write whitelist entry."); diff --git a/Minecraft.Server/Security/CipherHandshakeEnforcer.cpp b/Minecraft.Server/Security/CipherHandshakeEnforcer.cpp new file mode 100644 index 00000000..2052f94c --- /dev/null +++ b/Minecraft.Server/Security/CipherHandshakeEnforcer.cpp @@ -0,0 +1,60 @@ +#include "stdafx.h" +#include "CipherHandshakeEnforcer.h" +#include "ConnectionCipher.h" + +namespace ServerRuntime +{ + namespace Security + { + CipherHandshakeEnforcer::CipherHandshakeEnforcer() + { + memset(m_sentTick, 0, sizeof(m_sentTick)); + memset(m_tracked, 0, sizeof(m_tracked)); + } + + CipherHandshakeEnforcer::~CipherHandshakeEnforcer() + { + } + + void CipherHandshakeEnforcer::OnCipherKeySent(unsigned char smallId, unsigned int currentTick) + { + m_sentTick[smallId] = currentTick; + m_tracked[smallId] = true; + } + + void CipherHandshakeEnforcer::CheckTimeouts(unsigned int currentTick, + std::vector &outExpired, + std::vector &outCompleted) + { + auto ®istry = GetCipherRegistry(); + + for (int i = 0; i < MAX_CONNECTIONS; ++i) + { + if (!m_tracked[i]) + continue; + + if (registry.IsCipherActive(static_cast(i))) + { + outCompleted.push_back(static_cast(i)); + m_tracked[i] = false; + } + else if ((currentTick - m_sentTick[i]) > static_cast(kGraceTicks)) + { + outExpired.push_back(static_cast(i)); + m_tracked[i] = false; + } + } + } + + void CipherHandshakeEnforcer::OnDisconnected(unsigned char smallId) + { + m_tracked[smallId] = false; + } + + CipherHandshakeEnforcer &GetHandshakeEnforcer() + { + static CipherHandshakeEnforcer s_instance; + return s_instance; + } + } +} diff --git a/Minecraft.Server/Security/CipherHandshakeEnforcer.h b/Minecraft.Server/Security/CipherHandshakeEnforcer.h new file mode 100644 index 00000000..77baed3c --- /dev/null +++ b/Minecraft.Server/Security/CipherHandshakeEnforcer.h @@ -0,0 +1,64 @@ +#pragma once + +#ifdef _WINDOWS64 +#include +#endif + +#include + +namespace ServerRuntime +{ + namespace Security + { + /** + * Tracks pending cipher handshakes and kicks clients that don't complete + * within the grace period. + * + * When require-secure-client is enabled, old/unpatched clients that ignore + * MC|CKey are disconnected before they receive any PlayerInfoPacket data + * containing other players' XUIDs. + * + * Called from the main tick thread only (PlayerList::tick). + */ + class CipherHandshakeEnforcer + { + public: + // 5 seconds at 20 TPS. The security gate buffers all game data until + // cipher completes, so no data leaks regardless of grace period length. + // 5 seconds accommodates high-latency connections. + static const int kGraceTicks = 100; + + CipherHandshakeEnforcer(); + ~CipherHandshakeEnforcer(); + + CipherHandshakeEnforcer(const CipherHandshakeEnforcer &) = delete; + CipherHandshakeEnforcer &operator=(const CipherHandshakeEnforcer &) = delete; + + /** + * Register that MC|CKey was sent to this smallId at the given tick. + */ + void OnCipherKeySent(unsigned char smallId, unsigned int currentTick); + + /** + * Check for timed-out handshakes. Returns smallIds that exceeded the + * grace period without the cipher becoming active. Also returns + * smallIds that just completed (cipher became active) in outCompleted. + */ + void CheckTimeouts(unsigned int currentTick, + std::vector &outExpired, + std::vector &outCompleted); + + /** + * Clean up tracking for a disconnected connection. + */ + void OnDisconnected(unsigned char smallId); + + private: + static const int MAX_CONNECTIONS = 256; + unsigned int m_sentTick[MAX_CONNECTIONS]; // 0 = not tracked + bool m_tracked[MAX_CONNECTIONS]; + }; + + CipherHandshakeEnforcer &GetHandshakeEnforcer(); + } +} diff --git a/Minecraft.Server/Security/ConnectionCipher.cpp b/Minecraft.Server/Security/ConnectionCipher.cpp new file mode 100644 index 00000000..58e2a79c --- /dev/null +++ b/Minecraft.Server/Security/ConnectionCipher.cpp @@ -0,0 +1,115 @@ +#include "stdafx.h" +#include "ConnectionCipher.h" + +#include + +namespace ServerRuntime +{ + namespace Security + { + ConnectionCipherRegistry::ConnectionCipherRegistry() + { + InitializeCriticalSection(&m_lock); + memset(m_pending, 0, sizeof(m_pending)); + memset(m_pendingKeys, 0, sizeof(m_pendingKeys)); + } + + ConnectionCipherRegistry::~ConnectionCipherRegistry() + { + SecureZeroMemory(m_pendingKeys, sizeof(m_pendingKeys)); + DeleteCriticalSection(&m_lock); + } + + bool ConnectionCipherRegistry::PrepareKey(unsigned char smallId, uint8_t outKey[StreamCipher::KEY_SIZE]) + { + uint8_t key[StreamCipher::KEY_SIZE]; + if (!StreamCipher::GenerateKey(key)) + { + return false; + } + + EnterCriticalSection(&m_lock); + memcpy(m_pendingKeys[smallId], key, StreamCipher::KEY_SIZE); + m_pending[smallId] = true; + LeaveCriticalSection(&m_lock); + + memcpy(outKey, key, StreamCipher::KEY_SIZE); + SecureZeroMemory(key, sizeof(key)); + return true; + } + + bool ConnectionCipherRegistry::CommitCipher(unsigned char smallId) + { + EnterCriticalSection(&m_lock); + if (!m_pending[smallId]) + { + LeaveCriticalSection(&m_lock); + return false; + } + + m_ciphers[smallId].Initialize(m_pendingKeys[smallId]); + SecureZeroMemory(m_pendingKeys[smallId], StreamCipher::KEY_SIZE); + m_pending[smallId] = false; + LeaveCriticalSection(&m_lock); + return true; + } + + void ConnectionCipherRegistry::CancelPending(unsigned char smallId) + { + EnterCriticalSection(&m_lock); + SecureZeroMemory(m_pendingKeys[smallId], StreamCipher::KEY_SIZE); + m_pending[smallId] = false; + LeaveCriticalSection(&m_lock); + } + + bool ConnectionCipherRegistry::HasPendingKey(unsigned char smallId) const + { + EnterCriticalSection(&m_lock); + bool pending = m_pending[smallId]; + LeaveCriticalSection(&m_lock); + return pending; + } + + void ConnectionCipherRegistry::DeactivateCipher(unsigned char smallId) + { + EnterCriticalSection(&m_lock); + m_ciphers[smallId].Reset(); + SecureZeroMemory(m_pendingKeys[smallId], StreamCipher::KEY_SIZE); + m_pending[smallId] = false; + LeaveCriticalSection(&m_lock); + } + + bool ConnectionCipherRegistry::TryEncryptOutgoing(unsigned char smallId, uint8_t *data, int length) + { + EnterCriticalSection(&m_lock); + bool active = m_ciphers[smallId].IsActive(); + if (active) + { + m_ciphers[smallId].Encrypt(data, length); + } + LeaveCriticalSection(&m_lock); + return active; + } + + bool ConnectionCipherRegistry::IsCipherActive(unsigned char smallId) const + { + EnterCriticalSection(&m_lock); + bool active = m_ciphers[smallId].IsActive(); + LeaveCriticalSection(&m_lock); + return active; + } + + void ConnectionCipherRegistry::DecryptIncoming(unsigned char smallId, uint8_t *data, int length) + { + EnterCriticalSection(&m_lock); + m_ciphers[smallId].Decrypt(data, length); + LeaveCriticalSection(&m_lock); + } + + ConnectionCipherRegistry &GetCipherRegistry() + { + static ConnectionCipherRegistry s_instance; + return s_instance; + } + } +} diff --git a/Minecraft.Server/Security/ConnectionCipher.h b/Minecraft.Server/Security/ConnectionCipher.h new file mode 100644 index 00000000..bd3c294c --- /dev/null +++ b/Minecraft.Server/Security/ConnectionCipher.h @@ -0,0 +1,97 @@ +#pragma once + +#include "StreamCipher.h" + +#ifdef _WINDOWS64 +#include +#endif + +namespace ServerRuntime +{ + namespace Security + { + /** + * Per-connection cipher registry for the dedicated server. + * + * Handshake protocol (4-message, via CustomPayloadPacket): + * 1. Server calls PrepareKey(smallId) -> sends MC|CKey with key to client + * 2. Client stores key, sends MC|CAck, activates send cipher + * 3. Server recv thread detects MC|CAck -> calls SendCOnAndCommit which + * atomically sends MC|COn plaintext then calls CommitCipher(smallId) + * 4. Client recv thread detects MC|COn -> activates recv cipher + * + * Backwards compatible: old clients ignore MC|CKey, server never gets ack, + * cipher stays inactive. Old servers never send MC|CKey, client stays plaintext. + */ + class ConnectionCipherRegistry + { + public: + ConnectionCipherRegistry(); + ~ConnectionCipherRegistry(); + + ConnectionCipherRegistry(const ConnectionCipherRegistry &) = delete; + ConnectionCipherRegistry &operator=(const ConnectionCipherRegistry &) = delete; + ConnectionCipherRegistry(ConnectionCipherRegistry &&) = delete; + ConnectionCipherRegistry &operator=(ConnectionCipherRegistry &&) = delete; + + /** + * Generate a random key and store it in pending state for the given smallId. + * Does NOT activate the cipher. Call CommitCipher() after the client acks. + * Returns the generated key in outKey. + */ + bool PrepareKey(unsigned char smallId, uint8_t outKey[StreamCipher::KEY_SIZE]); + + /** + * Activate a previously prepared cipher. Called from the recv thread + * when the client's MC|CAck is detected by raw byte matching. + * Returns false if no key was pending for this smallId. + */ + bool CommitCipher(unsigned char smallId); + + /** + * Cancel a pending key (e.g., client disconnected before ack). + */ + void CancelPending(unsigned char smallId); + + /** + * Check if a key is pending for the given smallId (no side effects). + */ + bool HasPendingKey(unsigned char smallId) const; + + /** + * Deactivate the cipher and cancel any pending key for a disconnected connection. + */ + void DeactivateCipher(unsigned char smallId); + + /** + * Atomically check if cipher is active and encrypt outgoing data. + * Returns true if data was encrypted, false if cipher is inactive (data untouched). + */ + bool TryEncryptOutgoing(unsigned char smallId, uint8_t *data, int length); + + /** + * Check if the cipher is active (handshake completed) for a given smallId. + * Thread-safe, read-only query. + */ + bool IsCipherActive(unsigned char smallId) const; + + /** + * Decrypt incoming data from a specific connection. + * No-op if the cipher is not active for this connection. + */ + void DecryptIncoming(unsigned char smallId, uint8_t *data, int length); + + private: + static const int MAX_CONNECTIONS = 256; + StreamCipher m_ciphers[MAX_CONNECTIONS]; + bool m_pending[MAX_CONNECTIONS]; + uint8_t m_pendingKeys[MAX_CONNECTIONS][StreamCipher::KEY_SIZE]; + mutable CRITICAL_SECTION m_lock; + }; + + /** + * Global cipher registry singleton. + */ + ConnectionCipherRegistry &GetCipherRegistry(); + } +} diff --git a/Minecraft.Server/Security/IdentityTokenManager.cpp b/Minecraft.Server/Security/IdentityTokenManager.cpp new file mode 100644 index 00000000..f20a3d85 --- /dev/null +++ b/Minecraft.Server/Security/IdentityTokenManager.cpp @@ -0,0 +1,288 @@ +#include "stdafx.h" +#include "IdentityTokenManager.h" + +#ifdef _WINDOWS64 +#include +#endif + +#include "..\Common\FileUtils.h" +#include "..\Common\StringUtils.h" +#include "..\ServerLogger.h" +#include "..\vendor\nlohmann\json.hpp" + +#include + +namespace ServerRuntime +{ + namespace Security + { + using OrderedJson = nlohmann::ordered_json; + + IdentityTokenManager::IdentityTokenManager() + : m_initialized(false) + { + InitializeCriticalSection(&m_lock); + } + + IdentityTokenManager::~IdentityTokenManager() + { + DeleteCriticalSection(&m_lock); + } + + static std::string BytesToBase64(const uint8_t *data, int length) + { + static const char kTable[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + out.reserve(((length + 2) / 3) * 4); + for (int i = 0; i < length; i += 3) + { + uint32_t n = static_cast(data[i]) << 16; + if (i + 1 < length) n |= static_cast(data[i + 1]) << 8; + if (i + 2 < length) n |= static_cast(data[i + 2]); + out.push_back(kTable[(n >> 18) & 0x3F]); + out.push_back(kTable[(n >> 12) & 0x3F]); + out.push_back((i + 1 < length) ? kTable[(n >> 6) & 0x3F] : '='); + out.push_back((i + 2 < length) ? kTable[n & 0x3F] : '='); + } + return out; + } + + static bool Base64ToBytes(const std::string &encoded, std::vector &out) + { + static const int kDecodeTable[128] = { + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1,-1,63, + 52,53,54,55,56,57,58,59,60,61,-1,-1,-1,-1,-1,-1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14, + 15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1, + -1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40, + 41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1 + }; + out.clear(); + out.reserve(encoded.size() * 3 / 4); + uint32_t buf = 0; + int bits = 0; + for (char c : encoded) + { + if (c == '=') break; + if (c < 0 || c >= 128 || kDecodeTable[(int)c] < 0) return false; + buf = (buf << 6) | kDecodeTable[(int)c]; + bits += 6; + if (bits >= 8) + { + bits -= 8; + out.push_back(static_cast((buf >> bits) & 0xFF)); + } + } + return true; + } + + static std::string FormatXuid(PlayerUID xuid) + { + char buffer[32] = {}; + sprintf_s(buffer, sizeof(buffer), "0x%016llx", (unsigned long long)xuid); + return buffer; + } + + bool IdentityTokenManager::Initialize(const std::string &filePath) + { + EnterCriticalSection(&m_lock); + m_filePath = filePath; + m_tokens.clear(); + bool ok = Load(); + m_initialized = true; + LeaveCriticalSection(&m_lock); + + if (ok) + { + LogInfof("security", "loaded %u identity tokens from %s", + (unsigned)m_tokens.size(), filePath.c_str()); + } + else + { + LogInfof("security", "no existing identity tokens found, starting fresh"); + } + return true; + } + + void IdentityTokenManager::Shutdown() + { + EnterCriticalSection(&m_lock); + m_tokens.clear(); + m_initialized = false; + LeaveCriticalSection(&m_lock); + } + + bool IdentityTokenManager::HasToken(PlayerUID xuid) const + { + EnterCriticalSection(&m_lock); + bool found = m_tokens.find(xuid) != m_tokens.end(); + LeaveCriticalSection(&m_lock); + return found; + } + + bool IdentityTokenManager::GetToken(PlayerUID xuid, uint8_t outToken[TOKEN_SIZE]) const + { + EnterCriticalSection(&m_lock); + auto it = m_tokens.find(xuid); + if (it == m_tokens.end() || it->second.size() != TOKEN_SIZE) + { + LeaveCriticalSection(&m_lock); + return false; + } + memcpy(outToken, it->second.data(), TOKEN_SIZE); + LeaveCriticalSection(&m_lock); + return true; + } + + bool IdentityTokenManager::IssueToken(PlayerUID xuid, uint8_t outToken[TOKEN_SIZE]) + { + // Generate a random 32-byte identity token + uint8_t token[TOKEN_SIZE]; +#ifdef _WINDOWS64 + NTSTATUS status = BCryptGenRandom(nullptr, token, TOKEN_SIZE, + BCRYPT_USE_SYSTEM_PREFERRED_RNG); + if (!BCRYPT_SUCCESS(status)) + { + SecureZeroMemory(token, sizeof(token)); + return false; + } +#else + for (int i = 0; i < TOKEN_SIZE; ++i) + token[i] = static_cast(rand() & 0xFF); +#endif + + EnterCriticalSection(&m_lock); + m_tokens[xuid] = std::vector(token, token + TOKEN_SIZE); + bool saved = Save(); + LeaveCriticalSection(&m_lock); + + if (saved) + { + memcpy(outToken, token, TOKEN_SIZE); + SecureZeroMemory(token, sizeof(token)); + return true; + } + + SecureZeroMemory(token, sizeof(token)); + return false; + } + + bool IdentityTokenManager::VerifyToken(PlayerUID xuid, const uint8_t token[TOKEN_SIZE]) const + { + EnterCriticalSection(&m_lock); + auto it = m_tokens.find(xuid); + if (it == m_tokens.end() || it->second.size() != TOKEN_SIZE) + { + LeaveCriticalSection(&m_lock); + return false; + } + + // Constant-time comparison to prevent timing attacks + uint8_t diff = 0; + for (int i = 0; i < TOKEN_SIZE; ++i) + { + diff |= it->second[i] ^ token[i]; + } + LeaveCriticalSection(&m_lock); + return diff == 0; + } + + bool IdentityTokenManager::RevokeToken(PlayerUID xuid) + { + EnterCriticalSection(&m_lock); + auto it = m_tokens.find(xuid); + if (it == m_tokens.end()) + { + LeaveCriticalSection(&m_lock); + return false; + } + SecureZeroMemory(it->second.data(), it->second.size()); + m_tokens.erase(it); + bool saved = Save(); + LeaveCriticalSection(&m_lock); + return saved; + } + + bool IdentityTokenManager::Load() + { + std::string text; + if (!FileUtils::ReadTextFile(m_filePath, &text)) + { + return false; + } + + if (text.empty()) + { + return true; + } + + OrderedJson root; + try + { + root = OrderedJson::parse(StringUtils::StripUtf8Bom(text)); + } + catch (const nlohmann::json::exception &) + { + LogErrorf("security", "failed to parse %s", m_filePath.c_str()); + return false; + } + + if (!root.is_object() || !root.contains("tokens") || !root["tokens"].is_object()) + { + return true; + } + + for (auto it = root["tokens"].begin(); it != root["tokens"].end(); ++it) + { + const std::string &xuidStr = it.key(); + if (!it.value().is_string()) continue; + + unsigned long long parsed = 0; + if (!StringUtils::TryParseUnsignedLongLong(xuidStr, &parsed) || parsed == 0ULL) + continue; + + std::vector tokenBytes; + if (!Base64ToBytes(it.value().get(), tokenBytes)) + continue; + if (tokenBytes.size() != TOKEN_SIZE) + continue; + + m_tokens[static_cast(parsed)] = tokenBytes; + } + + return true; + } + + bool IdentityTokenManager::Save() const + { + OrderedJson root = OrderedJson::object(); + OrderedJson tokens = OrderedJson::object(); + + for (const auto &pair : m_tokens) + { + std::string xuidStr = FormatXuid(pair.first); + std::string tokenB64 = BytesToBase64(pair.second.data(), TOKEN_SIZE); + tokens[xuidStr] = tokenB64; + } + + root["tokens"] = tokens; + + std::string json = root.dump(2) + "\n"; + if (!FileUtils::WriteTextFileAtomic(m_filePath, json)) + { + LogErrorf("security", "failed to write %s", m_filePath.c_str()); + return false; + } + return true; + } + + IdentityTokenManager &GetIdentityTokenManager() + { + static IdentityTokenManager s_instance; + return s_instance; + } + } +} diff --git a/Minecraft.Server/Security/IdentityTokenManager.h b/Minecraft.Server/Security/IdentityTokenManager.h new file mode 100644 index 00000000..1fd6850d --- /dev/null +++ b/Minecraft.Server/Security/IdentityTokenManager.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include + +#ifdef _WINDOWS64 +#include +#endif + +typedef unsigned __int64 PlayerUID; + +namespace ServerRuntime +{ + namespace Security + { + /** + * Persistent XUID-to-token binding for identity verification. + * + * On first login, the server issues a random 32-byte token to the client + * over the encrypted cipher channel. The client stores it locally. + * On subsequent logins, the server challenges the client to present + * its stored token. Mismatch = kicked. + * + * This prevents XUID replay attacks: an attacker who steals a XUID + * still needs the token, which was only sent over the encrypted channel. + * + * Tokens are stored in `identity-tokens.json` and persist across restarts. + */ + class IdentityTokenManager + { + public: + static const int TOKEN_SIZE = 32; + + IdentityTokenManager(); + ~IdentityTokenManager(); + + IdentityTokenManager(const IdentityTokenManager &) = delete; + IdentityTokenManager &operator=(const IdentityTokenManager &) = delete; + + bool Initialize(const std::string &filePath); + void Shutdown(); + + bool HasToken(PlayerUID xuid) const; + bool GetToken(PlayerUID xuid, uint8_t outToken[TOKEN_SIZE]) const; + bool IssueToken(PlayerUID xuid, uint8_t outToken[TOKEN_SIZE]); + bool VerifyToken(PlayerUID xuid, const uint8_t token[TOKEN_SIZE]) const; + bool RevokeToken(PlayerUID xuid); + + private: + bool Load(); + bool Save() const; + + std::string m_filePath; + std::unordered_map> m_tokens; + mutable CRITICAL_SECTION m_lock; + bool m_initialized; + }; + + IdentityTokenManager &GetIdentityTokenManager(); + } +} diff --git a/Minecraft.Server/Security/RateLimiter.cpp b/Minecraft.Server/Security/RateLimiter.cpp new file mode 100644 index 00000000..a7a6d3ee --- /dev/null +++ b/Minecraft.Server/Security/RateLimiter.cpp @@ -0,0 +1,78 @@ +#include "stdafx.h" +#include "RateLimiter.h" + +namespace ServerRuntime +{ + namespace Security + { + RateLimiter::RateLimiter() + { + InitializeCriticalSection(&m_lock); + } + + RateLimiter::~RateLimiter() + { + DeleteCriticalSection(&m_lock); + } + + bool RateLimiter::AllowConnection(const std::string &ip, int maxPerWindow, int windowMs) + { + if (maxPerWindow <= 0 || windowMs <= 0) + { + return true; + } + + ULONGLONG now = GetTickCount64(); + ULONGLONG windowDuration = static_cast(windowMs); + + EnterCriticalSection(&m_lock); + + auto ×tamps = m_connectionTimes[ip]; + + // Remove timestamps outside the sliding window + while (!timestamps.empty() && (now - timestamps.front()) > windowDuration) + { + timestamps.pop_front(); + } + + bool allowed = timestamps.size() < static_cast(maxPerWindow); + if (allowed) + { + timestamps.push_back(now); + } + + LeaveCriticalSection(&m_lock); + return allowed; + } + + void RateLimiter::EvictStale(int evictionAgeMs) + { + ULONGLONG now = GetTickCount64(); + ULONGLONG evictionAge = static_cast(evictionAgeMs); + + EnterCriticalSection(&m_lock); + + auto it = m_connectionTimes.begin(); + while (it != m_connectionTimes.end()) + { + if (it->second.empty() || + (now - it->second.back()) > evictionAge) + { + it = m_connectionTimes.erase(it); + } + else + { + ++it; + } + } + + LeaveCriticalSection(&m_lock); + } + + RateLimiter &GetGlobalRateLimiter() + { + static RateLimiter s_instance; + return s_instance; + } + } +} diff --git a/Minecraft.Server/Security/RateLimiter.h b/Minecraft.Server/Security/RateLimiter.h new file mode 100644 index 00000000..aab7a5cb --- /dev/null +++ b/Minecraft.Server/Security/RateLimiter.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +#ifdef _WINDOWS64 +#include +#endif + +namespace ServerRuntime +{ + namespace Security + { + class RateLimiter + { + public: + RateLimiter(); + ~RateLimiter(); + + RateLimiter(const RateLimiter &) = delete; + RateLimiter &operator=(const RateLimiter &) = delete; + RateLimiter(RateLimiter &&) = delete; + RateLimiter &operator=(RateLimiter &&) = delete; + + /** + * Returns true if the connection from this IP should be allowed. + * Returns false if the IP has exceeded maxPerWindow connections within windowMs milliseconds. + */ + bool AllowConnection(const std::string &ip, int maxPerWindow, int windowMs); + + /** + * Removes stale entries older than evictionAgeMs from the tracking map. + */ + void EvictStale(int evictionAgeMs = 300000); + + private: + CRITICAL_SECTION m_lock; + std::unordered_map> m_connectionTimes; + }; + + /** + * Global rate limiter instance for the dedicated server accept loop. + */ + RateLimiter &GetGlobalRateLimiter(); + } +} diff --git a/Minecraft.Server/Security/SecurityConfig.cpp b/Minecraft.Server/Security/SecurityConfig.cpp new file mode 100644 index 00000000..7cbe3cb5 --- /dev/null +++ b/Minecraft.Server/Security/SecurityConfig.cpp @@ -0,0 +1,27 @@ +#include "stdafx.h" +#include "SecurityConfig.h" + +namespace ServerRuntime +{ + namespace Security + { + namespace + { + // Initialized once from main() before any worker threads start. + // Default member initializers in SecuritySettings provide safe hardened + // defaults if GetSettings() is called before InitializeSettings(). + // This global must NOT be written after threads are running. + SecuritySettings g_settings; + } + + void InitializeSettings(const SecuritySettings &settings) + { + g_settings = settings; + } + + const SecuritySettings &GetSettings() + { + return g_settings; + } + } +} diff --git a/Minecraft.Server/Security/SecurityConfig.h b/Minecraft.Server/Security/SecurityConfig.h new file mode 100644 index 00000000..476dc6b2 --- /dev/null +++ b/Minecraft.Server/Security/SecurityConfig.h @@ -0,0 +1,22 @@ +#pragma once + +namespace ServerRuntime +{ + namespace Security + { + struct SecuritySettings + { + bool hidePlayerListPreLogin = true; + int rateLimitConnectionsPerWindow = 5; + int rateLimitWindowSeconds = 30; + int maxPendingConnections = 10; + bool requireChallengeToken = false; + bool enableStreamCipher = true; + bool requireSecureClient = true; + bool proxyProtocol = false; + }; + + void InitializeSettings(const SecuritySettings &settings); + const SecuritySettings &GetSettings(); + } +} diff --git a/Minecraft.Server/Security/StreamCipher.cpp b/Minecraft.Server/Security/StreamCipher.cpp new file mode 100644 index 00000000..1275b566 --- /dev/null +++ b/Minecraft.Server/Security/StreamCipher.cpp @@ -0,0 +1,206 @@ +#include "stdafx.h" +#include "StreamCipher.h" + +#ifdef _WINDOWS64 +#include +#pragma comment(lib, "bcrypt.lib") +#endif + +#include + +namespace ServerRuntime +{ + namespace Security + { + StreamCipher::StreamCipher() + : m_sendKeystreamPos(AES_BLOCK) + , m_recvKeystreamPos(AES_BLOCK) + , m_active(false) + { +#ifdef _WINDOWS64 + m_hAlg = nullptr; + m_hKey = nullptr; +#endif + memset(m_sendCounter, 0, sizeof(m_sendCounter)); + memset(m_recvCounter, 0, sizeof(m_recvCounter)); + memset(m_sendKeystream, 0, sizeof(m_sendKeystream)); + memset(m_recvKeystream, 0, sizeof(m_recvKeystream)); + } + + StreamCipher::~StreamCipher() + { + Reset(); + } + + void StreamCipher::Initialize(const uint8_t key[KEY_SIZE], Role role) + { + if (m_active) + { + Reset(); + } + +#ifdef _WINDOWS64 + NTSTATUS status; + + status = BCryptOpenAlgorithmProvider(&m_hAlg, BCRYPT_AES_ALGORITHM, nullptr, 0); + if (!BCRYPT_SUCCESS(status)) + { + m_hAlg = nullptr; + return; + } + + // Set ECB mode -- we manage CTR ourselves for streaming support + status = BCryptSetProperty(m_hAlg, BCRYPT_CHAINING_MODE, + (PUCHAR)BCRYPT_CHAIN_MODE_ECB, sizeof(BCRYPT_CHAIN_MODE_ECB), 0); + if (!BCRYPT_SUCCESS(status)) + { + BCryptCloseAlgorithmProvider(m_hAlg, 0); + m_hAlg = nullptr; + return; + } + + // Create symmetric key from first 16 bytes + status = BCryptGenerateSymmetricKey(m_hAlg, &m_hKey, nullptr, 0, + (PUCHAR)key, AES_BLOCK, 0); + if (!BCRYPT_SUCCESS(status)) + { + BCryptCloseAlgorithmProvider(m_hAlg, 0); + m_hAlg = nullptr; + m_hKey = nullptr; + return; + } + + // Derive separate counters for send and recv to prevent CTR nonce reuse. + // Flipping the top bit of byte 0 guarantees the two counter spaces never + // overlap (one in 0x00-0x7F range, the other in 0x80-0xFF for byte 0). + // Server send = IV, Server recv = IV^0x80 + // Client send = IV^0x80, Client recv = IV + // This ensures: server-send matches client-recv, client-send matches server-recv. + uint8_t ivBase[AES_BLOCK]; + uint8_t ivFlipped[AES_BLOCK]; + memcpy(ivBase, key + AES_BLOCK, AES_BLOCK); + memcpy(ivFlipped, key + AES_BLOCK, AES_BLOCK); + ivFlipped[0] ^= 0x80; + + if (role == Server) + { + memcpy(m_sendCounter, ivBase, AES_BLOCK); + memcpy(m_recvCounter, ivFlipped, AES_BLOCK); + } + else + { + memcpy(m_sendCounter, ivFlipped, AES_BLOCK); + memcpy(m_recvCounter, ivBase, AES_BLOCK); + } + + SecureZeroMemory(ivBase, sizeof(ivBase)); + SecureZeroMemory(ivFlipped, sizeof(ivFlipped)); + + m_sendKeystreamPos = AES_BLOCK; // force generation on first use + m_recvKeystreamPos = AES_BLOCK; + m_active = true; +#endif + } + + void StreamCipher::Reset() + { +#ifdef _WINDOWS64 + if (m_hKey != nullptr) + { + BCryptDestroyKey(m_hKey); + m_hKey = nullptr; + } + if (m_hAlg != nullptr) + { + BCryptCloseAlgorithmProvider(m_hAlg, 0); + m_hAlg = nullptr; + } +#endif + SecureZeroMemory(m_sendCounter, sizeof(m_sendCounter)); + SecureZeroMemory(m_recvCounter, sizeof(m_recvCounter)); + SecureZeroMemory(m_sendKeystream, sizeof(m_sendKeystream)); + SecureZeroMemory(m_recvKeystream, sizeof(m_recvKeystream)); + m_sendKeystreamPos = AES_BLOCK; + m_recvKeystreamPos = AES_BLOCK; + m_active = false; + } + + void StreamCipher::IncrementCounter(uint8_t counter[AES_BLOCK]) + { + // Big-endian 128-bit increment (standard NIST CTR convention) + for (int i = AES_BLOCK - 1; i >= 0; --i) + { + if (++counter[i] != 0) + break; + } + } + + void StreamCipher::GenerateKeystream(uint8_t counter[AES_BLOCK], uint8_t keystream[AES_BLOCK]) + { +#ifdef _WINDOWS64 + ULONG cbResult = 0; + NTSTATUS status = BCryptEncrypt(m_hKey, counter, AES_BLOCK, nullptr, + nullptr, 0, keystream, AES_BLOCK, &cbResult, 0); // flags=0: exact block, no padding + if (!BCRYPT_SUCCESS(status)) + { + SecureZeroMemory(keystream, AES_BLOCK); + m_active = false; + return; + } + IncrementCounter(counter); +#endif + } + + void StreamCipher::Encrypt(uint8_t *data, int length) + { + if (!m_active || data == nullptr || length <= 0) + { + return; + } + + for (int i = 0; i < length; ++i) + { + if (m_sendKeystreamPos >= AES_BLOCK) + { + GenerateKeystream(m_sendCounter, m_sendKeystream); + m_sendKeystreamPos = 0; + } + data[i] ^= m_sendKeystream[m_sendKeystreamPos++]; + } + } + + void StreamCipher::Decrypt(uint8_t *data, int length) + { + if (!m_active || data == nullptr || length <= 0) + { + return; + } + + for (int i = 0; i < length; ++i) + { + if (m_recvKeystreamPos >= AES_BLOCK) + { + GenerateKeystream(m_recvCounter, m_recvKeystream); + m_recvKeystreamPos = 0; + } + data[i] ^= m_recvKeystream[m_recvKeystreamPos++]; + } + } + + bool StreamCipher::GenerateKey(uint8_t outKey[KEY_SIZE]) + { +#ifdef _WINDOWS64 + NTSTATUS status = BCryptGenRandom(nullptr, outKey, KEY_SIZE, + BCRYPT_USE_SYSTEM_PREFERRED_RNG); + return BCRYPT_SUCCESS(status); +#else + // Fallback: not cryptographically random + for (int i = 0; i < KEY_SIZE; ++i) + { + outKey[i] = static_cast(rand() & 0xFF); + } + return true; +#endif + } + } +} diff --git a/Minecraft.Server/Security/StreamCipher.h b/Minecraft.Server/Security/StreamCipher.h new file mode 100644 index 00000000..5db2327f --- /dev/null +++ b/Minecraft.Server/Security/StreamCipher.h @@ -0,0 +1,98 @@ +#pragma once + +#include + +#ifdef _WINDOWS64 +#include +#include +#endif + +namespace ServerRuntime +{ + namespace Security + { + /** + * AES-128-CTR stream cipher for game traffic encryption. + * + * Uses the Windows BCrypt API to generate AES-encrypted keystream + * blocks that are XOR'd with plaintext. Each direction (send/recv) + * maintains its own counter for independent keystream generation. + * + * Key material: 32 bytes (16-byte AES key + 16-byte IV/nonce). + * The IV is used as the initial counter block for both directions. + * + * Usage: + * 1. Server generates a random 32-byte key via GenerateKey() + * 2. Key is sent to the client in the MC|CKey CustomPayloadPacket + * 3. Both sides call Initialize() with the same 32 bytes + * 4. All subsequent TCP traffic is encrypted via Encrypt/Decrypt + */ + class StreamCipher + { + public: + static const int KEY_SIZE = 32; // 16 AES key + 16 IV + + enum Role { Server, Client }; + + StreamCipher(); + ~StreamCipher(); + + StreamCipher(const StreamCipher &) = delete; + StreamCipher &operator=(const StreamCipher &) = delete; + StreamCipher(StreamCipher &&) = delete; + StreamCipher &operator=(StreamCipher &&) = delete; + + /** + * Initialize with key material. First 16 bytes = AES key, last 16 bytes = IV. + * Role determines counter assignment to prevent nonce reuse between directions: + * Server: send=IV, recv=IV^0x80 (top bit flipped) + * Client: send=IV^0x80, recv=IV + * This ensures server-send matches client-recv and vice versa. + */ + void Initialize(const uint8_t key[KEY_SIZE], Role role = Server); + + /** + * AES-CTR encrypt data in place for sending. + */ + void Encrypt(uint8_t *data, int length); + + /** + * AES-CTR decrypt data in place after receiving. + */ + void Decrypt(uint8_t *data, int length); + + /** + * Returns true if the cipher has been initialized. + */ + bool IsActive() const { return m_active; } + + /** + * Reset to inactive state and securely wipe all key material. + */ + void Reset(); + + /** + * Generate 32 cryptographically random bytes (16 AES key + 16 IV). + */ + static bool GenerateKey(uint8_t outKey[KEY_SIZE]); + + private: + static const int AES_BLOCK = 16; + + static void IncrementCounter(uint8_t counter[AES_BLOCK]); + void GenerateKeystream(uint8_t counter[AES_BLOCK], uint8_t keystream[AES_BLOCK]); + +#ifdef _WINDOWS64 + BCRYPT_ALG_HANDLE m_hAlg; + BCRYPT_KEY_HANDLE m_hKey; +#endif + uint8_t m_sendCounter[AES_BLOCK]; + uint8_t m_recvCounter[AES_BLOCK]; + uint8_t m_sendKeystream[AES_BLOCK]; + uint8_t m_recvKeystream[AES_BLOCK]; + int m_sendKeystreamPos; + int m_recvKeystreamPos; + bool m_active; + }; + } +} diff --git a/Minecraft.Server/ServerLogManager.cpp b/Minecraft.Server/ServerLogManager.cpp index 2992edc9..7de7ea4d 100644 --- a/Minecraft.Server/ServerLogManager.cpp +++ b/Minecraft.Server/ServerLogManager.cpp @@ -7,6 +7,7 @@ #include #include +#include extern bool g_Win64DedicatedServer; @@ -26,6 +27,12 @@ namespace ServerRuntime { std::string remoteIp; std::string playerName; + PlayerUID offlineXuid = INVALID_XUID; + PlayerUID onlineXuid = INVALID_XUID; + bool isGuest = false; + bool cipherActive = false; + bool tokenIssued = false; + bool tokenVerified = false; }; /** @@ -36,6 +43,10 @@ namespace ServerRuntime { std::mutex stateLock; std::array entries; + + // Name->XUIDs cache from recent login attempts (case-insensitive name key) + // Multiple XUIDs per name for ambiguity detection + std::unordered_map> nameToXuidCache; }; ServerLogState g_serverLogState; @@ -54,6 +65,12 @@ namespace ServerRuntime entry->remoteIp.clear(); entry->playerName.clear(); + entry->offlineXuid = INVALID_XUID; + entry->onlineXuid = INVALID_XUID; + entry->isGuest = false; + entry->cipherActive = false; + entry->tokenIssued = false; + entry->tokenVerified = false; } static std::string NormalizeRemoteIp(const char *ip) @@ -148,6 +165,9 @@ namespace ServerRuntime case eTcpRejectReason_BannedIp: return "banned-ip"; case eTcpRejectReason_GameNotReady: return "game-not-ready"; case eTcpRejectReason_ServerFull: return "server-full"; + case eTcpRejectReason_RateLimited: return "rate-limited"; + case eTcpRejectReason_TooManyPending: return "too-many-pending"; + case eTcpRejectReason_InvalidProxyHeader: return "invalid-proxy-header"; default: return "unknown"; } } @@ -283,8 +303,17 @@ namespace ServerRuntime LogInfof("network", "accepted tcp connection from %s as smallId=%u", remoteIp.c_str(), (unsigned)smallId); } - // Once login succeeds, bind the resolved player name onto the cached transport entry. - void OnAcceptedPlayerLogin(unsigned char smallId, const std::wstring &playerName) + static std::string FormatXuidForLog(PlayerUID xuid) + { + if (xuid == INVALID_XUID) return "none"; + char buf[32] = {}; + sprintf_s(buf, sizeof(buf), "0x%016llx", (unsigned long long)xuid); + return buf; + } + + // Once login succeeds, bind the resolved player name and identity onto the cached transport entry. + void OnAcceptedPlayerLogin(unsigned char smallId, const std::wstring &playerName, + PlayerUID offlineXuid, PlayerUID onlineXuid, bool isGuest) { if (!IsDedicatedServerLoggingEnabled()) { @@ -297,13 +326,29 @@ namespace ServerRuntime std::lock_guard stateLock(g_serverLogState.stateLock); ConnectionLogEntry &entry = g_serverLogState.entries[smallId]; entry.playerName = playerNameUtf8; + entry.offlineXuid = offlineXuid; + entry.onlineXuid = onlineXuid; + entry.isGuest = isGuest; if (!entry.remoteIp.empty()) { remoteIp = entry.remoteIp; } } - LogInfof("network", "accepted player login: name=\"%s\" ip=%s smallId=%u", playerNameUtf8.c_str(), remoteIp.c_str(), (unsigned)smallId); + std::string xuidStr = FormatXuidForLog(offlineXuid); + std::string logMsg = "accepted player login: name=\"" + playerNameUtf8 + + "\" ip=" + remoteIp + + " xuid=" + xuidStr; + if (onlineXuid != INVALID_XUID && onlineXuid != offlineXuid) + { + logMsg += " online-xuid=" + FormatXuidForLog(onlineXuid); + } + if (isGuest) + { + logMsg += " guest=yes"; + } + logMsg += " smallId=" + std::to_string((unsigned)smallId); + LogInfof("network", "%s", logMsg.c_str()); } // Read the cached IP for the rejection log, then clear the slot because the player never fully joined. @@ -398,5 +443,234 @@ namespace ServerRuntime std::lock_guard stateLock(g_serverLogState.stateLock); ResetConnectionLogEntry(&g_serverLogState.entries[smallId]); } + + // ---- Security milestone tracking ---- + + static void TryEmitPlayerSecuredSummary(unsigned char smallId, const ConnectionLogEntry &entry) + { + // Only emit when cipher is confirmed active (the primary security gate) + if (!entry.cipherActive) return; + // If tokens are required, wait until token status is resolved + if (!entry.tokenIssued && !entry.tokenVerified) return; + + const char *tokenStatus = entry.tokenVerified ? "verified" : (entry.tokenIssued ? "issued" : "n/a"); + std::string xuidStr = FormatXuidForLog(entry.offlineXuid); + std::string logMsg = "player secured: name=\"" + entry.playerName + + "\" xuid=" + xuidStr + + " ip=" + (entry.remoteIp.empty() ? "unknown" : entry.remoteIp) + + " cipher=active token=" + tokenStatus; + if (entry.isGuest) + { + logMsg += " guest=yes"; + } + LogInfof("security", "%s", logMsg.c_str()); + } + + void OnCipherHandshakeCompleted(unsigned char smallId) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::lock_guard stateLock(g_serverLogState.stateLock); + ConnectionLogEntry &entry = g_serverLogState.entries[smallId]; + entry.cipherActive = true; + + // If tokens are not required, emit summary now + // (check if player name is cached -- it should be by this point) + if (!entry.playerName.empty()) + { + // Defer: token status may still arrive. Summary emits from token methods + // or if tokens are disabled, we need to check config. + // For simplicity: always defer to token methods. If tokens are disabled, + // the caller in PlayerList.cpp will call a direct emit. + } + } + + void OnCipherCompletedNoTokenRequired(unsigned char smallId) + { + // Called when cipher completes and require-challenge-token is false + if (!IsDedicatedServerLoggingEnabled()) return; + + std::lock_guard stateLock(g_serverLogState.stateLock); + ConnectionLogEntry &entry = g_serverLogState.entries[smallId]; + entry.cipherActive = true; + + if (!entry.playerName.empty()) + { + std::string xuidStr = FormatXuidForLog(entry.offlineXuid); + LogInfof("security", "player secured: name=\"%s\" xuid=%s ip=%s cipher=active token=n/a%s", + entry.playerName.c_str(), xuidStr.c_str(), + entry.remoteIp.empty() ? "unknown" : entry.remoteIp.c_str(), + entry.isGuest ? " guest=yes" : ""); + } + } + + void OnIdentityTokenIssued(unsigned char smallId) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::lock_guard stateLock(g_serverLogState.stateLock); + ConnectionLogEntry &entry = g_serverLogState.entries[smallId]; + entry.tokenIssued = true; + TryEmitPlayerSecuredSummary(smallId, entry); + } + + void OnIdentityTokenVerified(unsigned char smallId) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::lock_guard stateLock(g_serverLogState.stateLock); + ConnectionLogEntry &entry = g_serverLogState.entries[smallId]; + entry.tokenVerified = true; + TryEmitPlayerSecuredSummary(smallId, entry); + } + + void OnIdentityTokenMismatch(unsigned char smallId, const std::wstring &playerName) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::string name = NormalizePlayerName(playerName); + std::string ip("unknown"); + { + std::lock_guard stateLock(g_serverLogState.stateLock); + const auto &entry = g_serverLogState.entries[smallId]; + if (!entry.remoteIp.empty()) ip = entry.remoteIp; + } + LogWarnf("security", "identity token mismatch for player \"%s\" (ip=%s) - use: revoketoken %s", + name.c_str(), ip.c_str(), name.c_str()); + } + + void OnIdentityTokenTimeout(unsigned char smallId, const std::wstring &playerName) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::string name = NormalizePlayerName(playerName); + std::string ip("unknown"); + { + std::lock_guard stateLock(g_serverLogState.stateLock); + const auto &entry = g_serverLogState.entries[smallId]; + if (!entry.remoteIp.empty()) ip = entry.remoteIp; + } + LogWarnf("security", "kicked player \"%s\" (ip=%s) - identity token response timed out", + name.c_str(), ip.c_str()); + } + + void OnUnsecuredClientKicked(unsigned char smallId) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::string ip("unknown"); + { + std::lock_guard stateLock(g_serverLogState.stateLock); + const auto &entry = g_serverLogState.entries[smallId]; + if (!entry.remoteIp.empty()) ip = entry.remoteIp; + } + LogWarnf("security", "kicked unsecured client (smallId=%u, ip=%s) - cipher handshake timed out", + (unsigned)smallId, ip.c_str()); + } + + void OnXuidSpoofDetected(unsigned char smallId, const std::wstring &claimedName, + const char *newIp, const char *existingIp) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::string name = NormalizePlayerName(claimedName); + LogWarnf("security", "XUID spoof suspected for \"%s\" - new IP %s conflicts with existing IP %s", + name.c_str(), + (newIp != nullptr) ? newIp : "unknown", + (existingIp != nullptr) ? existingIp : "unknown"); + } + + void OnUnauthorizedCommand(unsigned char smallId, const std::wstring &playerName, const char *action) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::string name = NormalizePlayerName(playerName); + std::string ip("unknown"); + { + std::lock_guard stateLock(g_serverLogState.stateLock); + const auto &entry = g_serverLogState.entries[smallId]; + if (!entry.remoteIp.empty()) ip = entry.remoteIp; + } + LogWarnf("security", "non-OP player \"%s\" attempted %s (ip=%s)", + name.c_str(), (action != nullptr) ? action : "unknown-action", ip.c_str()); + } + + // ---- Name-to-XUID cache ---- + + // Normalize a player name for cache key consistency (lowercase + trim) + static std::string NormalizeNameKey(const std::string &name) + { + return StringUtils::ToLowerAscii(StringUtils::TrimAscii(name)); + } + + // Maximum entries in the name->XUID cache to prevent unbounded growth + static const size_t kMaxCacheEntries = 256; + // Maximum XUIDs tracked per name + static const size_t kMaxXuidsPerName = 8; + + void CachePlayerXuid(const std::wstring &playerName, PlayerUID xuid) + { + if (playerName.empty() || xuid == INVALID_XUID) + { + return; + } + + // Note: playerName is from the LoginPacket and is attacker-controlled. + // This cache is an operator convenience tool for `whitelist add `, + // not a security mechanism. The operator sees the resolved XUID and can + // verify it before whitelisting. Ambiguous names are blocked. + std::string key = NormalizeNameKey(StringUtils::WideToUtf8(playerName)); + + std::lock_guard stateLock(g_serverLogState.stateLock); + + // Evict oldest cache entry if at capacity + if (g_serverLogState.nameToXuidCache.size() >= kMaxCacheEntries && + g_serverLogState.nameToXuidCache.find(key) == g_serverLogState.nameToXuidCache.end()) + { + g_serverLogState.nameToXuidCache.erase(g_serverLogState.nameToXuidCache.begin()); + } + + auto &entries = g_serverLogState.nameToXuidCache[key]; + // Move matching XUID to the back (most recent) or append if new + for (auto it = entries.begin(); it != entries.end(); ++it) + { + if (*it == xuid) + { + entries.erase(it); + break; + } + } + entries.push_back(xuid); + // Cap per-name vector + while (entries.size() > kMaxXuidsPerName) + { + entries.erase(entries.begin()); + } + } + + int GetCachedXuids(const std::string &playerName, std::vector *outXuids) + { + if (playerName.empty()) + { + if (outXuids != nullptr) outXuids->clear(); + return 0; + } + + std::string key = NormalizeNameKey(playerName); + + std::lock_guard stateLock(g_serverLogState.stateLock); + auto it = g_serverLogState.nameToXuidCache.find(key); + if (it == g_serverLogState.nameToXuidCache.end() || it->second.empty()) + { + if (outXuids != nullptr) outXuids->clear(); + return 0; + } + + if (outXuids != nullptr) + { + *outXuids = it->second; + } + return static_cast(it->second.size()); + } } } diff --git a/Minecraft.Server/ServerLogManager.h b/Minecraft.Server/ServerLogManager.h index fe150b21..4a64e946 100644 --- a/Minecraft.Server/ServerLogManager.h +++ b/Minecraft.Server/ServerLogManager.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "../Minecraft.World/DisconnectPacket.h" @@ -17,7 +18,10 @@ namespace ServerRuntime { eTcpRejectReason_BannedIp = 0, eTcpRejectReason_GameNotReady, - eTcpRejectReason_ServerFull + eTcpRejectReason_ServerFull, + eTcpRejectReason_RateLimited, + eTcpRejectReason_TooManyPending, + eTcpRejectReason_InvalidProxyHeader }; /** @@ -89,10 +93,26 @@ namespace ServerRuntime void OnAcceptedTcpConnection(unsigned char smallId, const char *ip); /** - * Associates a player name with the connection and emits the accepted login log - * 接続にプレイヤー名を関連付けてログイン成功を記録 + * Associates a player name and identity with the connection and emits the accepted login log */ - void OnAcceptedPlayerLogin(unsigned char smallId, const std::wstring &playerName); + void OnAcceptedPlayerLogin(unsigned char smallId, const std::wstring &playerName, + PlayerUID offlineXuid = INVALID_XUID, PlayerUID onlineXuid = INVALID_XUID, bool isGuest = false); + + // Security milestone recording -- accumulates per-connection state for the + // consolidated "player secured" summary line + void OnCipherHandshakeCompleted(unsigned char smallId); + void OnCipherCompletedNoTokenRequired(unsigned char smallId); + void OnIdentityTokenIssued(unsigned char smallId); + void OnIdentityTokenVerified(unsigned char smallId); + void OnIdentityTokenTimeout(unsigned char smallId, const std::wstring &playerName); + + // Security warnings -- emit immediately to CLI + void OnIdentityTokenMismatch(unsigned char smallId, const std::wstring &playerName); + void OnIdentityTokenTimeout(unsigned char smallId, const std::wstring &playerName); + void OnUnsecuredClientKicked(unsigned char smallId); + void OnXuidSpoofDetected(unsigned char smallId, const std::wstring &claimedName, + const char *newIp, const char *existingIp); + void OnUnauthorizedCommand(unsigned char smallId, const std::wstring &playerName, const char *action); /** * Emits a named login rejection log and clears cached metadata for that smallId @@ -123,5 +143,21 @@ namespace ServerRuntime * 指定smallIdに紐づく接続キャッシュを消去 */ void ClearConnection(unsigned char smallId); + + /** + * Cache a player name -> XUID mapping from a login attempt (accepted or rejected). + * Used by `whitelist add ` to resolve names to XUIDs. + */ + void CachePlayerXuid(const std::wstring &playerName, PlayerUID xuid); + + /** + * Get all cached XUIDs for a player name (case-insensitive). + * Returns the number of distinct XUIDs seen. If > 1, the name is ambiguous + * and the operator should use an explicit XUID. + * + * Note: cached names are attacker-controlled (from LoginPacket). This cache + * is an operator convenience tool, not a security mechanism. + */ + int GetCachedXuids(const std::string &playerName, std::vector *outXuids); } } diff --git a/Minecraft.Server/ServerLogger.cpp b/Minecraft.Server/ServerLogger.cpp index 1dcf7c47..175b7fea 100644 --- a/Minecraft.Server/ServerLogger.cpp +++ b/Minecraft.Server/ServerLogger.cpp @@ -7,10 +7,48 @@ #include #include #include +#include namespace ServerRuntime { static volatile LONG g_minLogLevel = (LONG)eServerLogLevel_Info; +static FILE *g_logFile = NULL; +static std::once_flag g_logFileOnce; + +static void OpenLogFile() +{ + if (g_logFile != NULL) + return; + + errno_t err = fopen_s(&g_logFile, "server.log", "a"); + if (err != 0 || g_logFile == NULL) + { + g_logFile = NULL; + printf("[ServerLogger] Warning: Could not open server.log for writing (errno=%d)\n", (int)err); + fflush(stdout); + } +} + +static void CloseLogFile() +{ + if (g_logFile != NULL) + { + fflush(g_logFile); + fclose(g_logFile); + g_logFile = NULL; + } +} + +static void EnsureLogFileInitialized() +{ + std::call_once(g_logFileOnce, []() { + OpenLogFile(); + if (g_logFile != NULL) + { + atexit(CloseLogFile); + } + }); +} static const char *NormalizeCategory(const char *category) { @@ -121,6 +159,14 @@ static void WriteLogLine(EServerLogLevel level, const char *category, const char SetConsoleTextAttribute(stdoutHandle, originalInfo.wAttributes); } + EnsureLogFileInitialized(); + if (g_logFile != NULL) + { + fprintf(g_logFile, "[%s][%s][%s] %s\n", + timestamp, LogLevelToString(level), safeCategory, safeMessage); + fflush(g_logFile); + } + linenoiseExternalWriteEnd(); } diff --git a/Minecraft.Server/ServerProperties.cpp b/Minecraft.Server/ServerProperties.cpp index ddc75a4a..711ebe10 100644 --- a/Minecraft.Server/ServerProperties.cpp +++ b/Minecraft.Server/ServerProperties.cpp @@ -54,6 +54,8 @@ static const ServerPropertyDefault kServerPropertyDefaults[] = { "gamemode", "0" }, { "gamertags", "true" }, { "generate-structures", "true" }, + { "hardcore", "false" }, + { "hardcore-ban-ip", "false" }, { "host-can-be-invisible", "true" }, { "host-can-change-hunger", "true" }, { "host-can-fly", "true" }, @@ -80,7 +82,15 @@ static const ServerPropertyDefault kServerPropertyDefaults[] = { "spawn-monsters", "true" }, { "spawn-npcs", "true" }, { "tnt", "true" }, - { "trust-players", "true" } + { "trust-players", "true" }, + { "hide-player-list-prelogin", "true" }, + { "rate-limit-connections-per-window", "5" }, + { "rate-limit-window-seconds", "30" }, + { "max-pending-connections", "10" }, + { "require-challenge-token", "false" }, + { "enable-stream-cipher", "true" }, + { "require-secure-client", "true" }, + { "proxy-protocol", "false" } }; static std::string BoolToString(bool value) @@ -822,6 +832,20 @@ ServerPropertiesConfig LoadServerPropertiesConfig() config.maxPlayers = ReadNormalizedIntProperty(&merged, "max-players", kDefaultMaxPlayers, 1, kMaxDedicatedPlayers, &shouldWrite); config.seed = 0; config.hasSeed = ReadNormalizedOptionalInt64Property(&merged, "level-seed", &config.seed, &shouldWrite); + config.overrideSeed = 0; + config.hasOverrideSeed = false; + { + auto it = merged.find("override-seed"); + if (it != merged.end() && !TrimAscii(it->second).empty()) + { + __int64 parsed = 0; + if (TryParseInt64(TrimAscii(it->second), &parsed)) + { + config.overrideSeed = parsed; + config.hasOverrideSeed = true; + } + } + } config.logLevel = ReadNormalizedLogLevelProperty(&merged, "log-level", eServerLogLevel_Info, &shouldWrite); config.autosaveIntervalSeconds = ReadNormalizedIntProperty(&merged, "autosave-interval", kDefaultAutosaveIntervalSeconds, 5, 3600, &shouldWrite); @@ -861,10 +885,21 @@ ServerPropertiesConfig LoadServerPropertiesConfig() config.doTileDrops = ReadNormalizedBoolProperty(&merged, "do-tile-drops", true, &shouldWrite); config.naturalRegeneration = ReadNormalizedBoolProperty(&merged, "natural-regeneration", true, &shouldWrite); config.doDaylightCycle = ReadNormalizedBoolProperty(&merged, "do-daylight-cycle", true, &shouldWrite); + config.hardcore = ReadNormalizedBoolProperty(&merged, "hardcore", false, &shouldWrite); + config.hardcoreBanIp = ReadNormalizedBoolProperty(&merged, "hardcore-ban-ip", false, &shouldWrite); config.maxBuildHeight = ReadNormalizedIntProperty(&merged, "max-build-height", 256, 64, 256, &shouldWrite); config.motd = ReadNormalizedStringProperty(&merged, "motd", "A Minecraft Server", 255, &shouldWrite); + config.hidePlayerListPreLogin = ReadNormalizedBoolProperty(&merged, "hide-player-list-prelogin", true, &shouldWrite); + config.rateLimitConnectionsPerWindow = ReadNormalizedIntProperty(&merged, "rate-limit-connections-per-window", 5, 1, 100, &shouldWrite); + config.rateLimitWindowSeconds = ReadNormalizedIntProperty(&merged, "rate-limit-window-seconds", 30, 5, 300, &shouldWrite); + config.maxPendingConnections = ReadNormalizedIntProperty(&merged, "max-pending-connections", 10, 1, 50, &shouldWrite); + config.requireChallengeToken = ReadNormalizedBoolProperty(&merged, "require-challenge-token", false, &shouldWrite); + config.enableStreamCipher = ReadNormalizedBoolProperty(&merged, "enable-stream-cipher", true, &shouldWrite); + config.requireSecureClient = ReadNormalizedBoolProperty(&merged, "require-secure-client", true, &shouldWrite); + config.proxyProtocol = ReadNormalizedBoolProperty(&merged, "proxy-protocol", false, &shouldWrite); + if (shouldWrite) { if (WriteServerPropertiesFile(kServerPropertiesPath, merged)) diff --git a/Minecraft.Server/ServerProperties.h b/Minecraft.Server/ServerProperties.h index 3bb5aca8..0e6f8813 100644 --- a/Minecraft.Server/ServerProperties.h +++ b/Minecraft.Server/ServerProperties.h @@ -31,6 +31,9 @@ namespace ServerRuntime bool hasSeed; /** `level-seed` */ __int64 seed; + /** `override-seed` replaces the seed for biome generation on existing worlds */ + bool hasOverrideSeed; + __int64 overrideSeed; /** `log-level` */ EServerLogLevel logLevel; /** `autosave-interval` (seconds) */ @@ -73,6 +76,27 @@ namespace ServerRuntime bool doTileDrops; bool naturalRegeneration; bool doDaylightCycle; + bool hardcore; + /** `hardcore-ban-ip` — whether hardcore death bans include IP bans */ + bool hardcoreBanIp; + + /** security settings */ + /** `hide-player-list-prelogin` — strip XUIDs from PreLoginPacket response */ + bool hidePlayerListPreLogin; + /** `rate-limit-connections-per-window` — max TCP connections per IP within the rate limit window */ + int rateLimitConnectionsPerWindow; + /** `rate-limit-window-seconds` — sliding window duration for connection rate limiting */ + int rateLimitWindowSeconds; + /** `max-pending-connections` — max simultaneous pending (pre-login) connections */ + int maxPendingConnections; + /** `require-challenge-token` — reserved for future protocol extension (not yet enforced) */ + bool requireChallengeToken; + /** `enable-stream-cipher` — enable XOR stream cipher for traffic obfuscation */ + bool enableStreamCipher; + /** `require-secure-client` — kick clients that do not complete the cipher handshake */ + bool requireSecureClient; + /** `proxy-protocol` — parse PROXY protocol v1 headers from TCP tunnel (e.g. playit.gg) */ + bool proxyProtocol; /** other MinecraftServer runtime settings */ int maxBuildHeight; diff --git a/Minecraft.Server/Windows64/ServerMain.cpp b/Minecraft.Server/Windows64/ServerMain.cpp index bd568f0c..98052de6 100644 --- a/Minecraft.Server/Windows64/ServerMain.cpp +++ b/Minecraft.Server/Windows64/ServerMain.cpp @@ -13,6 +13,9 @@ #include "../ServerShutdown.h" #include "../WorldManager.h" #include "../Console/ServerCli.h" +#include "../Security/SecurityConfig.h" +#include "../Security/RateLimiter.h" +#include "../Security/IdentityTokenManager.h" #include "Tesselator.h" #include "Windows64/4JLibs/inc/4J_Render.h" #include "Windows64/GameConfig/Minecraft.spa.h" @@ -28,6 +31,9 @@ #include "../../Minecraft.World/TilePos.h" #include "../../Minecraft.World/compression.h" #include "../../Minecraft.World/OldChunkStorage.h" +#include "../../Minecraft.World/BiomeSource.h" +#include "../../Minecraft.World/LevelType.h" +#include "../../Minecraft.World/ConsoleSaveFileOriginal.h" #include "../../Minecraft.World/net.minecraft.world.level.tile.h" #include "../../Minecraft.World/Random.h" @@ -325,6 +331,7 @@ static void TickCoreSystems() g_NetworkManager.DoWork(); ProfileManager.Tick(); StorageManager.Tick(); + ConsoleSaveFileOriginal::flushPendingBackgroundSave(); } /** @@ -369,6 +376,13 @@ int main(int argc, char **argv) ServerPropertiesConfig serverProperties = LoadServerPropertiesConfig(); ApplyServerPropertiesToDedicatedConfig(serverProperties, &config); + // Hardcore mode forces Hard difficulty (matches vanilla Java behavior) + if (serverProperties.hardcore && serverProperties.difficulty != 3) + { + LogInfof("startup", "Hardcore mode enabled: forcing difficulty from %d to 3 (Hard).", serverProperties.difficulty); + serverProperties.difficulty = 3; + } + if (!ParseCommandLine(argc, argv, &config)) { PrintUsage(); @@ -407,6 +421,41 @@ int main(int argc, char **argv) return 2; } accessShutdownGuard.Activate(); + + { + ServerRuntime::Security::SecuritySettings secSettings; + secSettings.hidePlayerListPreLogin = serverProperties.hidePlayerListPreLogin; + secSettings.rateLimitConnectionsPerWindow = serverProperties.rateLimitConnectionsPerWindow; + secSettings.rateLimitWindowSeconds = serverProperties.rateLimitWindowSeconds; + secSettings.maxPendingConnections = serverProperties.maxPendingConnections; + secSettings.requireChallengeToken = serverProperties.requireChallengeToken; + secSettings.enableStreamCipher = serverProperties.enableStreamCipher; + secSettings.requireSecureClient = serverProperties.requireSecureClient; + secSettings.proxyProtocol = serverProperties.proxyProtocol; + ServerRuntime::Security::InitializeSettings(secSettings); + LogInfof("startup", "Security: hide-player-list=%s, rate-limit=%d/%ds, max-pending=%d, challenge-token=%s, stream-cipher=%s, require-secure-client=%s", + secSettings.hidePlayerListPreLogin ? "true" : "false", + secSettings.rateLimitConnectionsPerWindow, + secSettings.rateLimitWindowSeconds, + secSettings.maxPendingConnections, + secSettings.requireChallengeToken ? "required" : "optional", + secSettings.enableStreamCipher ? "enabled" : "disabled", + secSettings.requireSecureClient ? "true" : "false"); + if (secSettings.proxyProtocol) + { + LogInfof("startup", "PROXY protocol: enabled (all connections must send PROXY v1 header)"); + } + if (secSettings.requireSecureClient && !secSettings.enableStreamCipher) + { + LogInfof("startup", "WARNING: require-secure-client is enabled but enable-stream-cipher is disabled -- secure client enforcement will have no effect"); + } + + if (secSettings.requireChallengeToken) + { + ServerRuntime::Security::GetIdentityTokenManager().Initialize("identity-tokens.json"); + } + } + LogInfof("startup", "LAN advertise: %s", serverProperties.lanAdvertise ? "enabled" : "disabled"); LogInfof("startup", "Whitelist: %s", serverProperties.whiteListEnabled ? "enabled" : "disabled"); LogInfof("startup", "Spawn protection radius: %d", serverProperties.spawnProtectionRadius); @@ -529,6 +578,7 @@ int main(int argc, char **argv) app.SetGameHostOption(eGameHostOption_DoTileDrops, serverProperties.doTileDrops ? 1 : 0); app.SetGameHostOption(eGameHostOption_NaturalRegeneration, serverProperties.naturalRegeneration ? 1 : 0); app.SetGameHostOption(eGameHostOption_DoDaylightCycle, serverProperties.doDaylightCycle ? 1 : 0); + app.SetGameHostOption(eGameHostOption_Hardcore, serverProperties.hardcore ? 1 : 0); #ifdef _LARGE_WORLDS app.SetGameHostOption(eGameHostOption_WorldSize, serverProperties.worldSize); // Apply desired target size for loading existing worlds. @@ -537,6 +587,12 @@ int main(int argc, char **argv) app.SetGameNewHellScale(serverProperties.worldHellScale); #endif + if (serverProperties.hasOverrideSeed) + { + LogInfof("startup", "Seed override active: %lld", serverProperties.overrideSeed); + app.SetSeedOverride(serverProperties.overrideSeed); + } + StorageManager.SetSaveDisabled(serverProperties.disableSaving); // Read world name and fixed save-id from server.properties // Delegate load-vs-create decision to WorldManager @@ -576,9 +632,17 @@ int main(int argc, char **argv) { param->seed = config.seed; } + else if (worldBootstrap.saveData == nullptr) + { + // Only run seed validation when creating a brand-new world. + // Existing worlds already have their seed in level.dat. + LogInfof("startup", "Finding seed with biome diversity for %d-chunk world...", config.worldSizeChunks); + param->seed = BiomeSource::findSeed(LevelType::lvl_normal, config.worldSizeChunks); + LogInfof("startup", "Selected seed: %lld", param->seed); + } else { - param->seed = (new Random())->nextLong(); + param->seed = (new Random())->nextLong(); // placeholder; level.dat seed takes priority } #ifdef _LARGE_WORLDS param->xzSize = (unsigned int)config.worldSizeChunks; @@ -701,9 +765,20 @@ int main(int argc, char **argv) { C4JThread waitThread(&WaitForServerStoppedThreadProc, NULL, "WaitServerStopped"); waitThread.Run(); + while (waitThread.isRunning()) + { + TickCoreSystems(); + Sleep(10); + } waitThread.WaitForCompletion(INFINITE); } + while (ConsoleSaveFileOriginal::hasPendingBackgroundSave()) + { + TickCoreSystems(); + Sleep(10); + } + LogInfof("shutdown", "Cleaning up and exiting."); WinsockNetLayer::Shutdown(); LogDebugf("shutdown", "Network layer shutdown complete."); diff --git a/Minecraft.Server/cmake/sources/Common.cmake b/Minecraft.Server/cmake/sources/Common.cmake index 5f3bd7f1..407c45a6 100644 --- a/Minecraft.Server/cmake/sources/Common.cmake +++ b/Minecraft.Server/cmake/sources/Common.cmake @@ -133,6 +133,7 @@ set(_MINECRAFT_SERVER_COMMON_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/IUIScene_TradingMenu.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/IUIScene_WritingBookMenu.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIBitmapFont.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIUnicodeBitmapFont.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIComponent_Chat.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIComponent_DebugUIConsole.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIComponent_DebugUIMarketingGuide.cpp" @@ -504,6 +505,8 @@ set(_MINECRAFT_SERVER_COMMON_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/iob_shim.asm" "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/stdafx.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/stubs.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.World/ConsoleSaveFileOriginal.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.World/ConsoleSaveFileOriginal.h" "${CMAKE_CURRENT_SOURCE_DIR}/../include/lce_filesystem/lce_filesystem.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Console/ServerCliInput.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Console/ServerCliInput.h" @@ -530,9 +533,27 @@ set(_MINECRAFT_SERVER_COMMON_SERVER_ACCESS "${CMAKE_CURRENT_SOURCE_DIR}/Access/BanManager.h" "${CMAKE_CURRENT_SOURCE_DIR}/Access/WhitelistManager.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Access/WhitelistManager.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Access/OpManager.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Access/OpManager.h" ) source_group("Server/Access" FILES ${_MINECRAFT_SERVER_COMMON_SERVER_ACCESS}) +set(_MINECRAFT_SERVER_COMMON_SERVER_SECURITY + "${CMAKE_CURRENT_SOURCE_DIR}/Security/SecurityConfig.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/SecurityConfig.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/RateLimiter.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/RateLimiter.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/StreamCipher.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/StreamCipher.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/ConnectionCipher.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/ConnectionCipher.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/CipherHandshakeEnforcer.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/CipherHandshakeEnforcer.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/IdentityTokenManager.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/IdentityTokenManager.h" +) +source_group("Server/Security" FILES ${_MINECRAFT_SERVER_COMMON_SERVER_SECURITY}) + set(_MINECRAFT_SERVER_COMMON_SERVER_COMMON "${CMAKE_CURRENT_SOURCE_DIR}/Common/AccessStorageUtils.h" "${CMAKE_CURRENT_SOURCE_DIR}/Common/FileUtils.cpp" @@ -594,6 +615,8 @@ set(_MINECRAFT_SERVER_COMMON_SERVER_CONSOLE_COMMANDS "${CMAKE_CURRENT_SOURCE_DIR}/Console/commands/weather/CliCommandWeather.h" "${CMAKE_CURRENT_SOURCE_DIR}/Console/commands/whitelist/CliCommandWhitelist.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Console/commands/whitelist/CliCommandWhitelist.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Console/commands/revoketoken/CliCommandRevokeToken.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Console/commands/revoketoken/CliCommandRevokeToken.h" ) source_group("Server/Console/Commands" FILES ${_MINECRAFT_SERVER_COMMON_SERVER_CONSOLE_COMMANDS}) @@ -607,6 +630,7 @@ set(MINECRAFT_SERVER_COMMON ${_MINECRAFT_SERVER_COMMON_ROOT} ${_MINECRAFT_SERVER_COMMON_SERVER} ${_MINECRAFT_SERVER_COMMON_SERVER_ACCESS} + ${_MINECRAFT_SERVER_COMMON_SERVER_SECURITY} ${_MINECRAFT_SERVER_COMMON_SERVER_COMMON} ${_MINECRAFT_SERVER_COMMON_SERVER_CONSOLE} ${_MINECRAFT_SERVER_COMMON_SERVER_CONSOLE_COMMANDS} diff --git a/Minecraft.World/ArabicShaping.cpp b/Minecraft.World/ArabicShaping.cpp new file mode 100644 index 00000000..a5255818 --- /dev/null +++ b/Minecraft.World/ArabicShaping.cpp @@ -0,0 +1,556 @@ +#include "stdafx.h" +#include "ArabicShaping.h" +#include +#include + +// Arabic text shaping - contextual form selection and RTL visual reordering. +// Ported from "Arabic Writer" JS reference by Omar Muhammad (GPL). + +// Each entry: base char, isolated, initial, medial, final forms +struct ArabicCharEntry +{ + wchar_t base; + wchar_t isolated; + wchar_t initial; + wchar_t medial; + wchar_t final_; + bool connectsLeft; + bool connectsRight; +}; + +// Core Arabic + extended (Farsi/Urdu) characters with presentation forms +static const ArabicCharEntry arabicChars[] = +{ + // base isolated initial medial final cL cR + { 0x0621, 0xFE80, 0xFE80, 0xFE80, 0xFE80, false, false }, // hamza + { 0x0622, 0xFE81, 0xFE81, 0xFE82, 0xFE82, false, true }, // alef madda + { 0x0623, 0xFE83, 0xFE83, 0xFE84, 0xFE84, false, true }, // alef hamza above + { 0x0624, 0xFE85, 0xFE85, 0xFE86, 0xFE86, false, true }, // waw hamza + { 0x0625, 0xFE87, 0xFE87, 0xFE88, 0xFE88, false, true }, // alef hamza below + { 0x0626, 0xFE89, 0xFE8B, 0xFE8C, 0xFE8A, true, true }, // yeh hamza + { 0x0627, 0xFE8D, 0xFE8D, 0xFE8E, 0xFE8E, false, true }, // alef + { 0x0628, 0xFE8F, 0xFE91, 0xFE92, 0xFE90, true, true }, // beh + { 0x0629, 0xFE93, 0xFE93, 0xFE94, 0xFE94, false, true }, // teh marbuta + { 0x062A, 0xFE95, 0xFE97, 0xFE98, 0xFE96, true, true }, // teh + { 0x062B, 0xFE99, 0xFE9B, 0xFE9C, 0xFE9A, true, true }, // theh + { 0x062C, 0xFE9D, 0xFE9F, 0xFEA0, 0xFE9E, true, true }, // jeem + { 0x062D, 0xFEA1, 0xFEA3, 0xFEA4, 0xFEA2, true, true }, // hah + { 0x062E, 0xFEA5, 0xFEA7, 0xFEA8, 0xFEA6, true, true }, // khah + { 0x062F, 0xFEA9, 0xFEA9, 0xFEAA, 0xFEAA, false, true }, // dal + { 0x0630, 0xFEAB, 0xFEAB, 0xFEAC, 0xFEAC, false, true }, // thal + { 0x0631, 0xFEAD, 0xFEAD, 0xFEAE, 0xFEAE, false, true }, // reh + { 0x0632, 0xFEAF, 0xFEAF, 0xFEB0, 0xFEB0, false, true }, // zain + { 0x0633, 0xFEB1, 0xFEB3, 0xFEB4, 0xFEB2, true, true }, // seen + { 0x0634, 0xFEB5, 0xFEB7, 0xFEB8, 0xFEB6, true, true }, // sheen + { 0x0635, 0xFEB9, 0xFEBB, 0xFEBC, 0xFEBA, true, true }, // sad + { 0x0636, 0xFEBD, 0xFEBF, 0xFEC0, 0xFEBE, true, true }, // dad + { 0x0637, 0xFEC1, 0xFEC3, 0xFEC4, 0xFEC2, true, true }, // tah + { 0x0638, 0xFEC5, 0xFEC7, 0xFEC8, 0xFEC6, true, true }, // zah + { 0x0639, 0xFEC9, 0xFECB, 0xFECC, 0xFECA, true, true }, // ain + { 0x063A, 0xFECD, 0xFECF, 0xFED0, 0xFECE, true, true }, // ghain + { 0x0640, 0x0640, 0x0640, 0x0640, 0x0640, true, true }, // tatweel + { 0x0641, 0xFED1, 0xFED3, 0xFED4, 0xFED2, true, true }, // feh + { 0x0642, 0xFED5, 0xFED7, 0xFED8, 0xFED6, true, true }, // qaf + { 0x0643, 0xFED9, 0xFEDB, 0xFEDC, 0xFEDA, true, true }, // kaf + { 0x0644, 0xFEDD, 0xFEDF, 0xFEE0, 0xFEDE, true, true }, // lam + { 0x0645, 0xFEE1, 0xFEE3, 0xFEE4, 0xFEE2, true, true }, // meem + { 0x0646, 0xFEE5, 0xFEE7, 0xFEE8, 0xFEE6, true, true }, // noon + { 0x0647, 0xFEE9, 0xFEEB, 0xFEEC, 0xFEEA, true, true }, // heh + { 0x0648, 0xFEED, 0xFEED, 0xFEEE, 0xFEEE, false, true }, // waw + { 0x0649, 0xFEEF, 0xFEEF, 0xFEF0, 0xFEF0, false, true }, // alef maksura + { 0x064A, 0xFEF1, 0xFEF3, 0xFEF4, 0xFEF2, true, true }, // yeh + // Extended - Farsi/Urdu + { 0x067E, 0xFB56, 0xFB58, 0xFB59, 0xFB57, true, true }, // peh + { 0x0686, 0xFB7A, 0xFB7C, 0xFB7D, 0xFB7B, true, true }, // tcheh + { 0x0698, 0xFB8A, 0xFB8A, 0xFB8B, 0xFB8B, false, true }, // jeh + { 0x06A9, 0xFB8E, 0xFB90, 0xFB91, 0xFB8F, true, true }, // keheh (Farsi kaf) + { 0x06AF, 0xFB92, 0xFB94, 0xFB95, 0xFB93, true, true }, // gaf + { 0x06CC, 0xFBFC, 0xFBFE, 0xFBFF, 0xFBFD, true, true }, // Farsi yeh + // Urdu + { 0x0679, 0xFB66, 0xFB68, 0xFB69, 0xFB67, true, true }, // tteh + { 0x0688, 0xFB88, 0xFB88, 0xFB89, 0xFB89, false, true }, // ddal + { 0x0691, 0xFB8C, 0xFB8C, 0xFB8D, 0xFB8D, false, true }, // rreh + { 0x06C1, 0xFBA6, 0xFBA8, 0xFBA9, 0xFBA7, true, true }, // heh goal + { 0x06D2, 0xFBAE, 0xFBAE, 0xFBAF, 0xFBAF, false, true }, // yeh barree +}; + +static const int ARABIC_CHAR_COUNT = sizeof(arabicChars) / sizeof(arabicChars[0]); + +// Laam-Alef ligatures: when lam (0x0644) is followed by certain alef forms +struct LaamAlefEntry +{ + wchar_t alef; // the alef variant + wchar_t isolated; // ligature isolated form + wchar_t final_; // ligature final form +}; + +static const LaamAlefEntry laamAlefTable[] = +{ + { 0x0622, 0xFEF5, 0xFEF6 }, // lam + alef madda + { 0x0623, 0xFEF7, 0xFEF8 }, // lam + alef hamza above + { 0x0625, 0xFEF9, 0xFEFA }, // lam + alef hamza below + { 0x0627, 0xFEFB, 0xFEFC }, // lam + alef +}; + +// Build lookup map on first use +static std::unordered_map charMap; +static bool tablesInitialized = false; + +static void initTables() +{ + if (tablesInitialized) return; + for (int i = 0; i < ARABIC_CHAR_COUNT; i++) + { + charMap[arabicChars[i].base] = &arabicChars[i]; + } + tablesInitialized = true; +} + +static bool isArabicChar(wchar_t c) +{ + return (c >= 0x0600 && c <= 0x06FF) || (c >= 0xFB50 && c <= 0xFDFF) || (c >= 0xFE70 && c <= 0xFEFF); +} + +static bool isHaraka(wchar_t c) +{ + // Arabic diacritics (tashkeel/harakat): U+0610-U+061A, U+064B-U+065F, U+0670 + return (c >= 0x064B && c <= 0x065F) || (c >= 0x0610 && c <= 0x061A) || c == 0x0670; +} + +static bool isLaamAlefLigature(wchar_t c) +{ + // Laam-Alef presentation forms: U+FEF5-U+FEFC + return c >= 0xFEF5 && c <= 0xFEFC; +} + +static bool isDigit(wchar_t c) +{ + // Western digits and Arabic-Indic digits + return (c >= L'0' && c <= L'9') || (c >= 0x0660 && c <= 0x0669); +} + +// Neutral characters that inherit direction from surrounding context +static bool isNeutralChar(wchar_t c) +{ + return c == L' ' + || c == L'.' || c == L',' || c == L'!' || c == L'?' + || c == L':' || c == L';' || c == L'-' || c == L'(' + || c == L')' || c == L'[' || c == L']' + || c == 0x060C // Arabic comma + || c == 0x061B // Arabic semicolon + || c == 0x061F; // Arabic question mark +} + +static const ArabicCharEntry* findEntry(wchar_t c) +{ + auto it = charMap.find(c); + if (it != charMap.end()) return it->second; + return nullptr; +} + +static const LaamAlefEntry* findLaamAlef(wchar_t alef) +{ + for (int i = 0; i < 4; i++) + { + if (laamAlefTable[i].alef == alef) return &laamAlefTable[i]; + } + return nullptr; +} + +// ------------------------------------------------------------------------- +// Core shaping logic, shared by both public overloads. +// +// logicalToVisual: if non-null, maps input[i] -> position in output string. +// Must be pre-sized to input.size()+1. +// ------------------------------------------------------------------------- +static std::wstring shapeArabicTextInternal(const std::wstring& input, + std::vector* logicalToVisual) +{ + if (input.empty()) return input; + + initTables(); + + // Fast path: check if any base Arabic characters exist + bool hasArabic = false; + for (size_t i = 0; i < input.size(); i++) + { + if (input[i] >= 0x0600 && input[i] <= 0x06FF) + { + hasArabic = true; + break; + } + } + if (!hasArabic) + { + // Identity mapping + if (logicalToVisual) + { + for (size_t i = 0; i <= input.size(); i++) + (*logicalToVisual)[i] = (int)i; + } + return input; + } + + // ----------------------------------------------------------------------- + // Split into runs: Arabic vs non-Arabic. + // Track the starting logical index of each run. + // ----------------------------------------------------------------------- + struct Run + { + std::wstring text; + bool arabic; + int logicalStart; // index into input[] where this run begins + }; + std::vector runs; + + size_t i = 0; + while (i < input.size()) + { + bool curArabic = isArabicChar(input[i]) || isHaraka(input[i]); + Run run; + run.arabic = curArabic; + run.logicalStart = (int)i; + while (i < input.size()) + { + bool charArabic = isArabicChar(input[i]) || isHaraka(input[i]); + if (charArabic == curArabic) + { + run.text += input[i]; + i++; + } + else + { + break; + } + } + runs.push_back(run); + } + + // ----------------------------------------------------------------------- + // Merge neutral runs that sit between two Arabic runs into the preceding + // Arabic run (with the following Arabic run appended too). This keeps + // inter-word spaces inside the Arabic run so the whole phrase reverses + // together, producing correct RTL word order. + // ----------------------------------------------------------------------- + for (size_t r = 1; r + 1 < runs.size(); r++) + { + if (!runs[r].arabic && runs[r - 1].arabic && runs[r + 1].arabic) + { + bool allNeutral = true; + for (wchar_t c : runs[r].text) + { + if (!isNeutralChar(c)) { allNeutral = false; break; } + } + if (allNeutral) + { + // Absorb runs[r] and runs[r+1] into runs[r-1] + runs[r - 1].text += runs[r].text + runs[r + 1].text; + runs.erase(runs.begin() + r, runs.begin() + r + 2); + r--; // re-check from same position + } + } + } + + // Recompute logical starts after merging (run text lengths may have grown) + { + int pos = 0; + for (size_t r = 0; r < runs.size(); r++) + { + runs[r].logicalStart = pos; + pos += (int)runs[r].text.size(); + } + } + + // ----------------------------------------------------------------------- + // Shape each Arabic run. + // For each run we also build a posMap: posMap[localLogical] = localVisual + // (local means within the run's text, before run offsets are added). + // ----------------------------------------------------------------------- + std::vector> runPosMap(runs.size()); // per-run local maps + + for (size_t r = 0; r < runs.size(); r++) + { + if (!runs[r].arabic) + { + // Non-Arabic: identity mapping + runPosMap[r].resize(runs[r].text.size() + 1); + for (size_t j = 0; j <= runs[r].text.size(); j++) + runPosMap[r][j] = (int)j; + continue; + } + + std::wstring& text = runs[r].text; + const int textLen = (int)text.size(); + + // Collect base character indices (skip harakat) + std::vector baseIndices; + for (size_t j = 0; j < text.size(); j++) + { + if (!isHaraka(text[j])) + baseIndices.push_back(j); + } + + // ------------------------------------------------------------------ + // Laam-Alef ligatures + // ------------------------------------------------------------------ + std::vector consumed(text.size(), false); + std::vector> ligatures; // lam idx -> ligature char + + for (size_t bi = 0; bi + 1 < baseIndices.size(); bi++) + { + size_t idx = baseIndices[bi]; + size_t nextIdx = baseIndices[bi + 1]; + if (text[idx] == 0x0644) // lam + { + const LaamAlefEntry* la = findLaamAlef(text[nextIdx]); + if (la) + { + bool connectsToPrev = false; + if (bi > 0) + { + size_t prevIdx = baseIndices[bi - 1]; + const ArabicCharEntry* prevEntry = findEntry(text[prevIdx]); + if (prevEntry && prevEntry->connectsLeft) + connectsToPrev = true; + } + wchar_t ligChar = connectsToPrev ? la->final_ : la->isolated; + ligatures.push_back({ idx, ligChar }); + consumed[nextIdx] = true; + bi++; // skip the alef + } + } + } + + // Apply ligature characters + for (size_t li = 0; li < ligatures.size(); li++) + text[ligatures[li].first] = ligatures[li].second; + + // Rebuild base indices after ligature consumption + baseIndices.clear(); + for (size_t j = 0; j < text.size(); j++) + { + if (!isHaraka(text[j]) && !consumed[j]) + baseIndices.push_back(j); + } + + // ------------------------------------------------------------------ + // Contextual form selection + // ------------------------------------------------------------------ + for (size_t bi = 0; bi < baseIndices.size(); bi++) + { + size_t idx = baseIndices[bi]; + const ArabicCharEntry* entry = findEntry(text[idx]); + if (!entry) continue; + + bool prevConnects = false; + if (bi > 0) + { + size_t prevIdx = baseIndices[bi - 1]; + const ArabicCharEntry* prevEntry = findEntry(text[prevIdx]); + if (prevEntry && prevEntry->connectsLeft) + prevConnects = true; + else if (!prevEntry && text[prevIdx] >= 0xFE70) + prevConnects = false; // lam-alef ligature doesn't connect left + } + + bool nextConnects = false; + if (bi + 1 < baseIndices.size()) + { + size_t nextIdx = baseIndices[bi + 1]; + const ArabicCharEntry* nextEntry = findEntry(text[nextIdx]); + if (nextEntry && nextEntry->connectsRight) + nextConnects = true; + else if (!nextEntry && isLaamAlefLigature(text[nextIdx])) + nextConnects = true; // lam-alef ligatures always connect to the right + } + + bool canConnectPrev = prevConnects && entry->connectsRight; + bool canConnectNext = nextConnects && entry->connectsLeft; + + if (canConnectPrev && canConnectNext) text[idx] = entry->medial; + else if (canConnectPrev) text[idx] = entry->final_; + else if (canConnectNext) text[idx] = entry->initial; + else text[idx] = entry->isolated; + } + + // ------------------------------------------------------------------ + // Build shaped string (drop consumed ligature partners) + // and a forward mapping: shapedIdx[logicalPos] = position in shaped + // (for consumed chars, map to the ligature position) + // ------------------------------------------------------------------ + std::vector logToShaped(textLen + 1, 0); + std::wstring shaped; + + // Track where each position of text[] lands in shaped[] + // consumed chars map to the position of their ligature replacement + { + int shapedPos = 0; + // First record the ligature lam positions so consumed alefs can map there + // We need to process in order + for (int j = 0; j < textLen; j++) + { + logToShaped[j] = shapedPos; + if (!consumed[j]) + { + shaped += text[j]; + shapedPos++; + } + // consumed chars: logToShaped[j] stays pointing to the lam position + // (shapedPos is not incremented, so it already equals lam's slot) + } + logToShaped[textLen] = shapedPos; // end-of-run cursor + } + + // ------------------------------------------------------------------ + // Reverse for RTL visual order, preserving LTR digit sequences. + // Also build reversedPos[posInShaped] -> posInReversed. + // ------------------------------------------------------------------ + const int shapedLen = (int)shaped.size(); + std::wstring reversed; + reversed.reserve(shapedLen); + + // reversedOf[i] = where shaped[i] ended up in reversed[] + std::vector reversedOf(shapedLen, 0); + + // We'll do a two-pass approach: + // 1. Walk shaped backwards, collecting digit runs and individual chars + // 2. For each element we emit, record reversedOf[] + + // Collect output segments: each segment is (isDigitSeq, startInShaped, len) + struct Seg { int start; int len; bool isDigit; }; + std::vector segs; + { + int j = shapedLen - 1; + while (j >= 0) + { + if (isDigit(shaped[j])) + { + // Find the full digit run (going left from j) + int end = j; + while (j >= 0 && isDigit(shaped[j])) j--; + int start = j + 1; + segs.push_back({ start, end - start + 1, true }); + } + else + { + segs.push_back({ j, 1, false }); + j--; + } + } + } + + // Emit segments in order, recording reversedOf[] + { + int outPos = 0; + for (size_t s = 0; s < segs.size(); s++) + { + const Seg& seg = segs[s]; + if (seg.isDigit) + { + // Digit sequence: output in LTR order (start..start+len-1) + for (int k = seg.start; k < seg.start + seg.len; k++) + { + reversedOf[k] = outPos; + reversed += shaped[k]; + outPos++; + } + } + else + { + // Single non-digit char + reversedOf[seg.start] = outPos; + reversed += shaped[seg.start]; + outPos++; + } + } + } + + runs[r].text = reversed; + + // ------------------------------------------------------------------ + // Build the local logical->visual position map for this run. + // Cursor positions are between characters: for RTL text, cursor + // at shaped position p maps to visual position (shapedLen - p). + // ------------------------------------------------------------------ + if (logicalToVisual) + { + runPosMap[r].resize(textLen + 1); + for (int j = 0; j <= textLen; j++) + { + int sp = logToShaped[j]; + runPosMap[r][j] = shapedLen - sp; + } + } + } + + // ----------------------------------------------------------------------- + // Concatenate runs and compute absolute visual positions + // ----------------------------------------------------------------------- + std::wstring result; + result.reserve(input.size()); + + // Compute the visual start offset for each run + std::vector runVisualStart(runs.size(), 0); + { + int voff = 0; + for (size_t r = 0; r < runs.size(); r++) + { + runVisualStart[r] = voff; + voff += (int)runs[r].text.size(); + } + } + + for (size_t r = 0; r < runs.size(); r++) + result += runs[r].text; + + // ----------------------------------------------------------------------- + // Fill the caller's logicalToVisual[] array + // ----------------------------------------------------------------------- + if (logicalToVisual) + { + // For each input position, find which run it belongs to and map it + for (size_t r = 0; r < runs.size(); r++) + { + int logStart = runs[r].logicalStart; + + if (r < runPosMap.size() && !runPosMap[r].empty()) + { + int localLen = (int)runPosMap[r].size() - 1; // number of logical chars + for (int j = 0; j <= localLen; j++) + { + int logIdx = logStart + j; + if (logIdx <= (int)input.size()) + (*logicalToVisual)[logIdx] = runVisualStart[r] + runPosMap[r][j]; + } + } + } + + } + + return result; +} + +// ------------------------------------------------------------------------- +// Public API +// ------------------------------------------------------------------------- + +std::wstring shapeArabicText(const std::wstring& input) +{ + return shapeArabicTextInternal(input, nullptr); +} + +std::wstring shapeArabicText(const std::wstring& input, int logicalCursorPos, int* visualCursorPos) +{ + std::vector ltv(input.size() + 1, 0); + std::wstring result = shapeArabicTextInternal(input, <v); + + if (visualCursorPos) + { + int clamped = logicalCursorPos; + if (clamped < 0) clamped = 0; + if (clamped > (int)input.size()) clamped = (int)input.size(); + *visualCursorPos = ltv[clamped]; + } + + return result; +} diff --git a/Minecraft.World/ArabicShaping.h b/Minecraft.World/ArabicShaping.h new file mode 100644 index 00000000..103d3a7e --- /dev/null +++ b/Minecraft.World/ArabicShaping.h @@ -0,0 +1,10 @@ +#pragma once +#include + +// Shape Arabic text for visual display. Returns the visually-ordered string. +std::wstring shapeArabicText(const std::wstring& input); + +// Same as above, but also maps a logical cursor position to its visual position +// in the returned string. Pass the logical cursor index; visualCursorPos receives +// the index into the returned string where the cursor should be drawn. +std::wstring shapeArabicText(const std::wstring& input, int logicalCursorPos, int* visualCursorPos); diff --git a/Minecraft.World/BiomeSource.cpp b/Minecraft.World/BiomeSource.cpp index b7592b1e..772bc0b4 100644 --- a/Minecraft.World/BiomeSource.cpp +++ b/Minecraft.World/BiomeSource.cpp @@ -416,9 +416,9 @@ void BiomeSource::update() // 4J added - find a seed for this biomesource that matches certain criteria #ifdef __PSVITA__ -int64_t BiomeSource::findSeed(LevelType *generator, bool* pServerRunning) // MGH - added pRunning, so we can early out of this on Vita as it can take up to 60 secs +int64_t BiomeSource::findSeed(LevelType *generator, bool* pServerRunning, int worldSizeChunks) #else -int64_t BiomeSource::findSeed(LevelType *generator) +int64_t BiomeSource::findSeed(LevelType *generator, int worldSizeChunks) #endif { @@ -445,8 +445,8 @@ int64_t BiomeSource::findSeed(LevelType *generator) // Raw biome data has one result per 4x4 group of tiles. // Removing a border of 8 from each side since we'll be doing special things at the edge to turn our world into an island, and so don't want to count things // in the edge region in case they later get removed - static const int biomeWidth = ( 54 * 4 ) - 16; // Should be even so we can offset evenly - static const int biomeOffset = -( biomeWidth / 2 ); + const int biomeWidth = ( worldSizeChunks * 4 ) - 16; // Should be even so we can offset evenly + const int biomeOffset = -( biomeWidth / 2 ); // Storage for our biome indices intArray indices = intArray( biomeWidth * biomeWidth ); diff --git a/Minecraft.World/BiomeSource.h b/Minecraft.World/BiomeSource.h index 234e856e..cc1de075 100644 --- a/Minecraft.World/BiomeSource.h +++ b/Minecraft.World/BiomeSource.h @@ -36,9 +36,9 @@ private: static void getFracs(intArray indices, float *fracs); // 4J added public: #ifdef __PSVITA__ - static int64_t findSeed(LevelType *generator, bool* pServerRunning); // MGH - added pRunning, so we can early out of this on Vita as it can take up to 60 secs // 4J added + static int64_t findSeed(LevelType *generator, bool* pServerRunning, int worldSizeChunks = 54); #else - static int64_t findSeed(LevelType *generator); // 4J added + static int64_t findSeed(LevelType *generator, int worldSizeChunks = 54); #endif ~BiomeSource(); diff --git a/Minecraft.World/Boat.cpp b/Minecraft.World/Boat.cpp index 60d0c807..7be52e53 100644 --- a/Minecraft.World/Boat.cpp +++ b/Minecraft.World/Boat.cpp @@ -87,7 +87,7 @@ Boat::Boat(Level *level, double x, double y, double z) : Entity( level ) double Boat::getRideHeight() { - return heightOffset; + return heightOffset - 0.4f; } bool Boat::hurt(DamageSource *source, float hurtDamage) diff --git a/Minecraft.World/ConsoleSaveFileOriginal.cpp b/Minecraft.World/ConsoleSaveFileOriginal.cpp index 45e0ae20..8488c8cd 100644 --- a/Minecraft.World/ConsoleSaveFileOriginal.cpp +++ b/Minecraft.World/ConsoleSaveFileOriginal.cpp @@ -12,6 +12,28 @@ #include "../Minecraft.Client/Common/GameRules/LevelGenerationOptions.h" #include "../Minecraft.World/net.minecraft.world.level.chunk.storage.h" +#ifdef MINECRAFT_SERVER_BUILD +#include +#include +#include + +static std::atomic s_bgSaveActive{false}; +static std::mutex s_bgSaveMutex; + +struct BackgroundSaveResult +{ + ConsoleSaveFile *owner = nullptr; + PBYTE thumbData = nullptr; + DWORD thumbSize = 0; + BYTE textMeta[88] = {}; + int textMetaBytes = 0; + bool pending = false; +}; +static BackgroundSaveResult s_bgResult; +#endif + + + #ifdef _XBOX #define RESERVE_ALLOCATION MEM_RESERVE | MEM_LARGE_PAGES @@ -679,6 +701,83 @@ void ConsoleSaveFileOriginal::Flush(bool autosave, bool updateThumbnail ) unsigned int fileSize = header.GetFileSize(); +#ifdef MINECRAFT_SERVER_BUILD + // on the server we dont want to block the tick thread doing compression!!! + // sna[pshot pvSaveMem while we still hold the lock then hand it off to a background thread + byte *snap = new (std::nothrow) byte[fileSize]; + if (snap) + { + // copy the save buffer while we still own the lock so nothing can write to it mid-copy + QueryPerformanceCounter(&qwTime); + memcpy(snap, pvSaveMem, fileSize); + QueryPerformanceCounter(&qwNewTime); + app.DebugPrintf("snapshot %u bytes in %.3f sec\n", fileSize, + (qwNewTime.QuadPart - qwTime.QuadPart) * fSecsPerTick); + + PBYTE thumb = nullptr; + DWORD thumbSz = 0; + app.GetSaveThumbnail(&thumb, &thumbSz); + + BYTE meta[88]; + ZeroMemory(meta, 88); + int64_t seed = 0; + bool hasSeed = false; + if (MinecraftServer *sv = MinecraftServer::getInstance(); sv && sv->levels[0]) + { + seed = sv->levels[0]->getLevelData()->getSeed(); + hasSeed = true; + } + int metaLen = app.CreateImageTextData(meta, seed, hasSeed, + app.GetGameHostOption(eGameHostOption_All), Minecraft::GetInstance()->getCurrentTexturePackId()); + + // telemetry + INT uid = 0; + StorageManager.GetSaveUniqueNumber(&uid); + TelemetryManager->RecordLevelSaveOrCheckpoint(ProfileManager.GetPrimaryPad(), uid, fileSize); + + ReleaseSaveAccess(); + s_bgSaveActive.store(true, std::memory_order_release); + + std::thread([snap, fileSize, thumb, thumbSz, meta, metaLen, this]() { + unsigned int compLen = fileSize + 8; + byte *buf = static_cast(StorageManager.AllocateSaveData(compLen)); + if (!buf) + { + // FAIL!! attempt precalc + compLen = 0; + Compression::getCompression()->Compress(nullptr, &compLen, snap, fileSize); + compLen += 8; + buf = static_cast(StorageManager.AllocateSaveData(compLen)); + } + if (buf) + { + // COM,PRESS + Compression::getCompression()->Compress(buf + 8, &compLen, snap, fileSize); + ZeroMemory(buf, 8); + memcpy(buf + 4, &fileSize, sizeof(fileSize)); + + // store the result so flushPendingBackgroundSave() can pick it up on the main thread next tick + // StorageManager isnt thread safe so we cant call SetSaveImages or SaveSaveData from here. Bwoomp + std::lock_guard lk(s_bgSaveMutex); + s_bgResult.owner = this; + s_bgResult.thumbData = thumb; + s_bgResult.thumbSize = thumbSz; + memcpy(s_bgResult.textMeta, meta, sizeof(meta)); + s_bgResult.textMetaBytes = metaLen; + s_bgResult.pending = true; + } + else + { + app.DebugPrintf("save buf alloc failed\n"); + s_bgSaveActive.store(false, std::memory_order_release); + } + delete[] snap; + }).detach(); + return; + } + app.DebugPrintf("snapshot alloc failed (%u bytes)\n", fileSize); +#endif + // Assume that the compression will make it smaller so initially attempt to allocate the current file size // We add 4 bytes to the start so that we can signal compressed data // And another 4 bytes to store the decompressed data size @@ -844,6 +943,55 @@ int ConsoleSaveFileOriginal::SaveSaveDataCallback(LPVOID lpParam,bool bRes) { ConsoleSaveFile *pClass=static_cast(lpParam); +#ifdef _WINDOWS64 + // 4J Added: After save completes, capture the save folder name for hardcore world deletion + if (bRes && app.GetCurrentSaveFolderName().empty()) + { + // Try 1: Ask the library for the folder name + char szSaveFolder[MAX_SAVEFILENAME_LENGTH] = {}; + if (StorageManager.GetSaveUniqueFilename(szSaveFolder) && szSaveFolder[0] != '\0') + { + wchar_t wFolder[MAX_SAVEFILENAME_LENGTH] = {}; + mbstowcs(wFolder, szSaveFolder, MAX_SAVEFILENAME_LENGTH - 1); + app.SetCurrentSaveFolderName(wFolder); + app.DebugPrintf("SaveSaveDataCallback: captured save folder '%s'\n", szSaveFolder); + } + // Try 2: Scan GameHDD for the newest folder -- right after save, it's guaranteed to be ours + if (app.GetCurrentSaveFolderName().empty()) + { + WIN32_FIND_DATAW fd; + HANDLE hFind = FindFirstFileW(L"Windows64\\GameHDD\\*", &fd); + if (hFind != INVALID_HANDLE_VALUE) + { + FILETIME newestTime = {}; + wchar_t newestFolder[MAX_PATH] = {}; + do + { + if ((fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) && + wcscmp(fd.cFileName, L".") != 0 && wcscmp(fd.cFileName, L"..") != 0) + { + if (CompareFileTime(&fd.ftLastWriteTime, &newestTime) > 0) + { + newestTime = fd.ftLastWriteTime; + wcscpy_s(newestFolder, MAX_PATH, fd.cFileName); + } + } + } while (FindNextFileW(hFind, &fd)); + FindClose(hFind); + if (newestFolder[0] != L'\0') + { + app.SetCurrentSaveFolderName(newestFolder); + app.DebugPrintf("SaveSaveDataCallback: captured save folder via scan '%ls'\n", newestFolder); + } + } + } + } +#endif + +#ifdef MINECRAFT_SERVER_BUILD + s_bgSaveActive.store(false, std::memory_order_release); +#endif + return 0; } @@ -1091,3 +1239,25 @@ void *ConsoleSaveFileOriginal::getWritePointer(FileEntry *file) { return static_cast(pvSaveMem) + file->currentFilePointer;; } + +#ifdef MINECRAFT_SERVER_BUILD +void ConsoleSaveFileOriginal::flushPendingBackgroundSave() +{ + std::lock_guard lk(s_bgSaveMutex); + if (!s_bgResult.pending) + return; + + StorageManager.SetSaveImages( + s_bgResult.thumbData, s_bgResult.thumbSize, + nullptr, 0, s_bgResult.textMeta, s_bgResult.textMetaBytes); + StorageManager.SaveSaveData(&ConsoleSaveFileOriginal::SaveSaveDataCallback, s_bgResult.owner); + + s_bgResult.pending = false; + // the actual write isnt done until SaveSaveDataCallback fires +} + +bool ConsoleSaveFileOriginal::hasPendingBackgroundSave() +{ + return s_bgSaveActive.load(std::memory_order_acquire); +} +#endif diff --git a/Minecraft.World/ConsoleSaveFileOriginal.h b/Minecraft.World/ConsoleSaveFileOriginal.h index 9c91fafc..453cacd9 100644 --- a/Minecraft.World/ConsoleSaveFileOriginal.h +++ b/Minecraft.World/ConsoleSaveFileOriginal.h @@ -77,6 +77,11 @@ public: virtual void LockSaveAccess(); virtual void ReleaseSaveAccess(); +#ifdef MINECRAFT_SERVER_BUILD + static void flushPendingBackgroundSave(); + static bool hasPendingBackgroundSave(); +#endif + virtual ESavePlatform getSavePlatform(); virtual bool isSaveEndianDifferent(); virtual void setLocalPlatform(); diff --git a/Minecraft.World/CustomPayloadPacket.cpp b/Minecraft.World/CustomPayloadPacket.cpp index e86f01de..208e272b 100644 --- a/Minecraft.World/CustomPayloadPacket.cpp +++ b/Minecraft.World/CustomPayloadPacket.cpp @@ -14,7 +14,19 @@ const wstring CustomPayloadPacket::SET_ADVENTURE_COMMAND_PACKET = L"MC|AdvCdm"; const wstring CustomPayloadPacket::SET_BEACON_PACKET = L"MC|Beacon"; const wstring CustomPayloadPacket::SET_ITEM_NAME_PACKET = L"MC|ItemName"; +const wstring CustomPayloadPacket::CIPHER_KEY_CHANNEL = L"MC|CKey"; +const wstring CustomPayloadPacket::CIPHER_ACK_CHANNEL = L"MC|CAck"; +const wstring CustomPayloadPacket::CIPHER_ON_CHANNEL = L"MC|COn"; + +const wstring CustomPayloadPacket::IDENTITY_TOKEN_ISSUE = L"MC|CTIssue"; +const wstring CustomPayloadPacket::IDENTITY_TOKEN_CHALLENGE = L"MC|CTChallenge"; +const wstring CustomPayloadPacket::IDENTITY_TOKEN_RESPONSE = L"MC|CTResponse"; + +const wstring CustomPayloadPacket::FORK_HELLO_CHANNEL = L"MC|ForkHello"; +const wstring CustomPayloadPacket::FORK_PLAYER_LEAVE_CHANNEL = L"MC|ForkPLeave"; + CustomPayloadPacket::CustomPayloadPacket() + : length(0) { } @@ -22,6 +34,7 @@ CustomPayloadPacket::CustomPayloadPacket(const wstring &identifier, byteArray da { this->identifier = identifier; this->data = data; + this->length = 0; if (data.data != nullptr) { diff --git a/Minecraft.World/CustomPayloadPacket.h b/Minecraft.World/CustomPayloadPacket.h index 82a3f6e2..7445bdf8 100644 --- a/Minecraft.World/CustomPayloadPacket.h +++ b/Minecraft.World/CustomPayloadPacket.h @@ -17,6 +17,20 @@ public: static const wstring SET_BEACON_PACKET; static const wstring SET_ITEM_NAME_PACKET; + // Security: stream cipher handshake channels + static const wstring CIPHER_KEY_CHANNEL; // server->client: carries 32-byte key (16 AES key + 16 IV) + static const wstring CIPHER_ACK_CHANNEL; // client->server: ack (empty payload) + static const wstring CIPHER_ON_CHANNEL; // server->client: activation signal (empty payload) + + // Security: identity token channels + static const wstring IDENTITY_TOKEN_ISSUE; // server->client: issue new 32-byte token + static const wstring IDENTITY_TOKEN_CHALLENGE; // server->client: request stored token + static const wstring IDENTITY_TOKEN_RESPONSE; // client->server: present stored token + + // Fork extensions: server capability and player lifecycle + static const wstring FORK_HELLO_CHANNEL; // server->client: identifies fork server (empty payload) + static const wstring FORK_PLAYER_LEAVE_CHANNEL; // server->client: player disconnected (payload: UTF gamertag) + wstring identifier; int length; byteArray data; diff --git a/Minecraft.World/DerivedLevelData.cpp b/Minecraft.World/DerivedLevelData.cpp index bc7bf03f..b7bd441c 100644 --- a/Minecraft.World/DerivedLevelData.cpp +++ b/Minecraft.World/DerivedLevelData.cpp @@ -180,6 +180,10 @@ bool DerivedLevelData::isHardcore() return wrapped->isHardcore(); } +void DerivedLevelData::setHardcore(bool hardcore) +{ +} + LevelType *DerivedLevelData::getGenerator() { return wrapped->getGenerator(); diff --git a/Minecraft.World/DerivedLevelData.h b/Minecraft.World/DerivedLevelData.h index 9b439053..306f12dd 100644 --- a/Minecraft.World/DerivedLevelData.h +++ b/Minecraft.World/DerivedLevelData.h @@ -53,6 +53,7 @@ public: bool isGenerateMapFeatures(); void setGameType(GameType *gameType); bool isHardcore(); + void setHardcore(bool hardcore); LevelType *getGenerator(); void setGenerator(LevelType *generator); bool getAllowCommands(); diff --git a/Minecraft.World/DisconnectPacket.h b/Minecraft.World/DisconnectPacket.h index 3c96a429..b6648ddf 100644 --- a/Minecraft.World/DisconnectPacket.h +++ b/Minecraft.World/DisconnectPacket.h @@ -50,6 +50,7 @@ public: #ifdef _XBOX_ONE eDisconnect_ExitedGame, #endif + eDisconnect_HardcoreDeath, // 4J Added - for hardcore mode multiplayer ban-on-death }; // 4J Stu - The reason was a string, but we need to send a non-locale specific reason @@ -71,3 +72,4 @@ public: }; + diff --git a/Minecraft.World/EnderDragon.cpp b/Minecraft.World/EnderDragon.cpp index fe2a87eb..e9cc670f 100644 --- a/Minecraft.World/EnderDragon.cpp +++ b/Minecraft.World/EnderDragon.cpp @@ -1118,15 +1118,6 @@ bool EnderDragon::hurt(shared_ptr MultiEntityMobPart, Damage damage = damage / 4 + 1; } - //float rot1 = yRot * PI / 180; - //float ss1 = sin(rot1); - //float cc1 = cos(rot1); - - //xTarget = x + ss1 * 5 + (random->nextFloat() - 0.5f) * 2; - //yTarget = y + random->nextFloat() * 3 + 1; - //zTarget = z - cc1 * 5 + (random->nextFloat() - 0.5f) * 2; - //attackTarget = nullptr; - if ( source->getEntity() != nullptr && source->getEntity()->instanceof(eTYPE_PLAYER) || source->isExplosion() ) { int healthBefore = getHealth(); diff --git a/Minecraft.World/EnderDragon.h b/Minecraft.World/EnderDragon.h index f63e890e..861af9bb 100644 --- a/Minecraft.World/EnderDragon.h +++ b/Minecraft.World/EnderDragon.h @@ -183,7 +183,7 @@ public: double getHeadPartYRotDiff(int partIndex, doubleArray bodyPos, doubleArray partPos); Vec3 *getHeadLookVector(float a); - virtual wstring getAName() { return app.GetString(IDS_ENDERDRAGON); }; + virtual wstring getAName() { if (hasCustomName()) return getCustomName(); return app.GetString(IDS_ENDERDRAGON); }; virtual float getHealth() { return LivingEntity::getHealth(); }; virtual float getMaxHealth() { return LivingEntity::getMaxHealth(); }; virtual int getDimension() { return Entity::dimension; } diff --git a/Minecraft.World/Entity.cpp b/Minecraft.World/Entity.cpp index 6d295f13..5042a9b2 100644 --- a/Minecraft.World/Entity.cpp +++ b/Minecraft.World/Entity.cpp @@ -705,6 +705,8 @@ void Entity::move(double xa, double ya, double za, bool noEntityCubes) // 4J - return; } + auto self = shared_from_this(); + ySlideOffset *= 0.4f; double xo = x; @@ -734,21 +736,21 @@ void Entity::move(double xa, double ya, double za, bool noEntityCubes) // 4J - if (isPlayerSneaking) { double d = 0.05; - while (xa != 0 && level->getCubes(shared_from_this(), bb->cloneMove(xa, -1.0, 0))->empty()) + while (xa != 0 && level->getCubes(self, bb->cloneMove(xa, -1.0, 0), true)->empty()) { if (xa < d && xa >= -d) xa = 0; else if (xa > 0) xa -= d; else xa += d; xaOrg = xa; } - while (za != 0 && level->getCubes(shared_from_this(), bb->cloneMove(0, -1.0, za))->empty()) + while (za != 0 && level->getCubes(self, bb->cloneMove(0, -1.0, za), true)->empty()) { if (za < d && za >= -d) za = 0; else if (za > 0) za -= d; else za += d; zaOrg = za; } - while (xa != 0 && za != 0 && level->getCubes(shared_from_this(), bb->cloneMove(xa, -1.0, za))->empty()) + while (xa != 0 && za != 0 && level->getCubes(self, bb->cloneMove(xa, -1.0, za), true)->empty()) { if (xa < d && xa >= -d) xa = 0; else if (xa > 0) xa -= d; @@ -761,7 +763,7 @@ void Entity::move(double xa, double ya, double za, bool noEntityCubes) // 4J - } } - AABBList *aABBs = level->getCubes(shared_from_this(), bb->expand(xa, ya, za), noEntityCubes, true); + AABBList *aABBs = level->getCubes(self, bb->expand(xa, ya, za), noEntityCubes, true); // 4J Stu - Particles (and possibly other entities) don't have xChunk and zChunk set, so calculate the chunk instead @@ -816,7 +818,7 @@ void Entity::move(double xa, double ya, double za, bool noEntityCubes) // 4J - bb->set(bbOrg); // 4J - added extra expand, as if we don't move up by footSize by hitting a block above us, then overall we could be trying to move as much as footSize downwards, // so we'd better include cubes under our feet in this list of things we might possibly collide with - aABBs = level->getCubes(shared_from_this(), bb->expand(xa, ya, za)->expand(0,-ya,0),false,true); + aABBs = level->getCubes(self, bb->expand(xa, ya, za)->expand(0,-ya,0),false,true); if(!level->isClientSide || level->reallyHasChunk(xc, zc)) { @@ -926,7 +928,7 @@ void Entity::move(double xa, double ya, double za, bool noEntityCubes) // 4J - playSound(eSoundType_LIQUID_SWIM, speed, 1 + (random->nextFloat() - random->nextFloat()) * 0.4f); } playStepSound(xt, yt, zt, t); - Tile::tiles[t]->stepOn(level, xt, yt, zt, shared_from_this()); + Tile::tiles[t]->stepOn(level, xt, yt, zt, self); } } @@ -969,6 +971,7 @@ void Entity::checkInsideTiles() if (level->hasChunksAt(x0, y0, z0, x1, y1, z1)) { + auto self = shared_from_this(); for (int x = x0; x <= x1; x++) for (int y = y0; y <= y1; y++) for (int z = z0; z <= z1; z++) @@ -976,7 +979,7 @@ void Entity::checkInsideTiles() int t = level->getTile(x, y, z); if (t > 0) { - Tile::tiles[t]->entityInside(level, x, y, z, shared_from_this()); + Tile::tiles[t]->entityInside(level, x, y, z, self); } } } diff --git a/Minecraft.World/Level.cpp b/Minecraft.World/Level.cpp index 19126b24..1a2c9b51 100644 --- a/Minecraft.World/Level.cpp +++ b/Minecraft.World/Level.cpp @@ -3687,13 +3687,11 @@ vector > *Level::getEntities(shared_ptr except, AABB int zc0 = Mth::floor((bb->z0 - 2) / 16); int zc1 = Mth::floor((bb->z1 + 2) / 16); -#ifdef __PSVITA__ #ifdef _ENTITIES_RW_SECTION // AP - RW critical sections are expensive so enter it here so we only have to call it once instead of X times EnterCriticalRWSection(&LevelChunk::m_csEntities, false); #else EnterCriticalSection(&LevelChunk::m_csEntities); -#endif #endif for (int xc = xc0; xc <= xc1; xc++) @@ -3706,12 +3704,10 @@ vector > *Level::getEntities(shared_ptr except, AABB } MemSect(0); -#ifdef __PSVITA__ #ifdef _ENTITIES_RW_SECTION LeaveCriticalRWSection(&LevelChunk::m_csEntities, false); #else LeaveCriticalSection(&LevelChunk::m_csEntities); -#endif #endif return &es; @@ -3730,13 +3726,11 @@ vector > *Level::getEntitiesOfClass(const type_info& baseClas int zc1 = Mth::floor((bb->z1 + 2) / 16); vector > *es = new vector >(); -#ifdef __PSVITA__ #ifdef _ENTITIES_RW_SECTION // AP - RW critical sections are expensive so enter it here so we only have to call it once instead of X times EnterCriticalRWSection(&LevelChunk::m_csEntities, false); #else EnterCriticalSection(&LevelChunk::m_csEntities); -#endif #endif for (int xc = xc0; xc <= xc1; xc++) @@ -3750,12 +3744,10 @@ vector > *Level::getEntitiesOfClass(const type_info& baseClas } } -#ifdef __PSVITA__ #ifdef _ENTITIES_RW_SECTION LeaveCriticalRWSection(&LevelChunk::m_csEntities, false); #else LeaveCriticalSection(&LevelChunk::m_csEntities); -#endif #endif return es; diff --git a/Minecraft.World/LevelChunk.cpp b/Minecraft.World/LevelChunk.cpp index a1387d6c..5800abd7 100644 --- a/Minecraft.World/LevelChunk.cpp +++ b/Minecraft.World/LevelChunk.cpp @@ -817,8 +817,9 @@ void LevelChunk::recalcHeight(int x, int yStart, int z) { minHeight = y; } - else + else if (yOld == minHeight) { + // Only rescan when the column that was at the minimum changed height int min = Level::maxBuildHeight - 1; for (int _x = 0; _x < 16; _x++) for (int _z = 0; _z < 16; _z++) @@ -1643,10 +1644,7 @@ void LevelChunk::getEntities(shared_ptr except, AABB *bb, vector= ENTITY_BLOCKS_LENGTH) yc1 = ENTITY_BLOCKS_LENGTH - 1; -#ifndef __PSVITA__ - // AP - RW critical sections are expensive so enter once in Level::getEntities - EnterCriticalSection(&m_csEntities); -#endif + // Lock is now always held by Level::getEntities() for (int yc = yc0; yc <= yc1; yc++) { vector > *entities = entityBlocks[yc]; @@ -1671,9 +1669,6 @@ void LevelChunk::getEntities(shared_ptr except, AABB *bb, vector > &es, const EntitySelector *selector) @@ -1698,10 +1693,7 @@ void LevelChunk::getEntitiesOfClass(const type_info& ec, AABB *bb, vector > *entities = entityBlocks[yc]; @@ -1729,9 +1721,6 @@ void LevelChunk::getEntitiesOfClass(const type_info& ec, AABB *bb, vectorgetLong(L"RandomSeed"); + + // Allow server.properties to override the world seed for biome generation + // on existing worlds (e.g. to fix monotonous biomes after world expansion). + if (app.HasSeedOverride()) + { + app.DebugPrintf("Overriding world seed: %lld -> %lld\n", seed, app.GetSeedOverride()); + seed = app.GetSeedOverride(); + } + m_pGenerator = LevelType::lvl_normal; if (tag->contains(L"generatorName")) { @@ -671,6 +680,11 @@ bool LevelData::isHardcore() return hardcore; } +void LevelData::setHardcore(bool hardcore) +{ + this->hardcore = hardcore; +} + bool LevelData::getAllowCommands() { return allowCommands; diff --git a/Minecraft.World/LevelData.h b/Minecraft.World/LevelData.h index 9c8d08ed..2f17d827 100644 --- a/Minecraft.World/LevelData.h +++ b/Minecraft.World/LevelData.h @@ -141,6 +141,7 @@ public: virtual wstring getGeneratorOptions(); virtual void setGeneratorOptions(const wstring &options); virtual bool isHardcore(); + virtual void setHardcore(bool hardcore); virtual bool getAllowCommands(); virtual void setAllowCommands(bool allowCommands); virtual bool isInitialized(); diff --git a/Minecraft.World/LivingEntity.cpp b/Minecraft.World/LivingEntity.cpp index 9d3e7f7b..a7e8dc1d 100644 --- a/Minecraft.World/LivingEntity.cpp +++ b/Minecraft.World/LivingEntity.cpp @@ -1409,16 +1409,12 @@ void LivingEntity::jumpFromGround() void LivingEntity::travel(float xa, float ya) { -#ifdef __PSVITA__ - // AP - dynamic_pointer_cast is a non-trivial call + // AP - dynamic_pointer_cast is a non-trivial call, use raw pointer instead Player *thisPlayer = nullptr; - if( this->instanceof(eTYPE_PLAYER) ) + if (this->instanceof(eTYPE_PLAYER)) { thisPlayer = (Player*) this; } -#else - shared_ptr thisPlayer = dynamic_pointer_cast(shared_from_this()); -#endif if (isInWater() && !(thisPlayer && thisPlayer->abilities.flying) ) { double yo = y; @@ -1453,13 +1449,14 @@ void LivingEntity::travel(float xa, float ya) else { float friction = 0.91f; + int frictionTile = 0; if (onGround) { + frictionTile = level->getTile(Mth::floor(x), Mth::floor(bb->y0) - 1, Mth::floor(z)); friction = 0.6f * 0.91f; - int t = level->getTile(Mth::floor(x), Mth::floor(bb->y0) - 1, Mth::floor(z)); - if (t > 0) + if (frictionTile > 0) { - friction = Tile::tiles[t]->friction * 0.91f; + friction = Tile::tiles[frictionTile]->friction * 0.91f; } } @@ -1481,10 +1478,9 @@ void LivingEntity::travel(float xa, float ya) if (onGround) { friction = 0.6f * 0.91f; - int t = level->getTile( Mth::floor(x), Mth::floor(bb->y0) - 1, Mth::floor(z)); - if (t > 0) + if (frictionTile > 0) { - friction = Tile::tiles[t]->friction * 0.91f; + friction = Tile::tiles[frictionTile]->friction * 0.91f; } } if (onLadder()) diff --git a/Minecraft.World/LoginPacket.cpp b/Minecraft.World/LoginPacket.cpp index 79cdfbae..1fe74d3c 100644 --- a/Minecraft.World/LoginPacket.cpp +++ b/Minecraft.World/LoginPacket.cpp @@ -34,6 +34,7 @@ LoginPacket::LoginPacket() m_uiGamePrivileges = 0; m_xzSize = LEVEL_MAX_WIDTH; m_hellScale = HELL_LEVEL_MAX_SCALE; + m_isHardcore = false; } // Client -> Server @@ -63,10 +64,11 @@ LoginPacket::LoginPacket(const wstring& userName, int clientVersion, PlayerUID o m_uiGamePrivileges = 0; m_xzSize = LEVEL_MAX_WIDTH; m_hellScale = HELL_LEVEL_MAX_SCALE; + m_isHardcore = false; } // Server -> Client -LoginPacket::LoginPacket(const wstring& userName, int clientVersion, LevelType *pLevelType, int64_t seed, int gameType, char dimension, BYTE mapHeight, BYTE maxPlayers, char difficulty, INT multiplayerInstanceId, BYTE playerIndex, bool newSeaLevel, unsigned int uiGamePrivileges, int xzSize, int hellScale) +LoginPacket::LoginPacket(const wstring& userName, int clientVersion, LevelType *pLevelType, int64_t seed, int gameType, char dimension, BYTE mapHeight, BYTE maxPlayers, char difficulty, INT multiplayerInstanceId, BYTE playerIndex, bool newSeaLevel, unsigned int uiGamePrivileges, int xzSize, int hellScale, bool isHardcore) { this->userName = userName; this->clientVersion = clientVersion; @@ -92,6 +94,7 @@ LoginPacket::LoginPacket(const wstring& userName, int clientVersion, LevelType * m_uiGamePrivileges = uiGamePrivileges; m_xzSize = xzSize; m_hellScale = hellScale; + m_isHardcore = isHardcore; } void LoginPacket::read(DataInputStream *dis) //throws IOException @@ -106,6 +109,8 @@ void LoginPacket::read(DataInputStream *dis) //throws IOException } seed = dis->readLong(); gameType = dis->readInt(); + m_isHardcore = (gameType & 0x8) != 0; + gameType = gameType & ~0x8; dimension = dis->readByte(); mapHeight = dis->readByte(); maxPlayers = dis->readByte(); @@ -144,7 +149,7 @@ void LoginPacket::write(DataOutputStream *dos) //throws IOException writeUtf(m_pLevelType->getGeneratorName(), dos); } dos->writeLong(seed); - dos->writeInt(gameType); + dos->writeInt(m_isHardcore ? (gameType | 0x8) : gameType); dos->writeByte(dimension); dos->writeByte(mapHeight); dos->writeByte(maxPlayers); diff --git a/Minecraft.World/LoginPacket.h b/Minecraft.World/LoginPacket.h index 02a62b60..e168ef79 100644 --- a/Minecraft.World/LoginPacket.h +++ b/Minecraft.World/LoginPacket.h @@ -24,6 +24,7 @@ public: unsigned int m_uiGamePrivileges; int m_xzSize; // 4J Added int m_hellScale; // 4J Added + bool m_isHardcore; // 4J Added - for hardcore mode // 1.8.2 int gameType; @@ -31,7 +32,7 @@ public: BYTE maxPlayers; LoginPacket(); - LoginPacket(const wstring& userName, int clientVersion, LevelType *pLevelType, int64_t seed, int gameType, char dimension, BYTE mapHeight, BYTE maxPlayers, char difficulty, INT m_multiplayerInstanceId, BYTE playerIndex, bool newSeaLevel, unsigned int uiGamePrivileges, int xzSize, int hellScale); // Server -> Client + LoginPacket(const wstring& userName, int clientVersion, LevelType *pLevelType, int64_t seed, int gameType, char dimension, BYTE mapHeight, BYTE maxPlayers, char difficulty, INT m_multiplayerInstanceId, BYTE playerIndex, bool newSeaLevel, unsigned int uiGamePrivileges, int xzSize, int hellScale, bool isHardcore = false); // Server -> Client LoginPacket(const wstring& userName, int clientVersion, PlayerUID offlineXuid, PlayerUID onlineXuid, bool friendsOnlyUGC, DWORD ugcPlayersVersion, DWORD skinId, DWORD capeId, bool isGuest); // Client -> Server virtual void read(DataInputStream *dis); diff --git a/Minecraft.World/Packet.cpp b/Minecraft.World/Packet.cpp index 1769c2ac..963fe5de 100644 --- a/Minecraft.World/Packet.cpp +++ b/Minecraft.World/Packet.cpp @@ -401,20 +401,32 @@ void Packet::writeUtf(const wstring& value, DataOutputStream *dos) // throws IOE wstring Packet::readUtf(DataInputStream *dis, int maxLength) // throws IOException TODO 4J JEV, should this declare a throws? { + // Global safety cap to prevent memory exhaustion from malicious string lengths + static const int kMaxGlobalStringLength = 8192; + if (maxLength > kMaxGlobalStringLength) + { + maxLength = kMaxGlobalStringLength; + } short stringLength = dis->readShort(); - if (stringLength > maxLength || stringLength <= 0) + if (stringLength <= 0) { - return L""; - // throw new IOException( stream.str() ); + if (stringLength < 0) + { + app.DebugPrintf("SECURITY: readUtf received negative string length %d\n", stringLength); + } + return L""; } - if (stringLength < 0) + if (stringLength > maxLength) { - assert(false); - // throw new IOException(L"Received string length is less than zero! Weird string!"); + app.DebugPrintf("SECURITY: readUtf received string length %d exceeding max %d\n", stringLength, maxLength); + // Consume the declared bytes to keep the stream synchronized + dis->skip(static_cast(stringLength) * 2); + return L""; } wstring builder = L""; + builder.reserve(stringLength); for (int i = 0; i < stringLength; i++) { wchar_t rc = dis->readChar(); diff --git a/Minecraft.World/PortalForcer.cpp b/Minecraft.World/PortalForcer.cpp index fb853945..049e2d36 100644 --- a/Minecraft.World/PortalForcer.cpp +++ b/Minecraft.World/PortalForcer.cpp @@ -93,7 +93,7 @@ bool PortalForcer::findPortal(shared_ptr e, double xOriginal, double yOr int xc = Mth::floor(e->x); int zc = Mth::floor(e->z); - long hash = ChunkPos::hashCode(xc, zc); + long hash = ChunkPos::hashCode(xc >> 4, zc >> 4); bool updateCache = true; auto it = cachedPortals.find(hash); @@ -148,8 +148,17 @@ bool PortalForcer::findPortal(shared_ptr e, double xOriginal, double yOr if (updateCache) { - cachedPortals[hash] = new PortalPosition(x, y, z, level->getGameTime()); - cachedPortalKeys.push_back(hash); + auto existing = cachedPortals.find(hash); + if (existing != cachedPortals.end()) + { + delete existing->second; + existing->second = new PortalPosition(x, y, z, level->getGameTime()); + } + else + { + cachedPortals[hash] = new PortalPosition(x, y, z, level->getGameTime()); + cachedPortalKeys.push_back(hash); + } } double xt = x + 0.5; diff --git a/Minecraft.World/PortalTile.cpp b/Minecraft.World/PortalTile.cpp index 5e664e65..1b64212b 100644 --- a/Minecraft.World/PortalTile.cpp +++ b/Minecraft.World/PortalTile.cpp @@ -67,21 +67,8 @@ bool PortalTile::isCubeShaped() return false; } -bool PortalTile::trySpawnPortal(Level *level, int x, int y, int z, bool actuallySpawn) +bool PortalTile::validPortalFrame(Level* level, int x, int y, int z, int xd, int zd, bool actuallySpawn) { - int xd = 0; - int zd = 0; - if (level->getTile(x - 1, y, z) == Tile::obsidian_Id || level->getTile(x + 1, y, z) == Tile::obsidian_Id) xd = 1; - if (level->getTile(x, y, z - 1) == Tile::obsidian_Id || level->getTile(x, y, z + 1) == Tile::obsidian_Id) zd = 1; - - if (xd == zd) return false; - - if (level->getTile(x - xd, y, z - zd) == 0) - { - x -= xd; - z -= zd; - } - for (int xx = -1; xx <= 2; xx++) { for (int yy = -1; yy <= 3; yy++) @@ -101,9 +88,7 @@ bool PortalTile::trySpawnPortal(Level *level, int x, int y, int z, bool actually } } } - - if( !actuallySpawn ) - return true; + if (!actuallySpawn) return true; for (int xx = 0; xx < 2; xx++) { @@ -112,9 +97,52 @@ bool PortalTile::trySpawnPortal(Level *level, int x, int y, int z, bool actually level->setTileAndData(x + xd * xx, y + yy, z + zd * xx, Tile::portalTile_Id, 0, Tile::UPDATE_CLIENTS); } } - + return true; +} +bool PortalTile::trySpawnPortal(Level *level, int x, int y, int z, bool actuallySpawn) +{ + int xd = 0; + int zd = 0; + if (level->getTile(x - 1, y, z) == Tile::obsidian_Id || level->getTile(x + 1, y, z) == Tile::obsidian_Id) xd = 1; + if (level->getTile(x, y, z - 1) == Tile::obsidian_Id || level->getTile(x, y, z + 1) == Tile::obsidian_Id) zd = 1; + + bool twoPosible = false; // two neth portals posible (x and z direction) + if (xd == zd) + { + if (xd == 1) twoPosible = true; + else return false; + } + + bool changedx = false; // changed x so it can be reverted if two portals are posible + if (level->getTile(x - xd, y, z) == 0) + { + changedx = true; + x--; + } + else if (level->getTile(x, y, z - zd) == 0 && !twoPosible) + { + z--; + } + + if (!twoPosible) + { + if (!validPortalFrame(level, x, y, z, xd, zd, actuallySpawn)) return false; + } + else + { + if (!validPortalFrame(level, x, y, z, xd, 0, actuallySpawn)) + { + if (changedx) x++; // revert x (this check wants to check z not x and z) + + if (level->getTile(x, y, z - zd) == 0) z--; + + if (!validPortalFrame(level, x, y, z, 0, zd, actuallySpawn)) + return false; + } + } + return true; } void PortalTile::neighborChanged(Level *level, int x, int y, int z, int type) diff --git a/Minecraft.World/PortalTile.h b/Minecraft.World/PortalTile.h index 7be4320f..7415efbf 100644 --- a/Minecraft.World/PortalTile.h +++ b/Minecraft.World/PortalTile.h @@ -13,6 +13,7 @@ public: virtual void updateShape(LevelSource *level, int x, int y, int z, int forceData = -1, shared_ptr forceEntity = shared_ptr()); // 4J added forceData, forceEntity param virtual bool isSolidRender(bool isServerLevel = false); virtual bool isCubeShaped(); + virtual bool validPortalFrame(Level* level, int x, int y, int z, int xd, int zd, bool actuallySpawn); virtual bool trySpawnPortal(Level *level, int x, int y, int z, bool actuallySpawn); virtual void neighborChanged(Level *level, int x, int y, int z, int type); virtual bool shouldRenderFace(LevelSource *level, int x, int y, int z, int face); diff --git a/Minecraft.World/PreLoginPacket.cpp b/Minecraft.World/PreLoginPacket.cpp index d5c2d832..05cca90a 100644 --- a/Minecraft.World/PreLoginPacket.cpp +++ b/Minecraft.World/PreLoginPacket.cpp @@ -6,7 +6,7 @@ -PreLoginPacket::PreLoginPacket() +PreLoginPacket::PreLoginPacket() { loginKey = L""; m_playerXuids = nullptr; @@ -20,7 +20,7 @@ PreLoginPacket::PreLoginPacket() m_netcodeVersion = 0; } -PreLoginPacket::PreLoginPacket(wstring userName) +PreLoginPacket::PreLoginPacket(wstring userName) { this->loginKey = userName; m_playerXuids = nullptr; @@ -34,7 +34,7 @@ PreLoginPacket::PreLoginPacket(wstring userName) m_netcodeVersion = 0; } -PreLoginPacket::PreLoginPacket(wstring userName, PlayerUID *playerXuids, DWORD playerCount, BYTE friendsOnlyBits, DWORD ugcPlayersVersion,char *pszUniqueSaveName, DWORD serverSettings, BYTE hostIndex, DWORD texturePackId) +PreLoginPacket::PreLoginPacket(wstring userName, PlayerUID *playerXuids, DWORD playerCount, BYTE friendsOnlyBits, DWORD ugcPlayersVersion,char *pszUniqueSaveName, DWORD serverSettings, BYTE hostIndex, DWORD texturePackId) { this->loginKey = userName; m_playerXuids = playerXuids; diff --git a/Minecraft.World/RespawnPacket.cpp b/Minecraft.World/RespawnPacket.cpp index 6dbebfac..c4a19c8c 100644 --- a/Minecraft.World/RespawnPacket.cpp +++ b/Minecraft.World/RespawnPacket.cpp @@ -17,9 +17,10 @@ RespawnPacket::RespawnPacket() m_newEntityId = 0; m_xzSize = LEVEL_MAX_WIDTH; m_hellScale = HELL_LEVEL_MAX_SCALE; + m_isHardcore = false; } -RespawnPacket::RespawnPacket(char dimension, int64_t mapSeed, int mapHeight, GameType *playerGameType, char difficulty, LevelType *pLevelType, bool newSeaLevel, int newEntityId, int xzSize, int hellScale) +RespawnPacket::RespawnPacket(char dimension, int64_t mapSeed, int mapHeight, GameType *playerGameType, char difficulty, LevelType *pLevelType, bool newSeaLevel, int newEntityId, int xzSize, int hellScale, bool isHardcore) { this->dimension = dimension; this->mapSeed = mapSeed; @@ -31,6 +32,7 @@ RespawnPacket::RespawnPacket(char dimension, int64_t mapSeed, int mapHeight, Gam this->m_newEntityId = newEntityId; m_xzSize = xzSize; m_hellScale = hellScale; + m_isHardcore = isHardcore; app.DebugPrintf("RespawnPacket - Difficulty = %d\n",difficulty); } @@ -43,7 +45,9 @@ void RespawnPacket::handle(PacketListener *listener) void RespawnPacket::read(DataInputStream *dis) //throws IOException { dimension = dis->readByte(); - playerGameType = GameType::byId(dis->readByte()); + int rawGameType = dis->readByte(); + m_isHardcore = (rawGameType & 0x8) != 0; + playerGameType = GameType::byId(rawGameType & ~0x8); mapHeight = dis->readShort(); wstring typeName = readUtf(dis, 16); m_pLevelType = LevelType::getLevelType(typeName); @@ -66,7 +70,7 @@ void RespawnPacket::read(DataInputStream *dis) //throws IOException void RespawnPacket::write(DataOutputStream *dos) //throws IOException { dos->writeByte(dimension); - dos->writeByte(playerGameType->getId()); + dos->writeByte(m_isHardcore ? (playerGameType->getId() | 0x8) : playerGameType->getId()); dos->writeShort(mapHeight); if (m_pLevelType == nullptr) { diff --git a/Minecraft.World/RespawnPacket.h b/Minecraft.World/RespawnPacket.h index dc815994..bcc72592 100644 --- a/Minecraft.World/RespawnPacket.h +++ b/Minecraft.World/RespawnPacket.h @@ -19,9 +19,10 @@ public: int m_newEntityId; int m_xzSize; // 4J Added int m_hellScale; // 4J Added + bool m_isHardcore; // 4J Added - for hardcore mode RespawnPacket(); - RespawnPacket(char dimension, int64_t mapSeed, int mapHeight, GameType *playerGameType, char difficulty, LevelType *pLevelType, bool newSeaLevel, int newEntityId, int xzSize, int hellScale); + RespawnPacket(char dimension, int64_t mapSeed, int mapHeight, GameType *playerGameType, char difficulty, LevelType *pLevelType, bool newSeaLevel, int newEntityId, int xzSize, int hellScale, bool isHardcore = false); virtual void handle(PacketListener *listener); virtual void read(DataInputStream *dis); diff --git a/Minecraft.World/SignTileEntity.cpp b/Minecraft.World/SignTileEntity.cpp index c482b016..afc35697 100644 --- a/Minecraft.World/SignTileEntity.cpp +++ b/Minecraft.World/SignTileEntity.cpp @@ -50,11 +50,10 @@ void SignTileEntity::save(CompoundTag *tag) tag->putString(L"Text3", m_wsmessages[2] ); tag->putString(L"Text4", m_wsmessages[3] ); #ifndef _CONTENT_PACKAGE - OutputDebugStringW(L"### - Saving a sign with text - \n"); + app.DebugPrintf("[SIGN] Saving sign at (%d, %d, %d):\n", x, y, z); for(int i=0;i<4;i++) { - OutputDebugStringW(m_wsmessages[i].c_str()); - OutputDebugStringW(L"\n"); + app.DebugPrintf("[SIGN] Line%d: \"%ls\"\n", i+1, m_wsmessages[i].c_str()); } #endif } @@ -65,8 +64,8 @@ void SignTileEntity::load(CompoundTag *tag) TileEntity::load(tag); for (int i = 0; i < MAX_SIGN_LINES; i++) { - wchar_t *buf = new wchar_t[256]; - swprintf(buf, 256, L"Text%d", (i+1) ); + wchar_t buf[16]; + swprintf(buf, 16, L"Text%d", (i+1) ); m_wsmessages[i] = tag->getString( buf ); if (m_wsmessages[i].length() > MAX_LINE_LENGTH) m_wsmessages[i] = m_wsmessages[i].substr(0, MAX_LINE_LENGTH); } diff --git a/Minecraft.World/WitherBoss.h b/Minecraft.World/WitherBoss.h index a0d98379..26ab240c 100644 --- a/Minecraft.World/WitherBoss.h +++ b/Minecraft.World/WitherBoss.h @@ -105,6 +105,6 @@ public: // 4J Stu - These are required for the BossMob interface virtual float getMaxHealth() { return Monster::getMaxHealth(); }; virtual float getHealth() { return Monster::getHealth(); }; - virtual wstring getAName() { return app.GetString(IDS_WITHER); }; + virtual wstring getAName() { if (hasCustomName()) return getCustomName(); return app.GetString(IDS_WITHER); }; virtual int getDimension() { return Entity::dimension; } }; \ No newline at end of file diff --git a/Minecraft.World/cmake/sources/Common.cmake b/Minecraft.World/cmake/sources/Common.cmake index 5752df23..c3e83803 100644 --- a/Minecraft.World/cmake/sources/Common.cmake +++ b/Minecraft.World/cmake/sources/Common.cmake @@ -11,6 +11,8 @@ set(_MINECRAFT_WORLD_COMMON_CONSOLEHELPERS "${CMAKE_CURRENT_SOURCE_DIR}/HashExtension.h" "${CMAKE_CURRENT_SOURCE_DIR}/PerformanceTimer.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/PerformanceTimer.h" + "${CMAKE_CURRENT_SOURCE_DIR}/ArabicShaping.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ArabicShaping.h" "${CMAKE_CURRENT_SOURCE_DIR}/StringHelpers.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/StringHelpers.h" "${CMAKE_CURRENT_SOURCE_DIR}/ThreadName.cpp" diff --git a/Minecraft.World/x64headers/extraX64.h b/Minecraft.World/x64headers/extraX64.h index 03f1b6ac..204f5366 100644 --- a/Minecraft.World/x64headers/extraX64.h +++ b/Minecraft.World/x64headers/extraX64.h @@ -3,6 +3,7 @@ #include #include #include +#include #include "../../Minecraft.Client/SkinBox.h" @@ -11,7 +12,8 @@ #define MULTITHREAD_ENABLE -typedef unsigned char byte; +// byte is already defined by rpcndr.h (via windows.h) +// std::byte is suppressed via _HAS_STD_BYTE=0 in CMakeLists.txt const int XUSER_INDEX_ANY = 255; const int XUSER_INDEX_FOCUS = 254; diff --git a/Update-NightlyRelease.ps1 b/Update-NightlyRelease.ps1 new file mode 100644 index 00000000..00e45879 --- /dev/null +++ b/Update-NightlyRelease.ps1 @@ -0,0 +1,316 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Builds zips for client and server, updates their Nightly GitHub releases, and archives locally. +.DESCRIPTION + 1. Fetches the latest commit hash. + 2. Zips client (build\Minecraft.Client\Release) and server (build\Minecraft.Server\Release) builds into the archive folder. + 3. Updates the Nightly release: deletes old assets, uploads client zip + exe + pdb, updates title. + 4. Updates the Nightly-Dedicated-Server release: deletes old assets, uploads server zip, updates title. +.NOTES + Requires GITHUB_TOKEN environment variable to be set. +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# --- Configuration --- +$RepoOwner = "itsRevela" +$RepoName = "MinecraftConsoles" +$ReleaseTag = "Nightly" +$ReleaseDir = Join-Path $PSScriptRoot "build\Minecraft.Client\Release" +$ZipName = "LCREWindows64.zip" +$ServerReleaseTag = "Nightly-Dedicated-Server" +$ServerReleaseDir = Join-Path $PSScriptRoot "build\Minecraft.Server\Release" +$ServerZipName = "LCREServerWindows64.zip" +$ArchiveRoot = "C:\Users\revela\Documents\Minecraft\itsRevelaReleases" +$ApiBase = "https://api.github.com/repos/$RepoOwner/$RepoName" + +$Token = $env:GITHUB_TOKEN +if (-not $Token) { + Write-Error "GITHUB_TOKEN environment variable is not set. Generate a token at https://github.com/settings/tokens with 'repo' scope." + exit 1 +} + +$Headers = @{ + Authorization = "token $Token" + Accept = "application/vnd.github+json" +} + +# --- Step 1: Get latest commit hash (needed for archive folder and title) --- +Write-Host "==> Fetching latest commit hash..." -ForegroundColor Cyan + +$latestCommit = Invoke-RestMethod -Uri "$ApiBase/commits?per_page=1" -Headers $Headers -Method Get +$fullHash = $latestCommit[0].sha +$shortHash = $fullHash.Substring(0, 7) +Write-Host " Latest commit: $shortHash" + +# --- Step 2: Create archive folder and zip directly into it --- +Write-Host "==> Creating $ZipName in archive folder..." -ForegroundColor Cyan + +$dateStr = (Get-Date).ToString("MM-dd-yyyy") +$archiveFolder = Join-Path $ArchiveRoot "${dateStr}_${shortHash}" + +if (-not (Test-Path $archiveFolder)) { + New-Item -ItemType Directory -Path $archiveFolder -Force | Out-Null +} + +$ZipPath = Join-Path $archiveFolder $ZipName + +if (Test-Path $ZipPath) { + Remove-Item $ZipPath -Force +} + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$topLevelFolder = "LCREWindows64" + +$fs = [System.IO.File]::Open($ZipPath, [System.IO.FileMode]::Create) +try { + $zip = New-Object System.IO.Compression.ZipArchive($fs, [System.IO.Compression.ZipArchiveMode]::Create) + + try { + $basePath = (Resolve-Path $ReleaseDir).Path + + # Add empty directories so they exist when extracted + Get-ChildItem -Path $basePath -Recurse -Directory | ForEach-Object { + $dirFullPath = $_.FullName + $relativePath = $dirFullPath.Substring($basePath.Length).TrimStart('\','/') + $entryName = ($topLevelFolder + "/" + ($relativePath -replace '\\','/') + "/") + # Creating an entry with a trailing slash makes an empty directory in the zip + $zip.CreateEntry($entryName) | Out-Null + } + + Get-ChildItem -Path $basePath -Recurse -File | ForEach-Object { + $fullPath = $_.FullName + + if ($_.Extension -ieq ".pch" -or $_.Extension -ieq ".zip") { + return + } + + $relativePath = $fullPath.Substring($basePath.Length).TrimStart('\','/') + $entryName = ($topLevelFolder + "/" + ($relativePath -replace '\\','/')) + + Write-Host " Adding: $entryName" + + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $zip, + $fullPath, + $entryName, + [System.IO.Compression.CompressionLevel]::Optimal + ) | Out-Null + } + } + finally { + $zip.Dispose() + } +} +finally { + $fs.Dispose() +} + +Write-Host " Created: $ZipPath" -ForegroundColor Green + +# --- Step 2b: Zip the server build into the same archive folder --- +Write-Host "==> Creating $ServerZipName in archive folder..." -ForegroundColor Cyan + +$ServerZipPath = Join-Path $archiveFolder $ServerZipName + +if (Test-Path $ServerZipPath) { + Remove-Item $ServerZipPath -Force +} + +$serverTopLevelFolder = "LCREServerWindows64" + +$sfs = [System.IO.File]::Open($ServerZipPath, [System.IO.FileMode]::Create) +try { + $szip = New-Object System.IO.Compression.ZipArchive($sfs, [System.IO.Compression.ZipArchiveMode]::Create) + + try { + $serverBasePath = (Resolve-Path $ServerReleaseDir).Path + + # Add empty directories so they exist when extracted + Get-ChildItem -Path $serverBasePath -Recurse -Directory | ForEach-Object { + $dirFullPath = $_.FullName + $relativePath = $dirFullPath.Substring($serverBasePath.Length).TrimStart('\','/') + $entryName = ($serverTopLevelFolder + "/" + ($relativePath -replace '\\','/') + "/") + $szip.CreateEntry($entryName) | Out-Null + } + + Get-ChildItem -Path $serverBasePath -Recurse -File | ForEach-Object { + $fullPath = $_.FullName + + if ($_.Extension -ieq ".pch" -or $_.Extension -ieq ".zip") { + return + } + + $relativePath = $fullPath.Substring($serverBasePath.Length).TrimStart('\','/') + $entryName = ($serverTopLevelFolder + "/" + ($relativePath -replace '\\','/')) + + Write-Host " Adding: $entryName" + + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $szip, + $fullPath, + $entryName, + [System.IO.Compression.CompressionLevel]::Optimal + ) | Out-Null + } + } + finally { + $szip.Dispose() + } +} +finally { + $sfs.Dispose() +} + +Write-Host " Created: $ServerZipPath" -ForegroundColor Green + +# --- Step 3: Delete and recreate the Nightly-Dedicated-Server release (first, so client is most recent) --- +Write-Host "==> Fetching Nightly-Dedicated-Server release info..." -ForegroundColor Cyan + +$serverRelease = Invoke-RestMethod -Uri "$ApiBase/releases/tags/$ServerReleaseTag" -Headers $Headers -Method Get +$serverReleaseId = $serverRelease.id +$serverCurrentTitle = $serverRelease.name +$serverReleaseBody = if ($serverRelease.body) { $serverRelease.body } else { "" } +Write-Host " Release ID: $serverReleaseId" +Write-Host " Current title: $serverCurrentTitle" + +# Compute new title +$serverNewTitle = $serverCurrentTitle -replace '(?<=Server:\s{1,4})[0-9a-f]{7}', $shortHash +if ($serverNewTitle -eq $serverCurrentTitle -and $serverCurrentTitle -notmatch $shortHash) { + $serverNewTitle = "Server: $shortHash" + Write-Host " Warning: Could not parse existing title format, using fallback." -ForegroundColor Yellow +} + +Write-Host "==> Deleting old Nightly-Dedicated-Server release..." -ForegroundColor Cyan +Invoke-RestMethod -Uri "$ApiBase/releases/$serverReleaseId" -Headers $Headers -Method Delete +Write-Host " Deleted." -ForegroundColor Green + +# Force-update the server tag to point to the latest commit +Write-Host "==> Updating $ServerReleaseTag tag to $shortHash..." -ForegroundColor Cyan +$serverTagRefBody = @{ sha = $fullHash; force = $true } | ConvertTo-Json +Invoke-RestMethod -Uri "$ApiBase/git/refs/tags/$ServerReleaseTag" -Headers $Headers -Method Patch -Body $serverTagRefBody -ContentType "application/json" | Out-Null +Write-Host " Tag updated." -ForegroundColor Green + +# Create fresh release +Write-Host "==> Creating new Nightly-Dedicated-Server release..." -ForegroundColor Cyan +$serverCreateBody = @{ + tag_name = $ServerReleaseTag + name = $serverNewTitle + body = $serverReleaseBody + draft = $false + prerelease = $false +} | ConvertTo-Json +$newServerRelease = Invoke-RestMethod -Uri "$ApiBase/releases" -Headers $Headers -Method Post -Body $serverCreateBody -ContentType "application/json" +$serverReleaseId = $newServerRelease.id +Write-Host " Created release ID: $serverReleaseId" -ForegroundColor Green +Write-Host " Title: $serverNewTitle" + +# --- Step 4: Upload server zip --- +Write-Host "==> Uploading server assets..." -ForegroundColor Cyan + +$serverUploadBase = "https://uploads.github.com/repos/$RepoOwner/$RepoName/releases/$serverReleaseId/assets" + +if (-not (Test-Path $ServerZipPath)) { + Write-Error "File not found: $ServerZipPath" + exit 1 +} + +$serverUploadUrl = "$serverUploadBase`?name=$ServerZipName" +$serverFileBytes = [System.IO.File]::ReadAllBytes($ServerZipPath) +$serverSizeMB = [math]::Round($serverFileBytes.Length / 1MB, 2) +Write-Host " Uploading: $ServerZipName ($serverSizeMB MB)..." + +Invoke-RestMethod -Uri $serverUploadUrl -Headers @{ + Authorization = "token $Token" + Accept = "application/vnd.github+json" + "Content-Type" = "application/zip" +} -Method Post -Body $serverFileBytes | Out-Null + +Write-Host " Uploaded: $ServerZipName" -ForegroundColor Green + +# --- Step 5: Delete and recreate the Nightly release (last, so it shows as most recent) --- +# Deleting and recreating gives a fresh "released just now" timestamp. +Write-Host "==> Fetching Nightly release info..." -ForegroundColor Cyan + +$release = Invoke-RestMethod -Uri "$ApiBase/releases/tags/$ReleaseTag" -Headers $Headers -Method Get +$releaseId = $release.id +$currentTitle = $release.name +$releaseBody = if ($release.body) { $release.body } else { "" } +Write-Host " Release ID: $releaseId" +Write-Host " Current title: $currentTitle" + +# Compute new title +$newTitle = $currentTitle -replace '(?<=Client:\s{1,4})[0-9a-f]{7}', $shortHash +if ($newTitle -eq $currentTitle -and $currentTitle -notmatch $shortHash) { + $newTitle = "Latest: $shortHash" + Write-Host " Warning: Could not parse existing title format, using fallback." -ForegroundColor Yellow +} + +Write-Host "==> Deleting old Nightly release..." -ForegroundColor Cyan +Invoke-RestMethod -Uri "$ApiBase/releases/$releaseId" -Headers $Headers -Method Delete +Write-Host " Deleted." -ForegroundColor Green + +# Force-update the tag to point to the latest commit +Write-Host "==> Updating $ReleaseTag tag to $shortHash..." -ForegroundColor Cyan +$tagRefBody = @{ sha = $fullHash; force = $true } | ConvertTo-Json +Invoke-RestMethod -Uri "$ApiBase/git/refs/tags/$ReleaseTag" -Headers $Headers -Method Patch -Body $tagRefBody -ContentType "application/json" | Out-Null +Write-Host " Tag updated." -ForegroundColor Green + +# Create fresh release +Write-Host "==> Creating new Nightly release..." -ForegroundColor Cyan +$createBody = @{ + tag_name = $ReleaseTag + name = $newTitle + body = $releaseBody + draft = $false + prerelease = $false +} | ConvertTo-Json +$newRelease = Invoke-RestMethod -Uri "$ApiBase/releases" -Headers $Headers -Method Post -Body $createBody -ContentType "application/json" +$releaseId = $newRelease.id +Write-Host " Created release ID: $releaseId" -ForegroundColor Green +Write-Host " Title: $newTitle" + +# --- Step 6: Upload new assets --- +Write-Host "==> Uploading new assets..." -ForegroundColor Cyan + +$uploadBase = "https://uploads.github.com/repos/$RepoOwner/$RepoName/releases/$releaseId/assets" + +$filesToUpload = @( + @{ Path = $ZipPath; Name = $ZipName; ContentType = "application/zip" } + @{ Path = Join-Path $ReleaseDir "Minecraft.Client.exe"; Name = "Minecraft.Client.exe"; ContentType = "application/octet-stream" } +) + +foreach ($file in $filesToUpload) { + $filePath = $file.Path + if (-not (Test-Path $filePath)) { + Write-Error "File not found: $filePath" + exit 1 + } + + $uploadUrl = "$uploadBase`?name=$($file.Name)" + $fileBytes = [System.IO.File]::ReadAllBytes($filePath) + $sizeMB = [math]::Round($fileBytes.Length / 1MB, 2) + Write-Host " Uploading: $($file.Name) ($sizeMB MB)..." + + Invoke-RestMethod -Uri $uploadUrl -Headers @{ + Authorization = "token $Token" + Accept = "application/vnd.github+json" + "Content-Type" = $file.ContentType + } -Method Post -Body $fileBytes | Out-Null + + Write-Host " Uploaded: $($file.Name)" -ForegroundColor Green +} + +# --- Done --- +Write-Host "" +Write-Host "==> Nightly releases updated successfully!" -ForegroundColor Green +Write-Host " Commit: $shortHash" +Write-Host " Client title: $newTitle" +Write-Host " Client assets: $ZipName, Minecraft.Client.exe" +Write-Host " Server title: $serverNewTitle" +Write-Host " Server assets: $ServerZipName" +Write-Host " Archive: $archiveFolder" diff --git a/docker-compose.dedicated-server.ghcr.yml b/docker-compose.dedicated-server.ghcr.yml index 8bcaf484..cd6e45f0 100644 --- a/docker-compose.dedicated-server.ghcr.yml +++ b/docker-compose.dedicated-server.ghcr.yml @@ -1,6 +1,6 @@ services: minecraft-lce-dedicated-server: - image: ghcr.io/kuwacom/minecraft-lce-dedicated-server:nightly + image: ghcr.io/itsrevela/minecraft-lce-dedicated-server:nightly container_name: minecraft-lce-dedicated-server restart: unless-stopped tty: true diff --git a/docker-compose.dedicated-server.yml b/docker-compose.dedicated-server.yml index 4a0d3313..23caf26f 100644 --- a/docker-compose.dedicated-server.yml +++ b/docker-compose.dedicated-server.yml @@ -10,7 +10,7 @@ services: tty: true stdin_open: true environment: - TZ: ${TZ:-Asia/Tokyo} + TZ: ${TZ:-Etc/UTC} WINEARCH: win64 WINEPREFIX: /var/opt/wineprefix64 WINEDEBUG: -all @@ -18,13 +18,16 @@ services: SERVER_CLI_INPUT_MODE: ${SERVER_CLI_INPUT_MODE:-stream} # minimum required virtual screen XVFB_DISPLAY: ${XVFB_DISPLAY:-:99} - XVFB_SCREEN: ${XVFB_SCREEN:-64x64x16} + XVFB_SCREEN: ${XVFB_SCREEN:-720x1280x16} + # ip & port the server will run on + SERVER_BIND_IP: ${SERVER_BIND_IP:-0.0.0.0} + SERVER_PORT: ${SERVER_PORT:-25565} volumes: # - wineprefix64:/var/opt/wineprefix64 - ./server-data:/srv/persist ports: - - "25565:25565/tcp" - - "25565:25565/udp" + - "$SERVER_PORT:$SERVER_PORT/tcp" + - "$SERVER_PORT:$SERVER_PORT/udp" stop_grace_period: 30s # volumes: diff --git a/docker/dedicated-server/entrypoint.sh b/docker/dedicated-server/entrypoint.sh index 26b40da9..0eece274 100644 --- a/docker/dedicated-server/entrypoint.sh +++ b/docker/dedicated-server/entrypoint.sh @@ -3,9 +3,8 @@ set -euo pipefail SERVER_DIR="/srv/mc" SERVER_EXE="Minecraft.Server.exe" -# ip & port are fixed since they run inside the container -SERVER_PORT="25565" -SERVER_BIND_IP="0.0.0.0" +SERVER_PORT="${SERVER_PORT:-25565}" +SERVER_BIND_IP="${SERVER_BIND_IP:-0.0.0.0}" PERSIST_DIR="/srv/persist" WINE_CMD="" diff --git a/tools/AddExclusiveFullscreenCheckbox.class b/tools/AddExclusiveFullscreenCheckbox.class new file mode 100644 index 00000000..09973d12 Binary files /dev/null and b/tools/AddExclusiveFullscreenCheckbox.class differ diff --git a/tools/AddExclusiveFullscreenCheckbox.java b/tools/AddExclusiveFullscreenCheckbox.java new file mode 100644 index 00000000..cbfbf8d5 --- /dev/null +++ b/tools/AddExclusiveFullscreenCheckbox.java @@ -0,0 +1,158 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.tags.base.*; +import com.jpexs.decompiler.flash.types.*; +import java.io.*; +import java.util.*; + +/** + * Adds an "ExclusiveFullscreen" checkbox to SettingsGraphicsMenu SWF files. + * Clones the existing VSync checkbox, renames it "ExclusiveFullscreen", + * positions it below VSync, and shifts sliders down. + */ +public class AddExclusiveFullscreenCheckbox { + public static void main(String[] args) throws Exception { + if (args.length < 1) { + System.out.println("Usage: AddExclusiveFullscreenCheckbox [output_file]"); + System.exit(1); + } + + String inputPath = args[0]; + String outputPath = args.length > 1 ? args[1] : inputPath; + + SWF swf = new SWF(new FileInputStream(inputPath), false); + + // Check if ExclusiveFullscreen already exists + for (Tag tag : swf.getTags()) { + if (tag instanceof PlaceObject3Tag) { + PlaceObject3Tag po = (PlaceObject3Tag) tag; + if ("ExclusiveFullscreen".equals(po.name)) { + System.out.println("ExclusiveFullscreen checkbox already exists in " + inputPath + ", skipping."); + return; + } + } + } + + // Find the VSync checkbox tag and all slider tags + PlaceObject3Tag vsyncTag = null; + PlaceObject3Tag customSkinAnimTag = null; + List sliderTags = new ArrayList<>(); + int maxDepth = 0; + + for (Tag tag : swf.getTags()) { + if (tag instanceof PlaceObject3Tag) { + PlaceObject3Tag po = (PlaceObject3Tag) tag; + if ("VSync".equals(po.name)) { + vsyncTag = po; + } + if ("CustomSkinAnim".equals(po.name)) { + customSkinAnimTag = po; + } + if (po.name != null && (po.name.equals("RenderDistance") || po.name.equals("Gamma") + || po.name.equals("FOV") || po.name.equals("InterfaceOpacity"))) { + sliderTags.add(po); + } + if (po.depth > maxDepth) maxDepth = po.depth; + } + if (tag instanceof PlaceObject2Tag) { + PlaceObject2Tag po = (PlaceObject2Tag) tag; + if (po.depth > maxDepth) maxDepth = po.depth; + } + } + + if (vsyncTag == null) { + System.err.println("ERROR: Could not find VSync checkbox in " + inputPath); + System.exit(1); + } + + // Calculate checkbox Y-spacing from the gap between CustomSkinAnim and VSync + int checkboxSpacing; + if (customSkinAnimTag != null) { + checkboxSpacing = vsyncTag.matrix.translateY - customSkinAnimTag.matrix.translateY; + } else { + checkboxSpacing = vsyncTag.matrix.translateY > 8000 ? 1080 : 720; + } + + System.out.println("File: " + inputPath); + System.out.println(" VSync Y: " + vsyncTag.matrix.translateY); + System.out.println(" Checkbox spacing: " + checkboxSpacing); + + // Create the ExclusiveFullscreen PlaceObject3Tag by copying fields from VSync + PlaceObject3Tag efTag = new PlaceObject3Tag(swf); + efTag.placeFlagHasClassName = vsyncTag.placeFlagHasClassName; + efTag.placeFlagHasName = true; + efTag.placeFlagHasMatrix = true; + efTag.placeFlagHasImage = vsyncTag.placeFlagHasImage; + efTag.placeFlagHasCharacter = vsyncTag.placeFlagHasCharacter; + efTag.placeFlagMove = vsyncTag.placeFlagMove; + efTag.className = vsyncTag.className; + efTag.name = "ExclusiveFullscreen"; + efTag.depth = maxDepth + 1; + efTag.characterId = vsyncTag.characterId; + + // Copy and offset the matrix + MATRIX m = new MATRIX(vsyncTag.matrix); + m.translateY += checkboxSpacing; + efTag.matrix = m; + + efTag.setModified(true); + + System.out.println(" ExclusiveFullscreen Y: " + m.translateY); + System.out.println(" ExclusiveFullscreen depth: " + efTag.depth); + + // Shift all sliders down by checkboxSpacing + for (PlaceObject3Tag slider : sliderTags) { + System.out.println(" Shifting " + slider.name + " from Y=" + slider.matrix.translateY + + " to Y=" + (slider.matrix.translateY + checkboxSpacing)); + slider.matrix.translateY += checkboxSpacing; + slider.setModified(true); + } + + // Expand the background panel height + for (Tag tag : swf.getTags()) { + if (tag instanceof PlaceObject2Tag) { + PlaceObject2Tag po = (PlaceObject2Tag) tag; + if ("BackgroundPanel".equals(po.name)) { + int charId = po.characterId; + CharacterTag ct = swf.getCharacter(charId); + if (ct instanceof DefineSpriteTag) { + DefineSpriteTag sprite = (DefineSpriteTag) ct; + for (Tag sub : sprite.getTags()) { + if (sub instanceof PlaceObject3Tag) { + PlaceObject3Tag gridTag = (PlaceObject3Tag) sub; + float oldScaleY = gridTag.matrix.scaleY; + gridTag.matrix.scaleY += (float) checkboxSpacing / 640.0f; + gridTag.setModified(true); + System.out.println(" Background panel scaleY: " + oldScaleY + " -> " + gridTag.matrix.scaleY); + } + } + } + } + } + } + + // Insert ExclusiveFullscreen tag right after VSync + ArrayList tagList = swf.getTags().toArrayList(); + int insertIdx = -1; + for (int i = 0; i < tagList.size(); i++) { + if (tagList.get(i) == vsyncTag) { + insertIdx = i + 1; + break; + } + } + + if (insertIdx >= 0) { + swf.addTag(insertIdx, efTag); + System.out.println(" Inserted ExclusiveFullscreen tag at index " + insertIdx); + } else { + System.err.println("ERROR: Could not find insertion point"); + System.exit(1); + } + + // Save + try (FileOutputStream fos = new FileOutputStream(outputPath)) { + swf.saveTo(fos); + } + System.out.println(" Saved to: " + outputPath); + } +} diff --git a/tools/AddHardcoreBitmaps.java b/tools/AddHardcoreBitmaps.java new file mode 100644 index 00000000..91fe8c8b --- /dev/null +++ b/tools/AddHardcoreBitmaps.java @@ -0,0 +1,115 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.tags.base.*; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.*; +import java.util.*; + +/** + * Adds hardcore heart bitmap assets to a skinGraphicsHud SWF. + * These bitmaps are referenced by class name from PlaceObject3 tags in + * the health sprite frames defined in the parent skinHud/skinHDHud SWF. + * + * Usage: AddHardcoreBitmaps + */ +public class AddHardcoreBitmaps { + static final String[] TEXTURE_NAMES = { + "Health_Background_Hardcore", + "Health_Background_Hardcore_Flash", + "Health_Full_Hardcore", + "Health_Half_Hardcore", + "Health_Full_Flash_Hardcore", + "Health_Half_Flash_Hardcore", + "Health_Full_Poison_Hardcore", + "Health_Half_Poison_Hardcore", + "Health_Full_Poison_Flash_Hardcore", + "Health_Half_Poison_Flash_Hardcore" + }; + + public static void main(String[] args) throws Exception { + if (args.length < 3) { + System.out.println("Usage: AddHardcoreBitmaps "); + System.exit(1); + } + + String swfPath = args[0]; + String texturesDir = args[1]; + String outputPath = args[2]; + + System.out.println("Loading SWF: " + swfPath); + SWF swf = new SWF(new FileInputStream(swfPath), false); + + // Find max character ID + int maxCharId = 0; + for (Tag tag : swf.getTags()) { + if (tag instanceof CharacterTag) { + int id = ((CharacterTag) tag).getCharacterId(); + if (id > maxCharId) maxCharId = id; + } + } + System.out.println("Max existing character ID: " + maxCharId); + + // Find SymbolClass tag + SymbolClassTag symbolClass = null; + int symbolClassIdx = -1; + for (int i = 0; i < swf.getTags().size(); i++) { + if (swf.getTags().get(i) instanceof SymbolClassTag) { + symbolClass = (SymbolClassTag) swf.getTags().get(i); + symbolClassIdx = i; + break; + } + } + + if (symbolClass == null) { + System.err.println("ERROR: No SymbolClass tag found"); + System.exit(1); + } + + int nextCharId = maxCharId + 1; + int added = 0; + + for (String texName : TEXTURE_NAMES) { + String pngFile = texturesDir + "/" + texName + ".png"; + File f = new File(pngFile); + if (!f.exists()) { + System.err.println("ERROR: Missing texture: " + pngFile); + System.exit(1); + } + + BufferedImage img = ImageIO.read(f); + System.out.println(" Adding " + texName + " (id=" + nextCharId + ", " + img.getWidth() + "x" + img.getHeight() + ")"); + + // Create DefineBitsLossless2 tag + DefineBitsLossless2Tag bmp = new DefineBitsLossless2Tag(swf); + bmp.characterID = nextCharId; + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "PNG", baos); + bmp.setImage(baos.toByteArray()); + + // Insert before SymbolClass + swf.addTag(symbolClassIdx, bmp); + symbolClassIdx++; // SymbolClass shifted by 1 + + // Add to SymbolClass + symbolClass.tags.add(nextCharId); + symbolClass.names.add(texName); + + nextCharId++; + added++; + } + + System.out.println("Added " + added + " hardcore heart bitmaps"); + + // Mark SymbolClass as modified so new entries get serialized + symbolClass.setModified(true); + + // Save + System.out.println("Saving to: " + outputPath); + try (FileOutputStream fos = new FileOutputStream(outputPath)) { + swf.saveTo(fos); + } + System.out.println("Done!"); + } +} diff --git a/tools/AddHardcoreHearts.java b/tools/AddHardcoreHearts.java new file mode 100644 index 00000000..aa5eea60 --- /dev/null +++ b/tools/AddHardcoreHearts.java @@ -0,0 +1,405 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.tags.base.*; +import com.jpexs.decompiler.flash.types.*; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.*; +import java.util.*; + +/** + * Adds hardcore heart frames to the health sprite in skinHDHud.swf. + * + * The health sprite (FJ_Health) has 14 frames for normal/poison/wither states. + * This tool adds 10 new frames (15-24) for hardcore variants: + * Frame 15: Health_Empty_Hardcore (hardcore background) + * Frame 16: Health_Empty_Flash_Hardcore (hardcore background flash) + * Frame 17: Health_Full_Hardcore (hardcore bg + full) + * Frame 18: Health_Half_Hardcore (hardcore bg + half) + * Frame 19: Health_Full_Flash_Hardcore + * Frame 20: Health_Half_Flash_Hardcore + * Frame 21: Health_Full_Poison_Hardcore (hardcore bg + poison full) + * Frame 22: Health_Half_Poison_Hardcore + * Frame 23: Health_Full_Poison_Flash_Hardcore + * Frame 24: Health_Half_Poison_Flash_Hardcore + * + * Usage: AddHardcoreHearts + * + * The textures-dir should contain the hardcore PNGs: + * Health_Background_Hardcore.png, Health_Background_Hardcore_Flash.png, + * Health_Full_Hardcore.png, Health_Half_Hardcore.png, + * Health_Full_Flash_Hardcore.png, Health_Half_Flash_Hardcore.png, + * Health_Full_Poison_Hardcore.png, Health_Half_Poison_Hardcore.png, + * Health_Full_Poison_Flash_Hardcore.png, Health_Half_Poison_Flash_Hardcore.png + */ +public class AddHardcoreHearts { + public static void main(String[] args) throws Exception { + if (args.length < 3) { + System.out.println("Usage: AddHardcoreHearts "); + System.exit(1); + } + + String swfPath = args[0]; + String texturesDir = args[1]; + String outputPath = args[2]; + + System.out.println("Loading SWF: " + swfPath); + SWF swf = new SWF(new FileInputStream(swfPath), false); + + // Find the FJ_Health sprite by its SymbolClass name + Map symbolNames = new HashMap<>(); + for (Tag tag : swf.getTags()) { + if (tag instanceof SymbolClassTag) { + SymbolClassTag sym = (SymbolClassTag) tag; + for (int i = 0; i < sym.tags.size(); i++) { + symbolNames.put(sym.tags.get(i), sym.names.get(i)); + } + } + } + + // Find the health sprite + DefineSpriteTag healthSprite = null; + MATRIX existingMatrix = null; // Will capture from existing PlaceObject3 + for (Tag tag : swf.getTags()) { + if (tag instanceof DefineSpriteTag) { + DefineSpriteTag sprite = (DefineSpriteTag) tag; + String sym = symbolNames.getOrDefault(sprite.spriteId, ""); + if (sym.contains("FJ_Health") && !sym.contains("Absorb") && !sym.contains("Horse")) { + healthSprite = sprite; + System.out.println("Found health sprite: id=" + sprite.spriteId + " sym=" + sym + " frames=" + sprite.frameCount); + // Grab the matrix from the first PlaceObject3 in the sprite (Health_Background) + for (Tag sub : sprite.getTags()) { + if (sub instanceof PlaceObject3Tag) { + PlaceObject3Tag po = (PlaceObject3Tag) sub; + if (po.matrix != null) { + existingMatrix = po.matrix; + System.out.println("Captured matrix: " + existingMatrix); + break; + } + } + } + break; + } + } + } + + if (healthSprite == null) { + System.err.println("ERROR: Could not find FJ_Health sprite!"); + System.exit(1); + } + + // Find the highest character ID in the SWF to avoid conflicts + int maxCharId = 0; + for (Tag tag : swf.getTags()) { + if (tag instanceof CharacterTag) { + int id = ((CharacterTag) tag).getCharacterId(); + if (id > maxCharId) maxCharId = id; + } + } + System.out.println("Max existing character ID: " + maxCharId); + + // Load hardcore textures and create DefineBitsLossless2 tags + // We need to add these as top-level tags in the SWF, then reference them + // via PlaceObject in the health sprite frames + + // Map: texture name -> charId for the new bitmap + Map textureIds = new LinkedHashMap<>(); + String[] textureNames = { + "Health_Background_Hardcore", + "Health_Background_Hardcore_Flash", + "Health_Full_Hardcore", + "Health_Half_Hardcore", + "Health_Full_Flash_Hardcore", + "Health_Half_Flash_Hardcore", + "Health_Full_Poison_Hardcore", + "Health_Half_Poison_Hardcore", + "Health_Full_Poison_Flash_Hardcore", + "Health_Half_Poison_Flash_Hardcore" + }; + + // Find where to insert new tags (before SymbolClassTag) + int insertIndex = -1; + for (int i = 0; i < swf.getTags().size(); i++) { + if (swf.getTags().get(i) instanceof SymbolClassTag) { + insertIndex = i; + break; + } + } + + int nextCharId = maxCharId + 1; + SymbolClassTag symbolClass = null; + for (Tag tag : swf.getTags()) { + if (tag instanceof SymbolClassTag) { + symbolClass = (SymbolClassTag) tag; + break; + } + } + + for (String texName : textureNames) { + String pngFile = texturesDir + "/" + texName + ".png"; + File f = new File(pngFile); + if (!f.exists()) { + System.err.println("ERROR: Missing texture: " + pngFile); + System.exit(1); + } + + BufferedImage img = ImageIO.read(f); + System.out.println(" Loading " + texName + " (" + img.getWidth() + "x" + img.getHeight() + ")"); + + // Create DefineBitsLossless2 tag + DefineBitsLossless2Tag bmp = new DefineBitsLossless2Tag(swf); + bmp.characterID = nextCharId; + + // Convert image to PNG bytes for setImage + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "PNG", baos); + bmp.setImage(baos.toByteArray()); + + textureIds.put(texName, nextCharId); + + // Insert before SymbolClass + swf.addTag(insertIndex, bmp); + insertIndex++; + + // Add to SymbolClass so it can be referenced by name + symbolClass.tags.add(nextCharId); + symbolClass.names.add(texName); + + nextCharId++; + } + + System.out.println("Added " + textureIds.size() + " hardcore heart bitmaps"); + + // Mark SymbolClass as modified so new entries get serialized + symbolClass.setModified(true); + + // Now add 10 new frames to the health sprite + // Current structure: 14 frames, each has RemoveObject2, PlaceObject3(bg), PlaceObject3(heart), ShowFrame + // We follow the same pattern + + // Frame structure for each hardcore frame: + // 1. FrameLabelTag (name) + // 2. RemoveObject2 depth 1 (remove old bg) - only if not first frame + // 3. PlaceObject3 cls= depth 1 + // 4. RemoveObject2 depth 2 (remove old heart) - only if heart present + // 5. PlaceObject3 cls= depth 2 - if not empty + // 6. ShowFrameTag + + // The frame structure from the existing SWF: + // Each frame after the first uses RemoveObject2 to clear the previous frame's objects + // then places new objects + + // Define our 10 new frames + String[][] hardcoreFrames = { + // {label, bgTexture, heartTexture} (heartTexture null for empty) + {"Health_Empty_Hardcore", "Health_Background_Hardcore", null}, + {"Health_Empty_Flash_Hardcore", "Health_Background_Hardcore_Flash", null}, + {"Health_Full_Hardcore", "Health_Background_Hardcore", "Health_Full_Hardcore"}, + {"Health_Half_Hardcore", null, "Health_Half_Hardcore"}, // bg continues from prev + {"Health_Full_Flash_Hardcore", "Health_Background_Hardcore_Flash", "Health_Full_Flash_Hardcore"}, + {"Health_Half_Flash_Hardcore", null, "Health_Half_Flash_Hardcore"}, + {"Health_Full_Poison_Hardcore", "Health_Background_Hardcore", "Health_Full_Poison_Hardcore"}, + {"Health_Half_Poison_Hardcore", null, "Health_Half_Poison_Hardcore"}, + {"Health_Full_Poison_Flash_Hardcore","Health_Background_Hardcore_Flash", "Health_Full_Poison_Flash_Hardcore"}, + {"Health_Half_Poison_Flash_Hardcore",null, "Health_Half_Poison_Flash_Hardcore"}, + }; + + // Actually, looking at the existing structure more carefully: + // Frame 1 (Health_Empty): PlaceObject3 bg@1, ShowFrame + // Frame 2 (Health_Empty_Flash): Remove@1, PlaceObject3 bg_flash@1, ShowFrame + // Frame 3 (Health_Full): Remove@1, PlaceObject3 bg@1, PlaceObject3 full@2, ShowFrame + // Frame 4 (Health_Half): PlaceObject3 half@2 (replaces heart, bg continues), ShowFrame + // Wait - frame 4 reuses bg from frame 3. Let me re-check... + + // Looking at the dump: + // [8] FrameLabel "Health_Full" + // [9] PlaceObject3 cls=Health_Background dpt=1 + // [10] PlaceObject3 cls=Health_Full dpt=2 + // [11] ShowFrame + // [12] RemoveObject2 dpt=2 + // [13] FrameLabel "Health_Half" + // [14] PlaceObject3 cls=Health_Half dpt=2 + // [15] ShowFrame (bg from frame 3 still on depth 1) + // [16] RemoveObject2 dpt=1 + // [17] RemoveObject2 dpt=2 + // [18] FrameLabel "Health_Full_Flash" + // [19] PlaceObject3 cls=Health_Background_Flash dpt=1 + // [20] PlaceObject3 cls=Health_Full_Flash dpt=2 + // [21] ShowFrame + + // So the pattern is: + // - When bg changes: remove depth 1, place new bg at depth 1 + // - When heart changes: remove depth 2, place new heart at depth 2 + // - Half frames reuse the bg from the previous Full frame + + // For the last existing frame (14, Health_Half_Wither_Flash), it has objects at depth 1 and 2. + // We need to start our new frames by removing those. + + // Let me follow the exact same pattern as the existing frames, starting from a clean slate + // after the last existing frame. + + if (existingMatrix == null) { + System.err.println("ERROR: Could not find matrix from existing health sprite!"); + System.exit(1); + } + + // Build the new tags to append + List newTags = new ArrayList<>(); + + // Frame structure follows the original pattern: + // Empty: remove@1, place bg@1 (depth 2 empty) + // EmptyFlash: remove@1, place flashbg@1 (depth 2 empty) + // Full: remove@1, place bg@1, place full@2 (ADD heart, no remove@2) + // Half: remove@2, place half@2 (REPLACE heart, bg continues) + // FullFlash: remove@1, place flashbg@1, remove@2, place fullflash@2 + // HalfFlash: remove@2, place halfflash@2 (bg continues) + // (Poison/Wither follow the same Full/Half/FullFlash/HalfFlash pattern) + + // label, rmBg, bgTex, rmHrt, hrtTex + // Frame 15: Empty - remove both from frame 14 + addFrame(newTags, swf, healthSprite, "Health_Empty_Hardcore", + true, "Health_Background_Hardcore", + true, null, existingMatrix); + + // Frame 16: EmptyFlash + addFrame(newTags, swf, healthSprite, "Health_Empty_Flash_Hardcore", + true, "Health_Background_Hardcore_Flash", + false, null, existingMatrix); + + // Frame 17: Full - bg changes, heart ADDED (no remove@2, depth 2 was empty) + addFrame(newTags, swf, healthSprite, "Health_Full_Hardcore", + true, "Health_Background_Hardcore", + false, "Health_Full_Hardcore", existingMatrix); + + // Frame 18: Half - heart REPLACED (remove@2, then place), bg continues + addFrame(newTags, swf, healthSprite, "Health_Half_Hardcore", + false, null, + true, "Health_Half_Hardcore", existingMatrix); + + // Frame 19: FullFlash - both change + addFrame(newTags, swf, healthSprite, "Health_Full_Flash_Hardcore", + true, "Health_Background_Hardcore_Flash", + true, "Health_Full_Flash_Hardcore", existingMatrix); + + // Frame 20: HalfFlash - heart replaced, bg continues + addFrame(newTags, swf, healthSprite, "Health_Half_Flash_Hardcore", + false, null, + true, "Health_Half_Flash_Hardcore", existingMatrix); + + // Frame 21: FullPoison - both change + addFrame(newTags, swf, healthSprite, "Health_Full_Poison_Hardcore", + true, "Health_Background_Hardcore", + true, "Health_Full_Poison_Hardcore", existingMatrix); + + // Frame 22: HalfPoison - heart replaced, bg continues + addFrame(newTags, swf, healthSprite, "Health_Half_Poison_Hardcore", + false, null, + true, "Health_Half_Poison_Hardcore", existingMatrix); + + // Frame 23: FullPoisonFlash - both change + addFrame(newTags, swf, healthSprite, "Health_Full_Poison_Flash_Hardcore", + true, "Health_Background_Hardcore_Flash", + true, "Health_Full_Poison_Flash_Hardcore", existingMatrix); + + // Frame 24: HalfPoisonFlash - heart replaced, bg continues + addFrame(newTags, swf, healthSprite, "Health_Half_Poison_Flash_Hardcore", + false, null, + true, "Health_Half_Poison_Flash_Hardcore", existingMatrix); + + // Append new tags to sprite using addTag + int spriteInsertIdx = healthSprite.getTags().size(); + for (Tag newTag : newTags) { + healthSprite.addTag(spriteInsertIdx, newTag); + spriteInsertIdx++; + } + healthSprite.frameCount += 10; + + System.out.println("Added 10 hardcore frames. New frame count: " + healthSprite.frameCount); + + // Save + System.out.println("Saving modified SWF: " + outputPath); + try (FileOutputStream fos = new FileOutputStream(outputPath)) { + swf.saveTo(fos); + } + System.out.println("Done!"); + } + + /** + * Adds tags for a single frame in the health sprite. + * Uses the existingMatrix (cloned from existing PlaceObject3) to ensure correct encoding. + */ + /** + * @param removeBg emit RemoveObject2 for depth 1 before placing bg + * @param bgTexture if non-null, place this at depth 1 + * @param removeHeart emit RemoveObject2 for depth 2 before placing heart + * @param heartTexture if non-null, place this at depth 2 + */ + static void addFrame(List tags, SWF swf, DefineSpriteTag parent, + String label, + boolean removeBg, String bgTexture, + boolean removeHeart, String heartTexture, + MATRIX existingMatrix) { + + // Frame label + FrameLabelTag frameLabel = new FrameLabelTag(swf); + frameLabel.name = label; + frameLabel.setTimelined(parent); + tags.add(frameLabel); + + // Background at depth 1 + if (removeBg) { + RemoveObject2Tag rem = new RemoveObject2Tag(swf); + rem.depth = 1; + rem.setTimelined(parent); + tags.add(rem); + } + if (bgTexture != null) { + PlaceObject3Tag placeBg = new PlaceObject3Tag(swf); + placeBg.depth = 1; + placeBg.className = bgTexture; + placeBg.placeFlagHasMatrix = true; + placeBg.matrix = cloneMatrix(existingMatrix); + placeBg.placeFlagHasClassName = true; + placeBg.placeFlagHasCharacter = false; + placeBg.setTimelined(parent); + tags.add(placeBg); + } + + // Heart at depth 2 + if (removeHeart) { + RemoveObject2Tag rem = new RemoveObject2Tag(swf); + rem.depth = 2; + rem.setTimelined(parent); + tags.add(rem); + } + if (heartTexture != null) { + PlaceObject3Tag placeHeart = new PlaceObject3Tag(swf); + placeHeart.depth = 2; + placeHeart.className = heartTexture; + placeHeart.placeFlagHasMatrix = true; + placeHeart.matrix = cloneMatrix(existingMatrix); + placeHeart.placeFlagHasClassName = true; + placeHeart.placeFlagHasCharacter = false; + placeHeart.setTimelined(parent); + tags.add(placeHeart); + } + + // ShowFrame + ShowFrameTag showFrame = new ShowFrameTag(swf); + showFrame.setTimelined(parent); + tags.add(showFrame); + } + + static MATRIX cloneMatrix(MATRIX src) { + MATRIX m = new MATRIX(); + m.hasScale = src.hasScale; + m.scaleX = src.scaleX; + m.scaleY = src.scaleY; + m.hasRotate = src.hasRotate; + m.rotateSkew0 = src.rotateSkew0; + m.rotateSkew1 = src.rotateSkew1; + m.translateX = src.translateX; + m.translateY = src.translateY; + return m; + } +} diff --git a/tools/AddVSyncCheckbox.class b/tools/AddVSyncCheckbox.class new file mode 100644 index 00000000..ce0de93e Binary files /dev/null and b/tools/AddVSyncCheckbox.class differ diff --git a/tools/AddVSyncCheckbox.java b/tools/AddVSyncCheckbox.java new file mode 100644 index 00000000..618c6389 --- /dev/null +++ b/tools/AddVSyncCheckbox.java @@ -0,0 +1,160 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.tags.base.*; +import com.jpexs.decompiler.flash.types.*; +import java.io.*; +import java.util.*; + +/** + * Adds a "VSync" checkbox to SettingsGraphicsMenu SWF files. + * Clones the existing CustomSkinAnim checkbox, renames it "VSync", + * positions it below CustomSkinAnim, and shifts sliders down. + */ +public class AddVSyncCheckbox { + public static void main(String[] args) throws Exception { + if (args.length < 1) { + System.out.println("Usage: AddVSyncCheckbox [output_file]"); + System.exit(1); + } + + String inputPath = args[0]; + String outputPath = args.length > 1 ? args[1] : inputPath; + + SWF swf = new SWF(new FileInputStream(inputPath), false); + + // Check if VSync already exists + for (Tag tag : swf.getTags()) { + if (tag instanceof PlaceObject3Tag) { + PlaceObject3Tag po = (PlaceObject3Tag) tag; + if ("VSync".equals(po.name)) { + System.out.println("VSync checkbox already exists in " + inputPath + ", skipping."); + return; + } + } + } + + // Find the CustomSkinAnim checkbox tag and all slider tags + PlaceObject3Tag customSkinAnimTag = null; + PlaceObject3Tag bedrockFogTag = null; + List sliderTags = new ArrayList<>(); + int maxDepth = 0; + + for (Tag tag : swf.getTags()) { + if (tag instanceof PlaceObject3Tag) { + PlaceObject3Tag po = (PlaceObject3Tag) tag; + if ("CustomSkinAnim".equals(po.name)) { + customSkinAnimTag = po; + } + if ("BedrockFog".equals(po.name)) { + bedrockFogTag = po; + } + if (po.name != null && (po.name.equals("RenderDistance") || po.name.equals("Gamma") + || po.name.equals("FOV") || po.name.equals("InterfaceOpacity"))) { + sliderTags.add(po); + } + if (po.depth > maxDepth) maxDepth = po.depth; + } + if (tag instanceof PlaceObject2Tag) { + PlaceObject2Tag po = (PlaceObject2Tag) tag; + if (po.depth > maxDepth) maxDepth = po.depth; + } + } + + if (customSkinAnimTag == null) { + System.err.println("ERROR: Could not find CustomSkinAnim checkbox in " + inputPath); + System.exit(1); + } + + // Calculate checkbox Y-spacing + int checkboxSpacing; + if (bedrockFogTag != null) { + checkboxSpacing = customSkinAnimTag.matrix.translateY - bedrockFogTag.matrix.translateY; + } else { + checkboxSpacing = customSkinAnimTag.matrix.translateY > 8000 ? 1080 : 720; + } + + System.out.println("File: " + inputPath); + System.out.println(" CustomSkinAnim Y: " + customSkinAnimTag.matrix.translateY); + System.out.println(" Checkbox spacing: " + checkboxSpacing); + + // Create the VSync PlaceObject3Tag by copying fields from CustomSkinAnim + PlaceObject3Tag vsyncTag = new PlaceObject3Tag(swf); + vsyncTag.placeFlagHasClassName = customSkinAnimTag.placeFlagHasClassName; + vsyncTag.placeFlagHasName = true; + vsyncTag.placeFlagHasMatrix = true; + vsyncTag.placeFlagHasImage = customSkinAnimTag.placeFlagHasImage; + vsyncTag.placeFlagHasCharacter = customSkinAnimTag.placeFlagHasCharacter; + vsyncTag.placeFlagMove = customSkinAnimTag.placeFlagMove; + vsyncTag.className = customSkinAnimTag.className; + vsyncTag.name = "VSync"; + vsyncTag.depth = maxDepth + 1; + vsyncTag.characterId = customSkinAnimTag.characterId; + + // Copy and offset the matrix + MATRIX m = new MATRIX(customSkinAnimTag.matrix); + m.translateY += checkboxSpacing; + vsyncTag.matrix = m; + + vsyncTag.setModified(true); + + System.out.println(" VSync Y: " + m.translateY); + System.out.println(" VSync depth: " + vsyncTag.depth); + System.out.println(" VSync className: " + vsyncTag.className); + + // Shift all sliders down by checkboxSpacing + for (PlaceObject3Tag slider : sliderTags) { + System.out.println(" Shifting " + slider.name + " from Y=" + slider.matrix.translateY + + " to Y=" + (slider.matrix.translateY + checkboxSpacing)); + slider.matrix.translateY += checkboxSpacing; + slider.setModified(true); + } + + // Expand the background panel height + for (Tag tag : swf.getTags()) { + if (tag instanceof PlaceObject2Tag) { + PlaceObject2Tag po = (PlaceObject2Tag) tag; + if ("BackgroundPanel".equals(po.name)) { + int charId = po.characterId; + CharacterTag ct = swf.getCharacter(charId); + if (ct instanceof DefineSpriteTag) { + DefineSpriteTag sprite = (DefineSpriteTag) ct; + for (Tag sub : sprite.getTags()) { + if (sub instanceof PlaceObject3Tag) { + PlaceObject3Tag gridTag = (PlaceObject3Tag) sub; + float oldScaleY = gridTag.matrix.scaleY; + // The 9-grid base tile is 640 twips (32px * 20 twips/px) + gridTag.matrix.scaleY += (float) checkboxSpacing / 640.0f; + gridTag.setModified(true); + System.out.println(" Background panel scaleY: " + oldScaleY + " -> " + gridTag.matrix.scaleY); + } + } + } + } + } + } + + // Insert VSync tag right after CustomSkinAnim + ArrayList tagList = swf.getTags().toArrayList(); + int insertIdx = -1; + for (int i = 0; i < tagList.size(); i++) { + if (tagList.get(i) == customSkinAnimTag) { + insertIdx = i + 1; + break; + } + } + + if (insertIdx >= 0) { + swf.addTag(insertIdx, vsyncTag); + System.out.println(" Inserted VSync tag at index " + insertIdx); + } else { + System.err.println("ERROR: Could not find insertion point"); + System.exit(1); + } + + // Save + try (FileOutputStream fos = new FileOutputStream(outputPath)) { + swf.saveTo(fos); + } + System.out.println(" Saved to: " + outputPath); + } +} diff --git a/tools/CheckSetImage.java b/tools/CheckSetImage.java new file mode 100644 index 00000000..7c63f186 --- /dev/null +++ b/tools/CheckSetImage.java @@ -0,0 +1,12 @@ +import com.jpexs.decompiler.flash.tags.*; +import java.lang.reflect.*; + +public class CheckSetImage { + public static void main(String[] args) { + for (Method m : DefineBitsLossless2Tag.class.getMethods()) { + if (m.getName().contains("mage") || m.getName().contains("itmap") || m.getName().contains("set")) { + System.out.println(m.getReturnType().getSimpleName() + " " + m.getName() + "(" + java.util.Arrays.toString(m.getParameterTypes()) + ")"); + } + } + } +} diff --git a/tools/DecompileAS.java b/tools/DecompileAS.java new file mode 100644 index 00000000..e4dd09d4 --- /dev/null +++ b/tools/DecompileAS.java @@ -0,0 +1,106 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.abc.*; +import com.jpexs.decompiler.flash.abc.avm2.AVM2ConstantPool; +import com.jpexs.decompiler.flash.abc.types.*; +import com.jpexs.decompiler.flash.abc.types.traits.*; +import java.io.*; +import java.util.*; + +/** + * Decompiles ActionScript 3 class/method signatures from a SWF. + * Usage: DecompileAS + */ +public class DecompileAS { + public static void main(String[] args) throws Exception { + if (args.length < 1) { + System.out.println("Usage: DecompileAS "); + return; + } + + SWF swf = new SWF(new FileInputStream(args[0]), false); + + for (Tag tag : swf.getTags()) { + if (tag instanceof ImportAssets2Tag) { + ImportAssets2Tag imp = (ImportAssets2Tag) tag; + System.out.println("=== ImportAssets2 ==="); + System.out.println(" URL: " + imp.url); + } + + if (tag instanceof DoABC2Tag) { + DoABC2Tag abcTag = (DoABC2Tag) tag; + ABC abc = abcTag.getABC(); + AVM2ConstantPool cp = abc.constants; + + System.out.println("=== ABC: " + abcTag.name + " ==="); + System.out.println("Classes: " + abc.instance_info.size()); + System.out.println(); + + for (int c = 0; c < abc.instance_info.size(); c++) { + InstanceInfo ii = abc.instance_info.get(c); + System.out.println("--- Class: " + resolveName(cp, ii.name_index) + " ---"); + if (ii.super_index > 0) + System.out.println(" extends " + resolveName(cp, ii.super_index)); + + for (Trait t : ii.instance_traits.traits) { + dumpTrait(abc, cp, t, " "); + } + + ClassInfo ci = abc.class_info.get(c); + for (Trait t : ci.static_traits.traits) { + System.out.print(" [static] "); + dumpTrait(abc, cp, t, ""); + } + System.out.println(); + } + } + } + } + + static String resolveName(AVM2ConstantPool cp, int multinameIdx) { + if (multinameIdx <= 0) return "*"; + Multiname mn = cp.getMultiname(multinameIdx); + String name = mn.name_index > 0 ? cp.getString(mn.name_index) : "?"; + int nsIdx = mn.namespace_index; + if (nsIdx > 0) { + Namespace ns = cp.getNamespace(nsIdx); + String nsName = ns.name_index > 0 ? cp.getString(ns.name_index) : ""; + if (!nsName.isEmpty()) return nsName + "." + name; + } + return name; + } + + static void dumpTrait(ABC abc, AVM2ConstantPool cp, Trait t, String indent) { + try { + String name = resolveName(cp, t.name_index); + + if (t instanceof TraitMethodGetterSetter) { + TraitMethodGetterSetter tm = (TraitMethodGetterSetter) t; + MethodInfo mi = abc.method_info.get(tm.method_info); + + String kind = tm.kindType == Trait.TRAIT_GETTER ? "get" : + tm.kindType == Trait.TRAIT_SETTER ? "set" : "function"; + + StringBuilder sig = new StringBuilder(); + sig.append(kind).append(" ").append(name).append("("); + for (int p = 0; p < mi.param_types.length; p++) { + if (p > 0) sig.append(", "); + sig.append(resolveName(cp, mi.param_types[p])); + } + sig.append(")"); + sig.append(" : ").append(resolveName(cp, mi.ret_type)); + + System.out.println(indent + sig); + } else if (t instanceof TraitSlotConst) { + TraitSlotConst ts = (TraitSlotConst) t; + String type = resolveName(cp, ts.type_index); + String constKind = ts.kindType == Trait.TRAIT_CONST ? "const" : "var"; + System.out.println(indent + constKind + " " + name + " : " + type); + } else if (t instanceof TraitClass) { + System.out.println(indent + "[class] " + name); + } + } catch (Exception e) { + System.out.println(indent + "[error: " + e.getMessage() + "]"); + } + } +} diff --git a/tools/DecompileASBody.java b/tools/DecompileASBody.java new file mode 100644 index 00000000..bd9f32aa --- /dev/null +++ b/tools/DecompileASBody.java @@ -0,0 +1,48 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.exporters.modes.ScriptExportMode; +import com.jpexs.decompiler.flash.exporters.settings.ScriptExportSettings; +import com.jpexs.decompiler.flash.configuration.Configuration; +import java.io.*; +import java.util.*; + +/** + * Exports all ActionScript 3 source from a SWF to a directory. + * Usage: DecompileASBody [output-dir] + */ +public class DecompileASBody { + public static void main(String[] args) throws Exception { + if (args.length < 1) { + System.out.println("Usage: DecompileASBody [output-dir]"); + return; + } + + Configuration.autoDeobfuscate.set(false); + + String path = args[0]; + String outDir = args.length > 1 ? args[1] : "as_output"; + + SWF swf = new SWF(new FileInputStream(path), false); + + File out = new File(outDir); + out.mkdirs(); + + ScriptExportSettings settings = new ScriptExportSettings(ScriptExportMode.AS, false, false, false, false); + swf.exportActionScript(null, outDir, settings, false, null); + + System.out.println("Export complete. Files:"); + listFiles(new File(outDir), ""); + } + + static void listFiles(File dir, String prefix) { + File[] files = dir.listFiles(); + if (files == null) return; + Arrays.sort(files); + for (File f : files) { + if (f.isDirectory()) { + listFiles(f, prefix + f.getName() + "/"); + } else { + System.out.println(" " + prefix + f.getName() + " (" + f.length() + " bytes)"); + } + } + } +} diff --git a/tools/DumpSetHealthBC.java b/tools/DumpSetHealthBC.java new file mode 100644 index 00000000..b1d4b3e0 --- /dev/null +++ b/tools/DumpSetHealthBC.java @@ -0,0 +1,140 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.abc.*; +import com.jpexs.decompiler.flash.abc.avm2.AVM2ConstantPool; +import com.jpexs.decompiler.flash.abc.avm2.AVM2Code; +import com.jpexs.decompiler.flash.abc.avm2.instructions.*; +import com.jpexs.decompiler.flash.abc.types.*; +import com.jpexs.decompiler.flash.abc.types.traits.*; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.configuration.Configuration; +import java.io.*; +import java.util.*; + +/** + * Dumps the AVM2 bytecode of the SetHealth method from a HUD SWF. + */ +public class DumpSetHealthBC { + public static void main(String[] args) throws Exception { + Configuration.autoDeobfuscate.set(false); + SWF swf = new SWF(new FileInputStream(args[0]), false); + + for (Tag tag : swf.getTags()) { + if (!(tag instanceof DoABC2Tag)) continue; + DoABC2Tag abcTag = (DoABC2Tag) tag; + ABC abc = abcTag.getABC(); + AVM2ConstantPool cp = abc.constants; + + // Find Hud class + for (int c = 0; c < abc.instance_info.size(); c++) { + Multiname mn = cp.getMultiname(abc.instance_info.get(c).name_index); + String name = mn.name_index > 0 ? cp.getString(mn.name_index) : ""; + if (!"Hud".equals(name)) continue; + + InstanceInfo ii = abc.instance_info.get(c); + + // Find SetHealth method + for (Trait t : ii.instance_traits.traits) { + if (!(t instanceof TraitMethodGetterSetter)) continue; + Multiname tMn = cp.getMultiname(t.name_index); + String tName = tMn.name_index > 0 ? cp.getString(tMn.name_index) : ""; + if (!"SetHealth".equals(tName)) continue; + + TraitMethodGetterSetter tm = (TraitMethodGetterSetter) t; + MethodBody body = abc.findBody(tm.method_info); + if (body == null) continue; + + AVM2Code code = body.getCode(); + System.out.println("=== SetHealth bytecode ==="); + System.out.println("max_stack=" + body.max_stack + " max_regs=" + body.max_regs); + System.out.println(); + + for (int i = 0; i < code.code.size(); i++) { + AVM2Instruction inst = code.code.get(i); + StringBuilder sb = new StringBuilder(); + sb.append(String.format("[%3d] offset=%d opcode=0x%02X", i, inst.getAddress(), inst.definition.instructionCode)); + + // Show operands + if (inst.operands != null) { + for (int op : inst.operands) { + sb.append(" ").append(op); + } + } + + // Try to resolve multiname operands for property instructions + if (inst.operands != null && inst.operands.length > 0) { + int mnIdx = inst.operands[0]; + int opcode = inst.definition.instructionCode; + // Common property opcodes that use multiname as first operand + if (opcode == 0x66 || opcode == 0x61 || opcode == 0x5D || opcode == 0x60 || + opcode == 0x46 || opcode == 0x4F || opcode == 0x68) { + // getproperty, setproperty, findpropstrict, getlex, callproperty, callpropvoid, initproperty + try { + Multiname propMn = cp.getMultiname(mnIdx); + String propName = propMn.name_index > 0 ? cp.getString(propMn.name_index) : "?"; + sb.append(" -> ").append(propName); + } catch (Exception e) {} + } + } + + // Name the opcode + String opName = getOpName(inst.definition.instructionCode); + sb.append(" (").append(opName).append(")"); + + System.out.println(sb); + } + } + } + } + } + + static String getOpName(int opcode) { + switch (opcode) { + case 0xD0: return "getlocal0"; + case 0xD1: return "getlocal1"; + case 0xD2: return "getlocal2"; + case 0xD3: return "getlocal3"; + case 0xD4: return "setlocal0"; + case 0xD5: return "setlocal1"; + case 0xD6: return "setlocal2"; + case 0xD7: return "setlocal3"; + case 0x62: return "getlocal"; + case 0x63: return "setlocal"; + case 0x30: return "pushscope"; + case 0x47: return "returnvoid"; + case 0x48: return "returnvalue"; + case 0x66: return "getproperty"; + case 0x61: return "setproperty"; + case 0x5D: return "findpropstrict"; + case 0x60: return "getlex"; + case 0x24: return "pushbyte"; + case 0x25: return "pushint"; + case 0x26: return "pushtrue"; + case 0x27: return "pushfalse"; + case 0x20: return "pushnull"; + case 0xA0: return "add"; + case 0x73: return "convert_i"; + case 0x10: return "jump"; + case 0x12: return "iffalse"; + case 0x11: return "iftrue"; + case 0x15: return "iflt"; + case 0x16: return "ifle"; + case 0x13: return "ifgt"; + case 0x0C: return "ifnlt"; + case 0x46: return "callproperty"; + case 0x4F: return "callpropvoid"; + case 0x68: return "initproperty"; + case 0x4A: return "constructprop"; + case 0x75: return "convert_d"; + case 0x29: return "pop"; + case 0x2A: return "dup"; + case 0xAB: return "equals"; + case 0xAD: return "lessthan"; + case 0xAE: return "lessequals"; + case 0xAF: return "greaterthan"; + case 0xB0: return "greaterequals"; + case 0x96: return "not"; + case 0xA8: return "coerce_a"; + default: return String.format("0x%02X", opcode); + } + } +} diff --git a/tools/DumpSwf.java b/tools/DumpSwf.java new file mode 100644 index 00000000..2e719a98 --- /dev/null +++ b/tools/DumpSwf.java @@ -0,0 +1,40 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.tags.base.*; +import com.jpexs.decompiler.flash.types.*; +import java.io.*; +import java.util.*; + +public class DumpSwf { + public static void main(String[] args) throws Exception { + String path = args[0]; + SWF swf = new SWF(new FileInputStream(path), false); + + System.out.println("=== Top-level Tags ==="); + int idx = 0; + for (Tag tag : swf.getTags()) { + System.out.println("[" + idx + "] " + tag.getClass().getSimpleName() + " - " + tag); + dumpPlaceObject(tag, " "); + + if (tag instanceof DefineSpriteTag) { + DefineSpriteTag sprite = (DefineSpriteTag) tag; + System.out.println(" spriteId=" + sprite.spriteId + " frames=" + sprite.frameCount); + int subIdx = 0; + for (Tag sub : sprite.getTags()) { + System.out.println(" [" + subIdx + "] " + sub.getClass().getSimpleName() + " - " + sub); + dumpPlaceObject(sub, " "); + subIdx++; + } + } + idx++; + } + } + + static void dumpPlaceObject(Tag tag, String indent) { + if (tag instanceof PlaceObjectTypeTag) { + PlaceObjectTypeTag po = (PlaceObjectTypeTag) tag; + System.out.println(indent + "depth=" + po.getDepth() + " charId=" + po.getCharacterId() + + " name=" + po.getInstanceName() + " matrix=" + po.getMatrix()); + } + } +} diff --git a/tools/DumpSwfDetail.java b/tools/DumpSwfDetail.java new file mode 100644 index 00000000..ec122a40 --- /dev/null +++ b/tools/DumpSwfDetail.java @@ -0,0 +1,46 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.tags.base.*; +import com.jpexs.decompiler.flash.types.*; +import java.io.*; +import java.lang.reflect.*; +import java.util.*; + +public class DumpSwfDetail { + public static void main(String[] args) throws Exception { + String path = args[0]; + SWF swf = new SWF(new FileInputStream(path), false); + + System.out.println("SWF: " + path); + System.out.println("Frame size: " + swf.displayRect); + System.out.println("Frame rate: " + swf.frameRate); + System.out.println(); + + for (Tag tag : swf.getTags()) { + System.out.println(tag.getClass().getSimpleName() + " - " + tag); + + if (tag instanceof ImportAssets2Tag) { + ImportAssets2Tag imp = (ImportAssets2Tag) tag; + System.out.println(" URL: " + imp.url); + // Dump all fields via reflection + for (Field f : imp.getClass().getFields()) { + try { + System.out.println(" field " + f.getName() + " = " + f.get(imp)); + } catch (Exception e) {} + } + } + + if (tag instanceof DefineBitsLossless2Tag) { + DefineBitsLossless2Tag bmp = (DefineBitsLossless2Tag) tag; + System.out.println(" Bitmap: id=" + bmp.characterID + " " + bmp.bitmapWidth + "x" + bmp.bitmapHeight); + } + + if (tag instanceof SymbolClassTag) { + SymbolClassTag sym = (SymbolClassTag) tag; + for (int i = 0; i < sym.tags.size(); i++) { + System.out.println(" Symbol: id=" + sym.tags.get(i) + " name=" + sym.names.get(i)); + } + } + } + } +} diff --git a/tools/ExtractFromArc.java b/tools/ExtractFromArc.java new file mode 100644 index 00000000..22c5d305 --- /dev/null +++ b/tools/ExtractFromArc.java @@ -0,0 +1,42 @@ +import java.io.*; +import java.nio.file.*; + +public class ExtractFromArc { + public static void main(String[] args) throws Exception { + if (args.length < 3) { + System.out.println("Usage: ExtractFromArc "); + System.exit(1); + } + String arcPath = args[0]; + String target = args[1]; + String outPath = args[2]; + + DataInputStream dis = new DataInputStream(new FileInputStream(arcPath)); + int numFiles = dis.readInt(); + int foundOffset = -1, foundSize = -1; + for (int i = 0; i < numFiles; i++) { + String name = dis.readUTF(); + int offset = dis.readInt(); + int size = dis.readInt(); + if (name.startsWith("*")) name = name.substring(1); + if (name.equals(target)) { + foundOffset = offset; + foundSize = size; + } + } + dis.close(); + + if (foundOffset == -1) { + System.err.println("File not found in archive: " + target); + System.exit(1); + } + + RandomAccessFile raf = new RandomAccessFile(arcPath, "r"); + raf.seek(foundOffset); + byte[] data = new byte[foundSize]; + raf.readFully(data); + raf.close(); + Files.write(Paths.get(outPath), data); + System.out.println("Extracted " + target + " (" + foundSize + " bytes) -> " + outPath); + } +} diff --git a/tools/FindLogoBitmap.java b/tools/FindLogoBitmap.java new file mode 100644 index 00000000..fc096716 --- /dev/null +++ b/tools/FindLogoBitmap.java @@ -0,0 +1,63 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.tags.base.*; +import java.io.*; +import java.util.*; + +public class FindLogoBitmap { + public static void main(String[] args) throws Exception { + SWF swf = new SWF(new FileInputStream(args[0]), false); + + // Find MenuTitle symbol + Map symbolMap = new HashMap<>(); + for (Tag tag : swf.getTags()) { + if (tag instanceof SymbolClassTag) { + SymbolClassTag sym = (SymbolClassTag) tag; + for (int i = 0; i < sym.tags.size(); i++) { + symbolMap.put(sym.tags.get(i), sym.names.get(i)); + if (sym.names.get(i).contains("Title") || sym.names.get(i).contains("Logo")) { + System.out.println("Symbol: id=" + sym.tags.get(i) + " name=" + sym.names.get(i)); + } + } + } + if (tag instanceof ExportAssetsTag) { + ExportAssetsTag exp = (ExportAssetsTag) tag; + for (int i = 0; i < exp.tags.size(); i++) { + if (exp.names.get(i).contains("Title") || exp.names.get(i).contains("Logo")) { + System.out.println("Export: id=" + exp.tags.get(i) + " name=" + exp.names.get(i)); + } + } + } + } + + // Find all bitmaps + System.out.println("\n=== Bitmaps ==="); + for (Tag tag : swf.getTags()) { + if (tag instanceof DefineBitsLossless2Tag) { + DefineBitsLossless2Tag bmp = (DefineBitsLossless2Tag) tag; + String sym = symbolMap.getOrDefault(bmp.characterID, ""); + System.out.println("Lossless2: id=" + bmp.characterID + " " + bmp.bitmapWidth + "x" + bmp.bitmapHeight + " sym=" + sym); + } + if (tag instanceof DefineBitsJPEG2Tag) { + DefineBitsJPEG2Tag jpg = (DefineBitsJPEG2Tag) tag; + String sym = symbolMap.getOrDefault(jpg.characterID, ""); + System.out.println("JPEG2: id=" + jpg.characterID + " sym=" + sym); + } + if (tag instanceof DefineBitsJPEG3Tag) { + DefineBitsJPEG3Tag jpg = (DefineBitsJPEG3Tag) tag; + String sym = symbolMap.getOrDefault(jpg.characterID, ""); + System.out.println("JPEG3: id=" + jpg.characterID + " sym=" + sym); + } + if (tag instanceof DefineBitsTag) { + DefineBitsTag bmp = (DefineBitsTag) tag; + String sym = symbolMap.getOrDefault(bmp.characterID, ""); + System.out.println("Bits: id=" + bmp.characterID + " sym=" + sym); + } + if (tag instanceof DefineBitsLosslessTag) { + DefineBitsLosslessTag bmp = (DefineBitsLosslessTag) tag; + String sym = symbolMap.getOrDefault(bmp.characterID, ""); + System.out.println("Lossless: id=" + bmp.characterID + " " + bmp.bitmapWidth + "x" + bmp.bitmapHeight + " sym=" + sym); + } + } + } +} diff --git a/tools/FindMenuTitle.java b/tools/FindMenuTitle.java new file mode 100644 index 00000000..ca5aafb4 --- /dev/null +++ b/tools/FindMenuTitle.java @@ -0,0 +1,33 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.tags.base.*; +import java.io.*; + +public class FindMenuTitle { + public static void main(String[] args) throws Exception { + String path = args[0]; + System.out.println("Scanning: " + path); + SWF swf = new SWF(new FileInputStream(path), false); + + for (Tag tag : swf.getTags()) { + if (tag instanceof SymbolClassTag) { + SymbolClassTag sym = (SymbolClassTag) tag; + for (int i = 0; i < sym.tags.size(); i++) { + String name = sym.names.get(i); + if (name.contains("Menu") || name.contains("Logo") || name.contains("Title")) { + System.out.println(" Symbol: id=" + sym.tags.get(i) + " name=" + name); + } + } + } + if (tag instanceof ExportAssetsTag) { + ExportAssetsTag exp = (ExportAssetsTag) tag; + for (int i = 0; i < exp.tags.size(); i++) { + String name = exp.names.get(i); + if (name.contains("Menu") || name.contains("Logo") || name.contains("Title")) { + System.out.println(" Export: id=" + exp.tags.get(i) + " name=" + name); + } + } + } + } + } +} diff --git a/tools/FindMenuTitleAll.java b/tools/FindMenuTitleAll.java new file mode 100644 index 00000000..cc787837 --- /dev/null +++ b/tools/FindMenuTitleAll.java @@ -0,0 +1,37 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.tags.base.*; +import java.io.*; + +public class FindMenuTitleAll { + public static void main(String[] args) throws Exception { + String[] files = {"skinHD.swf", "skinHDGraphics.swf", "skinHDWin.swf", "skinHDInGame.swf", "skinHDLabels.swf", "skinHDHud.swf", "skinHDGraphicsInGame.swf", "skinHDGraphicsLabels.swf", "skinHDGraphicsHud.swf", "skin.swf", "skinGraphics.swf", "skinWin.swf"}; + String base = "C:/Users/revela/Documents/Minecraft/itsRevela/Minecraft.Client/Common/Media/"; + + for (String file : files) { + try { + SWF swf = new SWF(new FileInputStream(base + file), false); + for (Tag tag : swf.getTags()) { + if (tag instanceof SymbolClassTag) { + SymbolClassTag sym = (SymbolClassTag) tag; + for (int i = 0; i < sym.tags.size(); i++) { + if (sym.names.get(i).contains("Title") || sym.names.get(i).contains("MenuTitle")) { + System.out.println(file + " -> Symbol: id=" + sym.tags.get(i) + " name=" + sym.names.get(i)); + } + } + } + if (tag instanceof ExportAssetsTag) { + ExportAssetsTag exp = (ExportAssetsTag) tag; + for (int i = 0; i < exp.tags.size(); i++) { + if (exp.names.get(i).contains("Title") || exp.names.get(i).contains("MenuTitle")) { + System.out.println(file + " -> Export: id=" + exp.tags.get(i) + " name=" + exp.names.get(i)); + } + } + } + } + } catch (Exception e) { + System.err.println("Error reading " + file + ": " + e.getMessage()); + } + } + } +} diff --git a/tools/ListArc.java b/tools/ListArc.java new file mode 100644 index 00000000..14329c5e --- /dev/null +++ b/tools/ListArc.java @@ -0,0 +1,19 @@ +import java.io.*; +import java.nio.file.*; + +public class ListArc { + public static void main(String[] args) throws Exception { + DataInputStream dis = new DataInputStream(new FileInputStream(args[0])); + int numFiles = dis.readInt(); + System.out.println("Files: " + numFiles); + for (int i = 0; i < numFiles; i++) { + String name = dis.readUTF(); + int offset = dis.readInt(); + int size = dis.readInt(); + if (name.contains("ogo") || name.contains("skin") || name.contains("Menu") || name.contains("platform")) { + System.out.println(" >>> " + name + " offset=" + offset + " size=" + size); + } + } + dis.close(); + } +} diff --git a/tools/PatchHudABC.java b/tools/PatchHudABC.java new file mode 100644 index 00000000..aa02ea54 --- /dev/null +++ b/tools/PatchHudABC.java @@ -0,0 +1,617 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.abc.*; +import com.jpexs.decompiler.flash.abc.avm2.AVM2ConstantPool; +import com.jpexs.decompiler.flash.abc.types.*; +import com.jpexs.decompiler.flash.abc.types.traits.*; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.configuration.Configuration; +import java.io.*; +import java.util.*; + +/** + * Patches the HUD SWF ABC bytecode directly to add hardcore hearts support. + * + * This uses raw byte patching with manual jump offset fixups, avoiding the + * AVM2Code instruction list approach which fails to adjust existing jump + * offsets when bytes are inserted. + * + * Changes: + * 1. Adds m_bHardcore:Boolean instance variable to Hud class + * 2. Adds SetHardcore(Boolean) method + * 3. Modifies SetHealth to check m_bHardcore and offset heart frames + * + * Usage: PatchHudABC + */ +public class PatchHudABC { + + // AVM2 opcodes that have s24 jump offset as first operand + static final Set JUMP_OPCODES = Set.of( + 0x10, // jump + 0x11, // iftrue + 0x12, // iffalse + 0x13, // ifeq + 0x14, // ifne + 0x15, // iflt + 0x16, // ifle + 0x17, // ifgt + 0x18, // ifge + 0x19, // ifstricteq + 0x1A, // ifstrictne + 0x0C, // ifnlt + 0x0D, // ifnle + 0x0E, // ifngt + 0x0F // ifnge + ); + + public static void main(String[] args) throws Exception { + if (args.length < 2) { + System.out.println("Usage: PatchHudABC "); + return; + } + + Configuration.autoDeobfuscate.set(false); + + String inputPath = args[0]; + String outputPath = args[1]; + + System.out.println("Loading SWF: " + inputPath); + SWF swf = new SWF(new FileInputStream(inputPath), false); + + for (Tag tag : swf.getTags()) { + if (!(tag instanceof DoABC2Tag)) continue; + DoABC2Tag abcTag = (DoABC2Tag) tag; + ABC abc = abcTag.getABC(); + AVM2ConstantPool cp = abc.constants; + + // Find the Hud class + int hudClassIdx = -1; + for (int c = 0; c < abc.instance_info.size(); c++) { + Multiname mn = cp.getMultiname(abc.instance_info.get(c).name_index); + String name = mn.name_index > 0 ? cp.getString(mn.name_index) : ""; + if ("Hud".equals(name)) { + hudClassIdx = c; + break; + } + } + + if (hudClassIdx < 0) { + System.err.println("ERROR: Could not find Hud class"); + System.exit(1); + } + + System.out.println("Found Hud class at index " + hudClassIdx); + + InstanceInfo hudInstance = abc.instance_info.get(hudClassIdx); + + // === Step 1: Add m_bHardcore Boolean member variable === + + int strHardcore = ensureString(cp, "m_bHardcore"); + int mnBoolean = findMultinameByName(cp, "Boolean"); + if (mnBoolean < 0) { + System.err.println("ERROR: Could not find Boolean multiname"); + System.exit(1); + } + + // Find the private namespace for the Hud class (same as m_bWithered uses) + int privateNs = -1; + for (Trait t : hudInstance.instance_traits.traits) { + Multiname tMn = cp.getMultiname(t.name_index); + String tName = tMn.name_index > 0 ? cp.getString(tMn.name_index) : ""; + if ("m_bWithered".equals(tName)) { + privateNs = tMn.namespace_index; + break; + } + } + + if (privateNs < 0) { + System.err.println("ERROR: Could not find m_bWithered private namespace"); + System.exit(1); + } + + // Create QName multiname for m_bHardcore + int mnHardcore = addQName(cp, strHardcore, privateNs); + + // Add trait + TraitSlotConst hardcoreSlot = new TraitSlotConst(); + hardcoreSlot.name_index = mnHardcore; + hardcoreSlot.kindType = Trait.TRAIT_SLOT; + hardcoreSlot.type_index = mnBoolean; + hardcoreSlot.slot_id = 0; // auto-assign + hudInstance.instance_traits.traits.add(hardcoreSlot); + System.out.println("Added m_bHardcore member variable"); + + // === Step 2: Add SetHardcore method === + int strSetHardcore = ensureString(cp, "SetHardcore"); + + // Find public namespace (same as SetHealth uses) + int publicNs = -1; + for (Trait t : hudInstance.instance_traits.traits) { + if (t instanceof TraitMethodGetterSetter) { + Multiname tMn = cp.getMultiname(t.name_index); + String tName = tMn.name_index > 0 ? cp.getString(tMn.name_index) : ""; + if ("SetHealth".equals(tName)) { + publicNs = tMn.namespace_index; + break; + } + } + } + + if (publicNs < 0) { + System.err.println("ERROR: Could not find SetHealth public namespace"); + System.exit(1); + } + + int mnSetHardcore = addQName(cp, strSetHardcore, publicNs); + + // Create method info for SetHardcore(Boolean):void + int retTypeVoid = findMultinameByName(cp, "void"); + if (retTypeVoid < 0) retTypeVoid = 0; + MethodInfo setHardcoreMethod = new MethodInfo( + new int[]{mnBoolean}, retTypeVoid, 0, 0, null, null + ); + abc.method_info.add(setHardcoreMethod); + int setHardcoreMethodIdx = abc.method_info.size() - 1; + + // Find an existing instance method body to copy scope depth from + int refInitScope = 10; + int refMaxScope = 11; + for (Trait t : hudInstance.instance_traits.traits) { + if (t instanceof TraitMethodGetterSetter) { + MethodBody refBody = abc.findBody(((TraitMethodGetterSetter) t).method_info); + if (refBody != null) { + refInitScope = refBody.init_scope_depth; + refMaxScope = refBody.max_scope_depth; + break; + } + } + } + + // Create method body with raw bytes: + // D0 getlocal0 + // 30 pushscope + // D0 getlocal0 + // D1 getlocal1 + // 61 XX XX setproperty m_bHardcore (u30 multiname) + // 47 returnvoid + MethodBody setHardcoreBody = new MethodBody(); + setHardcoreBody.method_info = setHardcoreMethodIdx; + setHardcoreBody.max_stack = 2; + setHardcoreBody.max_regs = 2; + setHardcoreBody.init_scope_depth = refInitScope; + setHardcoreBody.max_scope_depth = refMaxScope; + + byte[] mnHardcoreU30 = encodeU30(mnHardcore); + byte[] shBytes = new byte[4 + 1 + mnHardcoreU30.length + 1]; + int pos = 0; + shBytes[pos++] = (byte) 0xD0; // getlocal0 + shBytes[pos++] = (byte) 0x30; // pushscope + shBytes[pos++] = (byte) 0xD0; // getlocal0 + shBytes[pos++] = (byte) 0xD1; // getlocal1 + shBytes[pos++] = (byte) 0x61; // setproperty + System.arraycopy(mnHardcoreU30, 0, shBytes, pos, mnHardcoreU30.length); + pos += mnHardcoreU30.length; + shBytes[pos++] = (byte) 0x47; // returnvoid + + setHardcoreBody.setCodeBytes(shBytes); + abc.bodies.add(setHardcoreBody); + + // Create trait for SetHardcore method + TraitMethodGetterSetter setHardcoreTrait = new TraitMethodGetterSetter(); + setHardcoreTrait.name_index = mnSetHardcore; + setHardcoreTrait.kindType = Trait.TRAIT_METHOD; + setHardcoreTrait.method_info = setHardcoreMethodIdx; + hudInstance.instance_traits.traits.add(setHardcoreTrait); + System.out.println("Added SetHardcore method"); + + // === Step 3: Patch SetHealth raw bytes === + for (Trait t : hudInstance.instance_traits.traits) { + if (!(t instanceof TraitMethodGetterSetter)) continue; + Multiname tMn = cp.getMultiname(t.name_index); + String tName = tMn.name_index > 0 ? cp.getString(tMn.name_index) : ""; + if (!"SetHealth".equals(tName)) continue; + + TraitMethodGetterSetter tm = (TraitMethodGetterSetter) t; + MethodBody body = abc.findBody(tm.method_info); + if (body == null) continue; + + System.out.println("Patching SetHealth method body..."); + patchSetHealthRawBytes(body, mnHardcore); + + // Bump max_stack if needed + if (body.max_stack < 3) body.max_stack = 3; + break; + } + + abcTag.setModified(true); + break; + } + + System.out.println("Saving to: " + outputPath); + try (FileOutputStream fos = new FileOutputStream(outputPath)) { + swf.saveTo(fos); + } + System.out.println("Done!"); + } + + /** + * Patches SetHealth by inserting raw bytes after the setlocal 7 instruction. + * + * The patch implements: + * if (m_bHardcore) { + * if (frame >= 11) frame += 6; // wither -> hardcore normal + * else frame += 14; // normal/poison -> hardcore + * } + */ + static void patchSetHealthRawBytes(MethodBody body, int mnHardcore) { + byte[] code = body.getCodeBytes(); + + // Find "setlocal 7" (0x63 0x07) after byte offset ~150 + int insertAt = -1; + for (int i = 150; i < code.length - 1; i++) { + if ((code[i] & 0xFF) == 0x63 && (code[i + 1] & 0xFF) == 0x07) { + insertAt = i + 2; // insert AFTER setlocal 7 + break; + } + } + + if (insertAt < 0) { + System.err.println("ERROR: Could not find setlocal 7 in SetHealth bytecode"); + System.exit(1); + } + System.out.println("Inserting patch at byte offset " + insertAt); + + // Build the patch bytes manually. + // Layout with byte sizes: + // + // D0 getlocal0 (1) + // 66 XX.. getproperty m_bHardcore (u30) (1 + u30len) + // 12 YY YY YY iffalse (4) + // 62 07 getlocal 7 (2) + // 24 0B pushbyte 11 (2) + // 15 ZZ ZZ ZZ iflt (4) + // 62 07 getlocal 7 (2) + // 24 06 pushbyte 6 (2) + // A0 add (1) + // 73 convert_i (1) + // 63 07 setlocal 7 (2) + // 10 WW WW WW jump (4) + // --- add14 block --- + // 62 07 getlocal 7 (2) + // 24 0E pushbyte 14 (2) + // A0 add (1) + // 73 convert_i (1) + // 63 07 setlocal 7 (2) + // --- end --- + + byte[] mnU30 = encodeU30(mnHardcore); + int mnU30Len = mnU30.length; + + // Calculate block sizes for jump offset computation + // iffalse is at offset: 1 + 1 + mnU30Len = (2 + mnU30Len) from patch start + // iffalse instruction ends at: (2 + mnU30Len) + 4 = (6 + mnU30Len) + // + // After iffalse: getlocal7(2) + pushbyte11(2) + iflt(4) = 8 bytes + // iflt is at offset: (6 + mnU30Len) + 4 = (10 + mnU30Len) from patch start + // iflt instruction ends at: (10 + mnU30Len) + 4 = (14 + mnU30Len) + // + // add6 block: getlocal7(2) + pushbyte6(2) + add(1) + convert_i(1) + setlocal7(2) + jump(4) = 12 bytes + // jump is at offset: (14 + mnU30Len) + 8 = (22 + mnU30Len) from patch start + // jump instruction ends at: (22 + mnU30Len) + 4 = (26 + mnU30Len) + // + // add14 block starts at offset: (26 + mnU30Len) from patch start + // add14 block: getlocal7(2) + pushbyte14(2) + add(1) + convert_i(1) + setlocal7(2) = 8 bytes + // Total patch size: (26 + mnU30Len) + 8 = (34 + mnU30Len) + + int add6BlockStart = 6 + mnU30Len; // after iffalse instruction + int ifltEnd = 14 + mnU30Len; // after iflt instruction + int jumpEnd = 26 + mnU30Len; // after jump instruction + int add14BlockStart = 26 + mnU30Len; // start of add14 block + int patchEnd = 34 + mnU30Len; // end of entire patch + + // iffalse offset: skip from end of iffalse to end of patch + // s24 = target - instructionEnd = patchEnd - (6 + mnU30Len) + int iffalseOffset = patchEnd - (6 + mnU30Len); + + // iflt offset: skip from end of iflt to add14 block start + // s24 = add14BlockStart - ifltEnd + int ifltOffset = add14BlockStart - ifltEnd; + + // jump offset: skip from end of jump to end of patch + // s24 = patchEnd - jumpEnd + int jumpOffset = patchEnd - jumpEnd; + + // Build patch byte array + byte[] patch = new byte[patchEnd]; + int p = 0; + + // getlocal0 + patch[p++] = (byte) 0xD0; + + // getproperty m_bHardcore + patch[p++] = (byte) 0x66; + System.arraycopy(mnU30, 0, patch, p, mnU30Len); + p += mnU30Len; + + // iffalse + patch[p++] = (byte) 0x12; + writeS24(patch, p, iffalseOffset); + p += 3; + + // getlocal 7 + patch[p++] = (byte) 0x62; + patch[p++] = (byte) 0x07; + + // pushbyte 11 + patch[p++] = (byte) 0x24; + patch[p++] = (byte) 0x0B; + + // iflt + patch[p++] = (byte) 0x15; + writeS24(patch, p, ifltOffset); + p += 3; + + // getlocal 7 + patch[p++] = (byte) 0x62; + patch[p++] = (byte) 0x07; + + // pushbyte 6 + patch[p++] = (byte) 0x24; + patch[p++] = (byte) 0x06; + + // add + patch[p++] = (byte) 0xA0; + + // convert_i + patch[p++] = (byte) 0x73; + + // setlocal 7 + patch[p++] = (byte) 0x63; + patch[p++] = (byte) 0x07; + + // jump + patch[p++] = (byte) 0x10; + writeS24(patch, p, jumpOffset); + p += 3; + + // --- add14 block --- + // getlocal 7 + patch[p++] = (byte) 0x62; + patch[p++] = (byte) 0x07; + + // pushbyte 14 + patch[p++] = (byte) 0x24; + patch[p++] = (byte) 0x0E; + + // add + patch[p++] = (byte) 0xA0; + + // convert_i + patch[p++] = (byte) 0x73; + + // setlocal 7 + patch[p++] = (byte) 0x63; + patch[p++] = (byte) 0x07; + + System.out.println("Patch is " + patch.length + " bytes, inserting at offset " + insertAt); + + // Build new code with patch inserted + byte[] newCode = new byte[code.length + patch.length]; + System.arraycopy(code, 0, newCode, 0, insertAt); + System.arraycopy(patch, 0, newCode, insertAt, patch.length); + System.arraycopy(code, insertAt, newCode, insertAt + patch.length, code.length - insertAt); + + // Fix all existing jump offsets that cross the insertion point + fixJumpOffsets(newCode, insertAt, patch.length); + + body.setCodeBytes(newCode); + System.out.println("Patched SetHealth: " + code.length + " -> " + newCode.length + " bytes"); + } + + // === Helper methods === + + static int ensureString(AVM2ConstantPool cp, String s) { + for (int i = 1; i < cp.getStringCount(); i++) { + if (s.equals(cp.getString(i))) return i; + } + return cp.addString(s); + } + + static int findMultinameByName(AVM2ConstantPool cp, String name) { + for (int i = 1; i < cp.getMultinameCount(); i++) { + Multiname mn = cp.getMultiname(i); + if (mn.name_index > 0 && name.equals(cp.getString(mn.name_index))) { + return i; + } + } + return -1; + } + + static int addQName(AVM2ConstantPool cp, int nameIndex, int nsIndex) { + for (int i = 1; i < cp.getMultinameCount(); i++) { + Multiname mn = cp.getMultiname(i); + if (mn.kind == Multiname.QNAME && mn.name_index == nameIndex && mn.namespace_index == nsIndex) { + return i; + } + } + Multiname mn = new Multiname(); + mn.kind = Multiname.QNAME; + mn.name_index = nameIndex; + mn.namespace_index = nsIndex; + return cp.addMultiname(mn); + } + + /** Encode an integer as AVM2 u30 (variable-length unsigned 30-bit integer). */ + static byte[] encodeU30(int val) { + ByteArrayOutputStream buf = new ByteArrayOutputStream(5); + do { + int b = val & 0x7F; + val >>>= 7; + if (val != 0) b |= 0x80; + buf.write(b); + } while (val != 0); + return buf.toByteArray(); + } + + // === Jump offset fixup methods (from TestIsolate.java) === + + /** + * Scans the bytecode for jump instructions and adjusts their s24 offsets + * to account for insertSize bytes inserted at insertPos. + */ + static void fixJumpOffsets(byte[] code, int insertPos, int insertSize) { + int i = 0; + while (i < code.length) { + int opcode = code[i] & 0xFF; + + if (JUMP_OPCODES.contains(opcode)) { + int jumpInstrEnd = i + 4; + int offset = readS24(code, i + 1); + + boolean jumpIsBeforeInsert = (i < insertPos); + boolean jumpIsAfterInsert = (i >= insertPos + insertSize); + + if (jumpIsBeforeInsert) { + int origTarget = jumpInstrEnd + offset; + if (origTarget >= insertPos) { + writeS24(code, i + 1, offset + insertSize); + System.out.println(" Fixed forward jump at " + i + ": " + offset + " -> " + (offset + insertSize)); + } + } else if (jumpIsAfterInsert) { + int origJumpEnd = (i - insertSize) + 4; + int origTarget = origJumpEnd + offset; + if (origTarget < insertPos) { + writeS24(code, i + 1, offset - insertSize); + System.out.println(" Fixed backward jump at " + i + ": " + offset + " -> " + (offset - insertSize)); + } + } + + i += 4; + } else { + i += instructionSize(code, i); + } + } + } + + static int readS24(byte[] code, int pos) { + int b0 = code[pos] & 0xFF; + int b1 = code[pos + 1] & 0xFF; + int b2 = code[pos + 2] & 0xFF; + int val = b0 | (b1 << 8) | (b2 << 16); + if ((val & 0x800000) != 0) val |= 0xFF000000; + return val; + } + + static void writeS24(byte[] code, int pos, int val) { + code[pos] = (byte) (val & 0xFF); + code[pos + 1] = (byte) ((val >> 8) & 0xFF); + code[pos + 2] = (byte) ((val >> 16) & 0xFF); + } + + /** Returns the byte size of the instruction at the given offset. */ + static int instructionSize(byte[] code, int pos) { + int op = code[pos] & 0xFF; + // No-operand instructions (1 byte) + if (op >= 0x01 && op <= 0x0B) return 1; + if (op == 0x1E || op == 0x1F) return 1; + if (op >= 0x20 && op <= 0x23) return 1; + if (op == 0x26 || op == 0x27) return 1; + if (op == 0x28 || op == 0x29 || op == 0x2A) return 1; + if (op == 0x2B) return 1; + if (op == 0x30) return 1; + if (op == 0x47 || op == 0x48) return 1; + if (op >= 0x57 && op <= 0x5A) return 1; + if (op >= 0x70 && op <= 0x78) return 1; + if (op == 0x79 || op == 0x7A) return 1; + if (op >= 0x80 && op <= 0x85) return 1; + if (op >= 0x87 && op <= 0x99) return 1; + if (op == 0x9A) return 1; + if (op == 0x9B || op == 0x9C) return 1; + if (op >= 0xA0 && op <= 0xB4) return 1; + if (op >= 0xC0 && op <= 0xC7) return 2; + if (op >= 0xD0 && op <= 0xD7) return 1; + + // Opcodes with s24 operand (4 bytes total) + if (JUMP_OPCODES.contains(op)) return 4; + + // Opcodes with u30 operand + if (op == 0x24) return 2; // pushbyte (u8) + if (op == 0x25) return 1 + u30Len(code, pos + 1); // pushint + if (op == 0x2C || op == 0x2D) return 1 + u30Len(code, pos + 1); // pushstring, pushint + if (op == 0x2E || op == 0x2F) return 1 + u30Len(code, pos + 1); // pushuint, pushdouble + if (op == 0x31) return 1 + u30Len(code, pos + 1); // pushnamespace + if (op == 0x32) return 5; // hasnext2 + + // Single u30 operand instructions + if (op == 0x40) return 1 + u30Len(code, pos + 1); // newfunction + if (op == 0x41 || op == 0x42) return 1 + u30Len(code, pos + 1); // call, construct + if (op == 0x43) return 1 + u30Len(code, pos + 1) + u30Len(code, pos + 1 + u30Len(code, pos + 1)); // callmethod + if (op == 0x44 || op == 0x45) return 1 + u30Len(code, pos + 1) + u30Len(code, pos + 1 + u30Len(code, pos + 1)); // callstatic, callsuper + if (op == 0x46) return 1 + u30Len(code, pos + 1) + u30Len(code, pos + 1 + u30Len(code, pos + 1)); // callproperty + if (op == 0x49) return 1 + u30Len(code, pos + 1); // constructsuper + if (op == 0x4A) return 1 + u30Len(code, pos + 1) + u30Len(code, pos + 1 + u30Len(code, pos + 1)); // constructprop + if (op == 0x4C) return 1 + u30Len(code, pos + 1) + u30Len(code, pos + 1 + u30Len(code, pos + 1)); // callproplex + if (op == 0x4E) return 1 + u30Len(code, pos + 1) + u30Len(code, pos + 1 + u30Len(code, pos + 1)); // callsupervoid + if (op == 0x4F) return 1 + u30Len(code, pos + 1) + u30Len(code, pos + 1 + u30Len(code, pos + 1)); // callpropvoid + + // Single u30 operand + if (op == 0x53) return 1 + u30Len(code, pos + 1); // applytype + if (op == 0x55) return 1 + u30Len(code, pos + 1); // newobject + if (op == 0x56) return 1 + u30Len(code, pos + 1); // newarray + if (op == 0x58) return 1 + u30Len(code, pos + 1); // newclass + if (op == 0x59) return 1 + u30Len(code, pos + 1); // getdescendants + if (op == 0x5A) return 1 + u30Len(code, pos + 1); // newcatch + if (op == 0x5D || op == 0x5E) return 1 + u30Len(code, pos + 1); // findpropstrict, findproperty + if (op == 0x60) return 1 + u30Len(code, pos + 1); // getlex + if (op == 0x61) return 1 + u30Len(code, pos + 1); // setproperty + if (op == 0x62) return 1 + u30Len(code, pos + 1); // getlocal + if (op == 0x63) return 1 + u30Len(code, pos + 1); // setlocal + if (op == 0x65) return 1 + u30Len(code, pos + 1); // getscopeobject + if (op == 0x66) return 1 + u30Len(code, pos + 1); // getproperty + if (op == 0x68) return 1 + u30Len(code, pos + 1); // initproperty + if (op == 0x6A) return 1 + u30Len(code, pos + 1); // deleteproperty + if (op == 0x6C) return 1 + u30Len(code, pos + 1); // getslot + if (op == 0x6D) return 1 + u30Len(code, pos + 1); // setslot + if (op == 0x86) return 1 + u30Len(code, pos + 1); // astype + if (op == 0x80) return 1 + u30Len(code, pos + 1); // coerce + + // debug (0xEF): special format + if (op == 0xEF) { + int off = 1; + off += 1; // debug_type (u8) + off += u30Len(code, pos + off); // index (u30) + off += 1; // reg (u8) + off += u30Len(code, pos + off); // extra (u30) + return off; + } + + // lookupswitch (0x1B): s24 + u30 count + (count+1) s24 offsets + if (op == 0x1B) { + int off = 4; // opcode + s24 default + int count = readU30(code, pos + off); + off += u30Len(code, pos + off); + off += (count + 1) * 3; // s24 offsets + return off; + } + + // Default: assume 1 byte + return 1; + } + + static int u30Len(byte[] code, int pos) { + int len = 1; + while ((code[pos + len - 1] & 0x80) != 0 && len < 5) len++; + return len; + } + + static int readU30(byte[] code, int pos) { + int result = 0; + int shift = 0; + for (int i = 0; i < 5; i++) { + int b = code[pos + i] & 0xFF; + result |= (b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + } + return result; + } +} diff --git a/tools/RebuildArc.class b/tools/RebuildArc.class new file mode 100644 index 00000000..5d4c67b7 Binary files /dev/null and b/tools/RebuildArc.class differ diff --git a/tools/RebuildArc.java b/tools/RebuildArc.java new file mode 100644 index 00000000..dc00747b --- /dev/null +++ b/tools/RebuildArc.java @@ -0,0 +1,168 @@ +import java.io.*; +import java.nio.file.*; +import java.util.*; + +/** + * Rebuilds a MediaWindows64.arc archive file by replacing specific SWF files + * with updated versions from disk. + * + * Usage: RebuildArc [file1.swf file2.swf ...] + * + * If no specific files are given, replaces all SWF files found in media_dir. + * The arc format is Java DataOutputStream style: big-endian ints, modified UTF-8 strings. + * + * Archive format: + * int: numberOfFiles + * For each file: + * UTF: filename (prefixed with '*' if compressed) + * int: offset into data section + * int: filesize + * Raw file data follows the header + */ +public class RebuildArc { + public static void main(String[] args) throws Exception { + if (args.length < 2) { + System.out.println("Usage: RebuildArc [file1.swf file2.swf ...]"); + System.exit(1); + } + + String arcPath = args[0]; + String mediaDir = args[1]; + Set replaceFiles = new HashSet<>(); + for (int i = 2; i < args.length; i++) { + replaceFiles.add(args[i]); + } + + // Read the original archive + DataInputStream dis = new DataInputStream(new FileInputStream(arcPath)); + int numberOfFiles = dis.readInt(); + + System.out.println("Archive has " + numberOfFiles + " files"); + + // Read the index + List filenames = new ArrayList<>(); + List offsets = new ArrayList<>(); + List sizes = new ArrayList<>(); + List compressed = new ArrayList<>(); + + for (int i = 0; i < numberOfFiles; i++) { + String name = dis.readUTF(); + int offset = dis.readInt(); + int size = dis.readInt(); + + boolean isCompressed = false; + if (name.startsWith("*")) { + name = name.substring(1); + isCompressed = true; + } + + filenames.add(name); + offsets.add(offset); + sizes.add(size); + compressed.add(isCompressed); + } + + // The header size is the current position - data offsets are relative to file start + // Read the entire file to get the raw data + dis.close(); + byte[] arcData = Files.readAllBytes(Paths.get(arcPath)); + + // Build replacement data map + // For each file, either use the replacement or the original data + List fileData = new ArrayList<>(); + int replacedCount = 0; + + for (int i = 0; i < numberOfFiles; i++) { + String name = filenames.get(i); + String simpleName = name.contains("\\") ? name.substring(name.lastIndexOf('\\') + 1) : name; + + boolean shouldReplace = false; + if (replaceFiles.isEmpty()) { + // Replace all SWFs found in mediaDir + File diskFile = new File(mediaDir, simpleName); + if (diskFile.exists() && simpleName.endsWith(".swf")) { + shouldReplace = true; + } + } else { + shouldReplace = replaceFiles.contains(simpleName); + } + + if (shouldReplace) { + File diskFile = new File(mediaDir, simpleName); + if (diskFile.exists()) { + byte[] newData = Files.readAllBytes(diskFile.toPath()); + fileData.add(newData); + // Replaced files are NOT compressed (our SWFs are uncompressed) + compressed.set(i, false); + sizes.set(i, newData.length); + System.out.println(" Replacing: " + name + " (" + newData.length + " bytes)"); + replacedCount++; + } else { + System.err.println(" WARNING: " + diskFile + " not found, keeping original"); + byte[] original = new byte[sizes.get(i)]; + System.arraycopy(arcData, offsets.get(i), original, 0, sizes.get(i)); + fileData.add(original); + } + } else { + // Keep original data + byte[] original = new byte[sizes.get(i)]; + System.arraycopy(arcData, offsets.get(i), original, 0, sizes.get(i)); + fileData.add(original); + } + } + + if (replacedCount == 0) { + System.out.println("No files were replaced!"); + System.exit(1); + } + + // Calculate new header size to compute data offsets + // Header: 4 bytes (numFiles) + for each file: (2 + UTF8 length + 4 + 4) bytes + ByteArrayOutputStream headerBuf = new ByteArrayOutputStream(); + DataOutputStream headerDos = new DataOutputStream(headerBuf); + headerDos.writeInt(numberOfFiles); + for (int i = 0; i < numberOfFiles; i++) { + String name = filenames.get(i); + if (compressed.get(i)) { + name = "*" + name; + } + headerDos.writeUTF(name); + headerDos.writeInt(0); // placeholder offset + headerDos.writeInt(0); // placeholder size + } + headerDos.flush(); + int headerSize = headerBuf.size(); + + // Now compute real offsets + int currentOffset = headerSize; + List newOffsets = new ArrayList<>(); + for (int i = 0; i < numberOfFiles; i++) { + newOffsets.add(currentOffset); + currentOffset += fileData.get(i).length; + } + + // Write the final archive + String outputPath = arcPath; // overwrite in place + DataOutputStream dos = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(outputPath))); + dos.writeInt(numberOfFiles); + for (int i = 0; i < numberOfFiles; i++) { + String name = filenames.get(i); + if (compressed.get(i)) { + name = "*" + name; + } + dos.writeUTF(name); + dos.writeInt(newOffsets.get(i)); + dos.writeInt(fileData.get(i).length); + } + + // Write file data + for (int i = 0; i < numberOfFiles; i++) { + dos.write(fileData.get(i)); + } + + dos.flush(); + dos.close(); + + System.out.println("Rebuilt archive: " + outputPath + " (" + replacedCount + " files replaced)"); + } +} diff --git a/tools/ReplaceLogo.java b/tools/ReplaceLogo.java new file mode 100644 index 00000000..adfbf115 --- /dev/null +++ b/tools/ReplaceLogo.java @@ -0,0 +1,124 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.tags.base.*; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.awt.*; +import java.io.*; +import java.util.*; + +/** + * Replaces MenuTitle and MenuTitleSmall bitmaps in a skin SWF + * with a custom logo image, then saves the modified SWF. + * + * Usage: ReplaceLogo [scale] + * + * Optional scale parameter (0.0-1.0) shrinks the logo within the bitmap + * canvas, adding transparent padding. Default is 1.0 (fill canvas). + * + * Finds bitmaps by symbol name (MenuTitle, MenuTitleSmall) rather than + * hardcoded IDs, so it works with both skinHDWin.swf and skinWin.swf. + */ +public class ReplaceLogo { + public static void main(String[] args) throws Exception { + if (args.length < 3) { + System.out.println("Usage: ReplaceLogo "); + System.exit(1); + } + + String swfPath = args[0]; + String logoPath = args[1]; + String outputPath = args[2]; + double extraScale = (args.length >= 4) ? Double.parseDouble(args[3]) : 1.0; + + System.out.println("Loading SWF: " + swfPath); + SWF swf = new SWF(new FileInputStream(swfPath), false); + + System.out.println("Loading logo: " + logoPath); + BufferedImage logoOriginal = ImageIO.read(new File(logoPath)); + System.out.println("Logo dimensions: " + logoOriginal.getWidth() + "x" + logoOriginal.getHeight()); + System.out.println("Extra scale: " + extraScale); + + // Build symbol name map from SymbolClass tags + Map symbolNames = new HashMap<>(); + for (Tag tag : swf.getTags()) { + if (tag instanceof SymbolClassTag) { + SymbolClassTag sym = (SymbolClassTag) tag; + for (int i = 0; i < sym.tags.size(); i++) { + symbolNames.put(sym.tags.get(i), sym.names.get(i)); + } + } + } + + // Find and replace MenuTitle / MenuTitleSmall bitmaps by symbol name + Set targetNames = Set.of("MenuTitle", "MenuTitleSmall"); + int replaced = 0; + + for (Tag tag : swf.getTags()) { + if (tag instanceof DefineBitsLossless2Tag) { + DefineBitsLossless2Tag bmp = (DefineBitsLossless2Tag) tag; + String symName = symbolNames.getOrDefault(bmp.characterID, ""); + + if (targetNames.contains(symName)) { + int targetW = bmp.bitmapWidth; + int targetH = bmp.bitmapHeight; + + System.out.println("Replacing " + symName + " (id=" + bmp.characterID + ", " + targetW + "x" + targetH + ")"); + + // Scale logo to fit within target dimensions, maintaining aspect ratio + // Then apply extra scale factor to shrink within the canvas + int fitW = (int) Math.round(targetW * extraScale); + int fitH = (int) Math.round(targetH * extraScale); + BufferedImage scaled = scaleToFit(logoOriginal, fitW, fitH); + + // Create target-sized image with transparency, center the scaled logo + BufferedImage target = new BufferedImage(targetW, targetH, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = target.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + int xOff = (targetW - scaled.getWidth()) / 2; + int yOff = (targetH - scaled.getHeight()) / 2; + g.drawImage(scaled, xOff, yOff, null); + g.dispose(); + + // Convert BufferedImage to PNG bytes for FFDec setImage() + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(target, "PNG", baos); + bmp.setImage(baos.toByteArray()); + + System.out.println(" Replaced with " + scaled.getWidth() + "x" + scaled.getHeight() + " (centered in " + targetW + "x" + targetH + ")"); + replaced++; + } + } + } + + if (replaced == 0) { + System.err.println("ERROR: No logo bitmaps found to replace!"); + System.exit(1); + } + + System.out.println("Saving modified SWF: " + outputPath); + try (FileOutputStream fos = new FileOutputStream(outputPath)) { + swf.saveTo(fos); + } + System.out.println("Done! Replaced " + replaced + " bitmaps."); + } + + static BufferedImage scaleToFit(BufferedImage src, int maxW, int maxH) { + double scaleX = (double) maxW / src.getWidth(); + double scaleY = (double) maxH / src.getHeight(); + double scale = Math.min(scaleX, scaleY); + + int newW = (int) Math.round(src.getWidth() * scale); + int newH = (int) Math.round(src.getHeight() * scale); + + BufferedImage result = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = result.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.drawImage(src, 0, 0, newW, newH, null); + g.dispose(); + return result; + } +} diff --git a/tools/ShiftLogo.java b/tools/ShiftLogo.java new file mode 100644 index 00000000..182c56e6 --- /dev/null +++ b/tools/ShiftLogo.java @@ -0,0 +1,91 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.tags.*; +import com.jpexs.decompiler.flash.tags.base.*; +import com.jpexs.decompiler.flash.types.*; +import java.io.*; + +/** + * Shifts the logo placement in ComponentLogo SWF files by adjusting + * the Y-translate of the PlaceObject2 tag. + * + * Usage: ShiftLogo + * + * A negative yShiftPixels moves the logo UP, positive moves it DOWN. + * The shift is scaled proportionally for non-1080p variants based on + * the SWF's frame height relative to 1080. + */ +public class ShiftLogo { + public static void main(String[] args) throws Exception { + if (args.length < 3) { + System.out.println("Usage: ShiftLogo "); + System.exit(1); + } + + String inputPath = args[0]; + String outputPath = args[1]; + int yShiftPx1080 = Integer.parseInt(args[2]); + + System.out.println("Loading: " + inputPath); + SWF swf = new SWF(new FileInputStream(inputPath), false); + + // Frame height in twips -> pixels to compute scale ratio vs 1080p + int frameHeightTwips = swf.displayRect.Ymax - swf.displayRect.Ymin; + double frameHeightPx = frameHeightTwips / 20.0; + double ratio = frameHeightPx / 1080.0; + int yShiftTwips = (int) Math.round(yShiftPx1080 * ratio * 20); + + System.out.println("Frame height: " + frameHeightPx + "px, ratio: " + ratio); + System.out.println("Y shift: " + yShiftPx1080 + "px@1080 -> " + (yShiftTwips / 20.0) + "px actual (" + yShiftTwips + " twips)"); + + int modified = 0; + for (Tag tag : swf.getTags()) { + // Handle PlaceObject2 at top level + if (tag instanceof PlaceObject2Tag) { + PlaceObject2Tag po = (PlaceObject2Tag) tag; + if (po.getMatrix() != null) { + MATRIX m = po.getMatrix(); + int oldY = m.translateY; + m.translateY += yShiftTwips; + System.out.println("PlaceObject2 '" + po.getInstanceName() + "': translateY " + oldY + " -> " + m.translateY + " twips (" + (oldY/20.0) + " -> " + (m.translateY/20.0) + " px)"); + po.setModified(true); + modified++; + } + } + // Handle PlaceObject3 at top level (used by 480 and Split720) + if (tag instanceof PlaceObject3Tag) { + PlaceObject3Tag po = (PlaceObject3Tag) tag; + if (po.getMatrix() != null) { + MATRIX m = po.getMatrix(); + int oldY = m.translateY; + m.translateY += yShiftTwips; + System.out.println("PlaceObject3 '" + po.getInstanceName() + "': translateY " + oldY + " -> " + m.translateY + " twips (" + (oldY/20.0) + " -> " + (m.translateY/20.0) + " px)"); + po.setModified(true); + modified++; + } + } + // Handle PlaceObject3 inside DefineSprite + if (tag instanceof DefineSpriteTag) { + DefineSpriteTag sprite = (DefineSpriteTag) tag; + for (Tag sub : sprite.getTags()) { + if (sub instanceof PlaceObject3Tag) { + PlaceObject3Tag po = (PlaceObject3Tag) sub; + if (po.getMatrix() != null) { + MATRIX m = po.getMatrix(); + int oldY = m.translateY; + m.translateY += yShiftTwips; + System.out.println("PlaceObject3 (in sprite): translateY " + oldY + " -> " + m.translateY + " twips (" + (oldY/20.0) + " -> " + (m.translateY/20.0) + " px)"); + po.setModified(true); + modified++; + } + } + } + } + } + + System.out.println("Modified " + modified + " placement tags"); + try (FileOutputStream fos = new FileOutputStream(outputPath)) { + swf.saveTo(fos); + } + System.out.println("Saved: " + outputPath); + } +} diff --git a/tools/ShiftMenuY.java b/tools/ShiftMenuY.java new file mode 100644 index 00000000..d32e3f00 --- /dev/null +++ b/tools/ShiftMenuY.java @@ -0,0 +1,61 @@ +import com.jpexs.decompiler.flash.SWF; +import com.jpexs.decompiler.flash.tags.Tag; +import com.jpexs.decompiler.flash.tags.base.PlaceObjectTypeTag; +import com.jpexs.decompiler.flash.types.MATRIX; +import java.io.*; + +/** + * Shifts a named PlaceObject element's Y position in a SWF. + * Usage: ShiftMenuY + * + * Positive offset = move down, negative = move up. + * SWF uses twips (1 pixel = 20 twips). + */ +public class ShiftMenuY { + public static void main(String[] args) throws Exception { + if (args.length < 3) { + System.out.println("Usage: ShiftMenuY [element_name]"); + System.out.println(" Shifts all elements (or a named element) by the given pixel offset."); + System.out.println(" Negative values move up, positive move down."); + return; + } + + String inputPath = args[0]; + String outputPath = args[1]; + int offsetPixels = Integer.parseInt(args[2]); + String targetName = args.length > 3 ? args[3] : null; + int offsetTwips = offsetPixels * 20; + + SWF swf = new SWF(new FileInputStream(inputPath), false); + + int modified = 0; + for (Tag tag : swf.getTags()) { + if (tag instanceof PlaceObjectTypeTag) { + PlaceObjectTypeTag po = (PlaceObjectTypeTag) tag; + String name = po.getInstanceName(); + if (targetName != null && !targetName.equals(name)) continue; + MATRIX matrix = po.getMatrix(); + if (matrix != null) { + int oldY = matrix.translateY; + matrix.translateY += offsetTwips; + System.out.printf(" %s: Y %d -> %d twips (%+d px)%n", + name != null ? name : "(unnamed)", + oldY, matrix.translateY, offsetPixels); + po.setModified(true); + modified++; + } + } + } + + if (modified == 0) { + System.out.println("No elements modified."); + return; + } + System.out.printf("Modified %d element(s) by %+d pixels%n", modified, offsetPixels); + + try (FileOutputStream fos = new FileOutputStream(outputPath)) { + swf.saveTo(fos); + } + System.out.println("Saved: " + outputPath); + } +} diff --git a/tools/performance-monitor/bots.py b/tools/performance-monitor/bots.py new file mode 100644 index 00000000..34a6d86a --- /dev/null +++ b/tools/performance-monitor/bots.py @@ -0,0 +1,425 @@ +""" +Bot player manager for server load testing. + +Each bot connects to the game server as a real client using the same +connection flow as the server-monitor tool (PreLogin -> Login -> cipher +handshake -> identity tokens -> keepalive). +""" + +import logging +import os +import secrets +import socket +import struct +import sys +import threading +import time +from dataclasses import dataclass + +from PySide6.QtCore import QObject, Signal + +# Import protocol code from the sibling server-monitor tool. +_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_SM_PATH = os.path.join(_PARENT, "server-monitor") +if _SM_PATH not in sys.path: + sys.path.append(_SM_PATH) + +from protocol import frame_packet, DataInputStream +from packets import ( + PRE_LOGIN, LOGIN, KEEP_ALIVE, CUSTOM_PAYLOAD, DISCONNECT, + build_prelogin, build_login, build_keep_alive, + build_custom_payload, parse_prelogin_response, + parse_login_response_stream, DISCONNECT_REASONS, +) + +logger = logging.getLogger("perfmon") + +# Reuse the same constants/patterns as server-monitor/connection.py +SMALLID_REJECT = 0xFF + +CIPHER_KEY_PREFIX = ( + b"\xFA\x00\x07" + b"\x00\x4D\x00\x43\x00\x7C\x00\x43\x00\x4B\x00\x65\x00\x79" + b"\x00\x20" +) +CIPHER_KEY_TOTAL = len(CIPHER_KEY_PREFIX) + 32 + +CIPHER_ON_PATTERN = ( + b"\xFA\x00\x06" + b"\x00\x4D\x00\x43\x00\x7C\x00\x43\x00\x4F\x00\x6E" + b"\x00\x00" +) + +CIPHER_ACK_CHANNEL = "MC|CAck" +IDENTITY_TOKEN_ISSUE = "MC|CTIssue" +IDENTITY_TOKEN_CHALLENGE = "MC|CTChallenge" +IDENTITY_TOKEN_RESPONSE = "MC|CTResponse" + +PHASE_HANDSHAKE = 0 +PHASE_CIPHER_SCAN = 1 +PHASE_MONITORING = 2 + + +def _build_channel_pattern(channel: str) -> bytes: + result = b"\xFA" + result += struct.pack(">h", len(channel)) + for ch in channel: + result += struct.pack(">H", ord(ch)) + return result + + +class CipherState: + def __init__(self, key: bytes, iv: bytes): + from Crypto.Cipher import AES + from Crypto.Util import Counter + ctr = Counter.new(128, initial_value=int.from_bytes(iv, "big")) + self._cipher = AES.new(key, AES.MODE_CTR, counter=ctr) + + def process(self, data: bytes) -> bytes: + return self._cipher.encrypt(data) + + +@dataclass +class BotState: + name: str + thread: threading.Thread | None = None + sock: socket.socket | None = None + running: bool = False + connected: bool = False + + +class BotConnection: + """Single bot connection -- mirrors ServerConnection from server-monitor.""" + + def __init__(self, bot: BotState, host: str, port: int, xuid: int, + log_fn, on_connected_fn): + self._bot = bot + self._host = host + self._port = port + self._xuid = xuid + self._log = log_fn + self._on_connected = on_connected_fn + self._sock: socket.socket | None = None + self._recv_buf = bytearray() + self._scan_buf = bytearray() + self._phase = PHASE_HANDSHAKE + self._got_prelogin = False + self._got_login = False + self._recv_cipher: CipherState | None = None + self._send_cipher: CipherState | None = None + self._cipher_key = b"" + self._cipher_iv = b"" + self._cipher_scan_start = 0.0 + self._identity_token = b"" + + def run(self): + self._log(f"Bot '{self._bot.name}' connecting to {self._host}:{self._port}...") + self._sock = socket.create_connection((self._host, self._port), timeout=10) + self._bot.sock = self._sock + self._sock.settimeout(5.0) + + # Phase 1: SmallID assignment + first_byte = self._recv_exact(1) + if first_byte[0] == SMALLID_REJECT: + reject_data = self._recv_exact(5) + reason_id = struct.unpack(">i", reject_data[1:5])[0] + reason = DISCONNECT_REASONS.get(reason_id, f"Unknown({reason_id})") + self._log(f"Bot '{self._bot.name}' rejected: {reason}") + return + + self._log(f"Bot '{self._bot.name}' assigned smallId={first_byte[0]}") + + # Send PreLogin + self._send_packet(PRE_LOGIN, build_prelogin(self._bot.name)) + self._log(f"Bot '{self._bot.name}' sent PreLogin") + + # Main recv loop + self._sock.settimeout(1.0) + last_keepalive = time.time() + keepalive_counter = 0 + + while self._bot.running: + try: + chunk = self._sock.recv(65536) + except socket.timeout: + # Cipher scan timeout + if (self._phase == PHASE_CIPHER_SCAN + and not self._cipher_key + and time.time() - self._cipher_scan_start > 3.0): + self._handle_cipher_scan(b"") + # Keepalive + now = time.time() + if now - last_keepalive >= 10.0: + keepalive_counter += 1 + self._send_packet(KEEP_ALIVE, build_keep_alive(keepalive_counter)) + last_keepalive = now + continue + except OSError: + break + + if not chunk: + self._log(f"Bot '{self._bot.name}' server closed connection") + break + + self._recv_buf.extend(chunk) + self._process_frames() + + now = time.time() + if now - last_keepalive >= 10.0: + keepalive_counter += 1 + self._send_packet(KEEP_ALIVE, build_keep_alive(keepalive_counter)) + last_keepalive = now + + self._log(f"Bot '{self._bot.name}' disconnected") + + def _process_frames(self): + while len(self._recv_buf) >= 4: + payload_len = struct.unpack(">I", self._recv_buf[:4])[0] + total = 4 + payload_len + if len(self._recv_buf) < total: + break + + raw_payload = bytes(self._recv_buf[4:total]) + del self._recv_buf[:total] + + if self._recv_cipher: + raw_payload = self._recv_cipher.process(raw_payload) + + if not raw_payload: + continue + + if self._phase == PHASE_HANDSHAKE: + self._handle_handshake(raw_payload) + elif self._phase == PHASE_CIPHER_SCAN: + self._handle_cipher_scan(raw_payload) + elif self._phase == PHASE_MONITORING: + self._handle_monitoring(raw_payload) + + def _handle_handshake(self, payload: bytes): + if not payload: + return + packet_id = payload[0] + data = payload[1:] + + if packet_id == PRE_LOGIN and not self._got_prelogin: + try: + parsed = parse_prelogin_response(data) + self._got_prelogin = True + self._log(f"Bot '{self._bot.name}' PreLogin OK: {parsed['player_count']} players online") + self._send_packet(LOGIN, build_login(self._bot.name, self._xuid)) + self._log(f"Bot '{self._bot.name}' sent Login") + except Exception as e: + self._log(f"Bot '{self._bot.name}' PreLogin parse error: {e}") + + elif packet_id == LOGIN and not self._got_login: + try: + dis = DataInputStream(data) + parsed = parse_login_response_stream(dis) + self._got_login = True + self._log(f"Bot '{self._bot.name}' Login OK: entityId={parsed['entity_id']}") + self._phase = PHASE_CIPHER_SCAN + self._cipher_scan_start = time.time() + # Feed remaining bytes from this frame into cipher scan + remaining = data[len(data) - dis.remaining:] + if remaining: + self._handle_cipher_scan(remaining) + except Exception as e: + self._log(f"Bot '{self._bot.name}' Login parse error: {e}") + + elif packet_id == DISCONNECT: + try: + dis = DataInputStream(data) + reason_id = dis.read_int() + reason = DISCONNECT_REASONS.get(reason_id, f"Unknown({reason_id})") + self._log(f"Bot '{self._bot.name}' kicked: {reason}") + except Exception: + self._log(f"Bot '{self._bot.name}' kicked (unknown reason)") + self._bot.running = False + + def _handle_cipher_scan(self, payload: bytes): + self._scan_buf.extend(payload) + + # Timeout: no cipher key after 3s = server has cipher disabled + if (not self._cipher_key + and time.time() - self._cipher_scan_start > 3.0): + self._log(f"Bot '{self._bot.name}' no cipher, proceeding unencrypted") + self._phase = PHASE_MONITORING + self._bot.connected = True + self._on_connected() + buf = bytes(self._scan_buf) + self._scan_buf.clear() + if buf: + self._handle_monitoring(buf) + return + + # Look for MC|CKey + if not self._cipher_key: + idx = self._scan_buf.find(CIPHER_KEY_PREFIX) + if idx >= 0 and idx + CIPHER_KEY_TOTAL <= len(self._scan_buf): + key_start = idx + len(CIPHER_KEY_PREFIX) + key_data = bytes(self._scan_buf[key_start:key_start + 32]) + self._cipher_key = key_data[:16] + self._cipher_iv = key_data[16:32] + self._log(f"Bot '{self._bot.name}' got cipher key") + + # Send MC|CAck and activate send cipher + self._send_packet(CUSTOM_PAYLOAD, build_custom_payload(CIPHER_ACK_CHANNEL)) + iv_send = bytearray(self._cipher_iv) + iv_send[0] ^= 0x80 + self._send_cipher = CipherState(self._cipher_key, bytes(iv_send)) + self._log(f"Bot '{self._bot.name}' send cipher active") + + del self._scan_buf[:idx + CIPHER_KEY_TOTAL] + + # Look for MC|COn + if self._cipher_key and not self._recv_cipher: + idx = self._scan_buf.find(CIPHER_ON_PATTERN) + if idx >= 0: + self._recv_cipher = CipherState(self._cipher_key, self._cipher_iv) + self._log(f"Bot '{self._bot.name}' cipher handshake complete") + self._phase = PHASE_MONITORING + self._bot.connected = True + self._on_connected() + self._scan_buf.clear() + + def _handle_monitoring(self, payload: bytes): + self._scan_buf.extend(payload) + self._scan_identity_tokens() + # Trim buffer + if len(self._scan_buf) > 4096: + del self._scan_buf[:-512] + + def _scan_identity_tokens(self): + # MC|CTIssue: server sends a token for us to store + issue_prefix = _build_channel_pattern(IDENTITY_TOKEN_ISSUE) + idx = self._scan_buf.find(issue_prefix) + if idx >= 0: + data_start = idx + len(issue_prefix) + if data_start + 2 + 32 <= len(self._scan_buf): + data_len = struct.unpack(">h", self._scan_buf[data_start:data_start+2])[0] + if data_len == 32: + token = bytes(self._scan_buf[data_start+2:data_start+2+32]) + self._identity_token = token + self._log(f"Bot '{self._bot.name}' received identity token") + del self._scan_buf[:data_start + 2 + 32] + return + + # MC|CTChallenge: server wants us to present our token + challenge_prefix = _build_channel_pattern(IDENTITY_TOKEN_CHALLENGE) + idx = self._scan_buf.find(challenge_prefix) + if idx >= 0: + data_start = idx + len(challenge_prefix) + if data_start + 2 <= len(self._scan_buf): + del self._scan_buf[:data_start + 2] + if self._identity_token and len(self._identity_token) == 32: + self._send_packet(CUSTOM_PAYLOAD, + build_custom_payload(IDENTITY_TOKEN_RESPONSE, self._identity_token)) + self._log(f"Bot '{self._bot.name}' sent identity token response") + else: + self._send_packet(CUSTOM_PAYLOAD, + build_custom_payload(IDENTITY_TOKEN_RESPONSE)) + self._log(f"Bot '{self._bot.name}' sent empty identity token response") + + def _send_packet(self, packet_id: int, payload: bytes): + raw = frame_packet(packet_id, payload) + if self._send_cipher: + header = raw[:4] + encrypted = self._send_cipher.process(raw[4:]) + raw = header + encrypted + try: + if self._sock: + self._sock.sendall(raw) + except OSError: + pass + + def _recv_exact(self, n: int) -> bytes: + data = b"" + while len(data) < n: + chunk = self._sock.recv(n - len(data)) + if not chunk: + raise ConnectionError("Connection closed during recv") + data += chunk + return data + + +class BotManager(QObject): + """Manages bot player connections for load testing.""" + + bot_added = Signal(str) + bot_removed = Signal(str) + bot_error = Signal(str, str) + log = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._bots: dict[str, BotState] = {} + self._lock = threading.Lock() + self._next_id = 1 + + @property + def bot_count(self) -> int: + with self._lock: + return len(self._bots) + + def add_bot(self, host: str, port: int) -> str: + with self._lock: + name = f"Bot{self._next_id}" + self._next_id += 1 + xuid = secrets.randbits(62) | (1 << 32) + + bot = BotState(name=name) + bot.running = True + bot.thread = threading.Thread( + target=self._run_bot, args=(bot, host, port, xuid), + daemon=True) + + with self._lock: + self._bots[name] = bot + + bot.thread.start() + return name + + def remove_bot(self) -> str | None: + with self._lock: + if not self._bots: + return None + name = list(self._bots.keys())[-1] + bot = self._bots.pop(name) + + bot.running = False + if bot.sock: + try: + bot.sock.close() + except OSError: + pass + self.bot_removed.emit(name) + self.log.emit(f"Bot '{name}' disconnecting") + return name + + def remove_all(self): + with self._lock: + names = list(self._bots.keys()) + for _ in names: + self.remove_bot() + + def _run_bot(self, bot: BotState, host: str, port: int, xuid: int): + try: + def on_connected(): + self.bot_added.emit(bot.name) + + conn = BotConnection(bot, host, port, xuid, self.log.emit, on_connected) + conn.run() + except Exception as e: + self.bot_error.emit(bot.name, str(e)) + self.log.emit(f"Bot '{bot.name}' error: {e}") + finally: + bot.running = False + bot.connected = False + if bot.sock: + try: + bot.sock.close() + except OSError: + pass + bot.sock = None + with self._lock: + self._bots.pop(bot.name, None) diff --git a/tools/performance-monitor/build-dll.bat b/tools/performance-monitor/build-dll.bat new file mode 100644 index 00000000..c1e911c6 --- /dev/null +++ b/tools/performance-monitor/build-dll.bat @@ -0,0 +1,25 @@ +@echo off +echo Building perf-monitor.dll... +cd /d "%~dp0dll" + +if not exist build mkdir build +cd build + +cmake .. -G "Visual Studio 17 2022" -A x64 +if errorlevel 1 ( + echo CMake configure failed! + pause + exit /b 1 +) + +cmake --build . --config Release +if errorlevel 1 ( + echo Build failed! + pause + exit /b 1 +) + +echo. +echo Build successful! +echo DLL: %cd%\Release\perf-monitor.dll +pause diff --git a/tools/performance-monitor/connection.py b/tools/performance-monitor/connection.py new file mode 100644 index 00000000..fb609e56 --- /dev/null +++ b/tools/performance-monitor/connection.py @@ -0,0 +1,157 @@ +""" +TCP client for the injected performance monitor DLL. + +Connects to the DLL's TCP server, reads length-prefixed JSON frames, +parses them into model objects, and emits Qt signals. +""" + +import json +import logging +import socket +import struct +import threading +import time + +from PySide6.QtCore import QObject, Signal + +from models import ( + TickSnapshot, AutosaveSnapshot, + parse_tick, parse_autosave, +) + +logger = logging.getLogger("perfmon") + + +class PerfConnection(QObject): + """Manages TCP connection to the injected DLL's metrics server.""" + + connected = Signal(dict) # hello_ack payload + disconnected = Signal(str) # reason + tick_received = Signal(object) # TickSnapshot + autosave_received = Signal(object) # AutosaveSnapshot + error = Signal(str) + log = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._sock: socket.socket | None = None + self._thread: threading.Thread | None = None + self._running = False + + @property + def is_connected(self) -> bool: + return self._running and self._sock is not None + + def connect_to(self, host: str, port: int): + if self._running: + return + self._running = True + self._thread = threading.Thread( + target=self._run, args=(host, port), daemon=True + ) + self._thread.start() + + def disconnect(self): + self._running = False + if self._sock: + try: + self._sock.close() + except OSError: + pass + + def _run(self, host: str, port: int): + try: + self._do_connect(host, port) + except Exception as e: + self.error.emit(str(e)) + finally: + self._running = False + if self._sock: + try: + self._sock.close() + except OSError: + pass + self._sock = None + + def _do_connect(self, host: str, port: int): + self.log.emit(f"Connecting to {host}:{port}...") + self._sock = socket.create_connection((host, port), timeout=10) + self._sock.settimeout(2.0) + + self.log.emit("Connected, waiting for hello_ack...") + + # Read frames in a loop + recv_buf = bytearray() + + while self._running: + try: + chunk = self._sock.recv(65536) + except socket.timeout: + continue + except OSError: + break + + if not chunk: + break + + recv_buf.extend(chunk) + + # Process complete frames + while len(recv_buf) >= 4: + payload_len = struct.unpack(">I", recv_buf[:4])[0] + + if payload_len > 1_000_000: + raw_hex = recv_buf[:min(32, len(recv_buf))].hex() + self.error.emit( + f"Frame too large: {payload_len} bytes " + f"(0x{payload_len:08X}). Raw: {raw_hex} -- " + f"wrong port? DLL listens on 19800 by default" + ) + self._running = False + break + + total = 4 + payload_len + if len(recv_buf) < total: + break + + json_bytes = bytes(recv_buf[4:total]) + del recv_buf[:total] + + try: + data = json.loads(json_bytes) + except json.JSONDecodeError as e: + logger.warning("Invalid JSON frame: %s", e) + continue + + self._dispatch(data) + + self.log.emit("Connection lost") + self.disconnected.emit("Connection closed") + time.sleep(0.1) + + def _dispatch(self, data: dict): + msg_type = data.get("type", "") + + if msg_type == "hello_ack": + self.log.emit( + f"Server: {data.get('level_count', '?')} levels, " + f"target TPS {data.get('server_tps_target', 20)}" + ) + self.connected.emit(data) + + elif msg_type == "tick": + try: + snap = parse_tick(data) + self.tick_received.emit(snap) + except Exception as e: + logger.warning("Failed to parse tick: %s", e) + + elif msg_type == "autosave": + try: + snap = parse_autosave(data) + self.autosave_received.emit(snap) + except Exception as e: + logger.warning("Failed to parse autosave: %s", e) + + else: + self.log.emit(f"Unknown message type: {msg_type}") diff --git a/tools/performance-monitor/dll/CMakeLists.txt b/tools/performance-monitor/dll/CMakeLists.txt new file mode 100644 index 00000000..3222aaf8 --- /dev/null +++ b/tools/performance-monitor/dll/CMakeLists.txt @@ -0,0 +1,33 @@ +cmake_minimum_required(VERSION 3.20) +project(PerfMonitor LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_library(perf-monitor SHARED + src/dllmain.cpp + src/hooks.cpp + src/symbols.cpp + src/metrics.cpp + src/tcp_server.cpp + src/json_writer.cpp +) + +target_include_directories(perf-monitor PRIVATE src) + +target_link_libraries(perf-monitor PRIVATE + dbghelp # PDB symbol resolution + ws2_32 # Winsock TCP server + psapi # GetProcessMemoryInfo +) + +target_compile_definitions(perf-monitor PRIVATE + WIN32_LEAN_AND_MEAN + NOMINMAX + _CRT_SECURE_NO_WARNINGS +) + +# Use /MT to match the server's static CRT (MultiThreaded / MultiThreadedDebug) +set_property(TARGET perf-monitor PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" +) diff --git a/tools/performance-monitor/dll/src/dllmain.cpp b/tools/performance-monitor/dll/src/dllmain.cpp new file mode 100644 index 00000000..d7c52d98 --- /dev/null +++ b/tools/performance-monitor/dll/src/dllmain.cpp @@ -0,0 +1,172 @@ +// +// dllmain.cpp -- DLL entry point. +// +// When injected into Minecraft.Server.exe: +// 1. Resolve MinecraftServer::tick() via PDB symbols +// 2. Install a trampoline hook on tick() +// 3. Start a TCP server for the GUI to connect to +// +// On unload (FreeLibrary or process exit): +// 1. Remove the hook (restore original bytes) +// 2. Stop the TCP server +// + +#include "perf_monitor.h" +#include +#include + +// ----------------------------------------------------------------------- +// Globals +// ----------------------------------------------------------------------- + +std::atomic g_hooksInstalled{false}; +std::atomic g_clientConnected{false}; + +static HMODULE g_hModule = nullptr; +static HANDLE g_initThread = nullptr; + +static const int DEFAULT_PORT = 19800; +ResolvedSymbols g_symbols = {}; + +// ----------------------------------------------------------------------- +// Logging +// ----------------------------------------------------------------------- + +static FILE *g_logFile = nullptr; +static std::mutex g_logMtx; + +void LogInit(HMODULE hModule) +{ + char path[MAX_PATH]; + GetModuleFileNameA(hModule, path, MAX_PATH); + + // Replace DLL name with log name + char *sep = strrchr(path, '\\'); + if (sep) { + strcpy(sep + 1, "perf-monitor.log"); + } else { + strcpy(path, "perf-monitor.log"); + } + + g_logFile = fopen(path, "w"); +} + +void LogWrite(const char *fmt, ...) +{ + if (!g_logFile) return; + + std::lock_guard lock(g_logMtx); + + // Timestamp + SYSTEMTIME st; + GetLocalTime(&st); + fprintf(g_logFile, "%02d:%02d:%02d.%03d ", + st.wHour, st.wMinute, st.wSecond, st.wMilliseconds); + + va_list args; + va_start(args, fmt); + vfprintf(g_logFile, fmt, args); + va_end(args); + + fprintf(g_logFile, "\n"); + fflush(g_logFile); +} + +void LogShutdown() +{ + if (g_logFile) { + fclose(g_logFile); + g_logFile = nullptr; + } +} + +// ----------------------------------------------------------------------- +// Initialization thread (runs after injection) +// ----------------------------------------------------------------------- + +static DWORD WINAPI InitThread(LPVOID) +{ + LogWrite("perf-monitor.dll loaded into PID %u", GetCurrentProcessId()); + + // Get the base address of the main executable module + HMODULE hExe = GetModuleHandleA(nullptr); + if (!hExe) { + LogWrite("FATAL: GetModuleHandle(NULL) failed"); + return 1; + } + uintptr_t moduleBase = reinterpret_cast(hExe); + LogWrite("Module base: 0x%llx", (unsigned long long)moduleBase); + + // Resolve symbols from PDB + ResolvedSymbols syms{}; + HANDLE hProcess = GetCurrentProcess(); + + if (!ResolveSymbols(hProcess, moduleBase, syms)) { + LogWrite("FATAL: Symbol resolution failed"); + return 1; + } + + g_symbols = syms; + LogWrite("MinecraftServer::tick() at 0x%llx", (unsigned long long)syms.MinecraftServer_tick); + LogWrite("MinecraftServer::getInstance() at 0x%llx", (unsigned long long)syms.MinecraftServer_getInstance); + LogWrite("ServerLevel::getChunkMap() at 0x%llx", (unsigned long long)syms.ServerLevel_getChunkMap); + + // Initialize metrics subsystem + MetricsInit(); + + // Install the tick hook + if (!InstallTickHook(syms.MinecraftServer_tick)) { + LogWrite("FATAL: Hook installation failed"); + return 1; + } + LogWrite("Tick hook installed"); + + // Start TCP server + if (!TcpServerStart(DEFAULT_PORT)) { + LogWrite("FATAL: TCP server failed to start on port %d", DEFAULT_PORT); + RemoveTickHook(); + return 1; + } + LogWrite("TCP server listening on port %d", DEFAULT_PORT); + + return 0; +} + +// ----------------------------------------------------------------------- +// DllMain +// ----------------------------------------------------------------------- + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID) +{ + switch (reason) { + case DLL_PROCESS_ATTACH: + DisableThreadLibraryCalls(hModule); + g_hModule = hModule; + LogInit(hModule); + + // Spawn initialization on a separate thread so DllMain returns + // quickly (loader lock). + g_initThread = CreateThread(nullptr, 0, InitThread, nullptr, 0, nullptr); + break; + + case DLL_PROCESS_DETACH: + LogWrite("DLL detaching..."); + + // Wait for init to finish before tearing down + if (g_initThread) { + WaitForSingleObject(g_initThread, 5000); + CloseHandle(g_initThread); + g_initThread = nullptr; + } + + TcpServerStop(); + RemoveTickHook(); + MetricsShutdown(); + + LogWrite("Cleanup complete"); + LogShutdown(); + break; + } + + return TRUE; +} diff --git a/tools/performance-monitor/dll/src/hooks.cpp b/tools/performance-monitor/dll/src/hooks.cpp new file mode 100644 index 00000000..af6e124b --- /dev/null +++ b/tools/performance-monitor/dll/src/hooks.cpp @@ -0,0 +1,337 @@ +// +// hooks.cpp -- Trampoline hook on MinecraftServer::tick(). +// +// Overwrites the first 14 bytes of tick() with JMP to HookedTick. +// The trampoline contains the saved prologue bytes + JMP back to tick+N. +// +// The previous crash was caused by a server autosave bug, not the +// trampoline. With that fixed, this approach is stable. +// + +#include "perf_monitor.h" +#include +#include + +// ----------------------------------------------------------------------- +// State +// ----------------------------------------------------------------------- + +static constexpr int HOOK_SIZE = 14; // FF 25 00 00 00 00 + 8-byte addr + +static uint8_t g_originalBytes[32] = {}; +static uintptr_t g_tickAddr = 0; +static uint8_t *g_trampoline = nullptr; +static int g_prologueLen = 0; + +static int64_t g_qpcFreq = 0; + +using TickFn = void(__fastcall *)(void *thisPtr); + +// ----------------------------------------------------------------------- +// Timing helpers +// ----------------------------------------------------------------------- + +static inline int64_t NowUs() +{ + LARGE_INTEGER li; + QueryPerformanceCounter(&li); + return (li.QuadPart * 1000000) / g_qpcFreq; +} + +static inline int64_t NowMs() +{ + FILETIME ft; + GetSystemTimeAsFileTime(&ft); + ULARGE_INTEGER uli; + uli.LowPart = ft.dwLowDateTime; + uli.HighPart = ft.dwHighDateTime; + return (int64_t)((uli.QuadPart - 116444736000000000ULL) / 10000ULL); +} + +// ----------------------------------------------------------------------- +// Instruction length decoder +// ----------------------------------------------------------------------- + +static int GetInstructionLength(const uint8_t *ip) +{ + const uint8_t *p = ip; + + // Skip prefixes + while ((*p >= 0x40 && *p <= 0x4F) || *p == 0x66 || *p == 0x67 || + *p == 0xF2 || *p == 0xF3) { + p++; + } + + uint8_t op = *p++; + + // Simple 1-byte opcodes + if (op == 0x90 || op == 0xC3 || op == 0xCC) return (int)(p - ip); + if (op >= 0x50 && op <= 0x5F) return (int)(p - ip); + + // MOV reg, imm + if (op >= 0xB8 && op <= 0xBF) { + bool rexW = (ip[0] >= 0x48 && ip[0] <= 0x4F); + return (int)(p - ip) + (rexW ? 8 : 4); + } + + // Two-byte escape + if (op == 0x0F) { p++; } + + // ModRM-based instructions + { + uint8_t modrm = *p++; + uint8_t mod = (modrm >> 6) & 3; + uint8_t rm = modrm & 7; + + if (rm == 4 && mod != 3) p++; // SIB + + if (mod == 0 && rm == 5) p += 4; // [rip+disp32] + else if (mod == 1) p += 1; // [reg+disp8] + else if (mod == 2) p += 4; // [reg+disp32] + + // Group opcodes with immediate + uint8_t base_op = ip[(p - ip > 2 && (ip[0] >= 0x40 && ip[0] <= 0x4F)) ? 1 : 0]; + if (base_op == 0x81) p += 4; + else if (base_op == 0x80 || base_op == 0x83) p += 1; + } + + int len = (int)(p - ip); + return (len > 0) ? len : 1; +} + +static int CopyPrologue(const uint8_t *src, uint8_t *dst, int minBytes) +{ + int copied = 0; + while (copied < minBytes) { + int len = GetInstructionLength(src + copied); + memcpy(dst + copied, src + copied, len); + copied += len; + if (copied > 48) break; + } + return copied; +} + +// ----------------------------------------------------------------------- +// The hook function +// ----------------------------------------------------------------------- + +static int g_tickCounter = 0; +static double g_memUsedMb = 0.0; +static double g_memTotalMb = 0.0; +static int g_memUpdateCounter = 0; + +// SEH-safe level reader (separate from player count to avoid one killing the other) +static void ReadLevels(void *thisPtr, std::vector &out) +{ + LevelMetrics localLevels[3] = {}; + uint32_t levelCount = 0; + + __try { + auto base = reinterpret_cast(thisPtr); + auto levelsData = *reinterpret_cast(base + 24); + auto levelsLength = *reinterpret_cast(base + 32); + + if (levelsData && levelsLength > 0 && levelsLength <= 3) { + levelCount = levelsLength; + for (uint32_t i = 0; i < levelsLength; i++) { + localLevels[i].dimension = (i == 0) ? 0 : (i == 1) ? -1 : 1; + if (levelsData[i]) { + CollectLevelMetrics(levelsData[i], i, localLevels[i]); + } + } + } + } + __except (EXCEPTION_EXECUTE_HANDLER) { + static bool logged = false; + if (!logged) { + LogWrite("WARNING: Exception reading level data"); + logged = true; + } + return; + } + + for (uint32_t i = 0; i < levelCount; i++) { + out.push_back(localLevels[i]); + } +} + + +// Track the time between tick() calls to measure true TPS. +// When autosave (or anything else in run()) stalls, the next tick is +// delayed, and the interval grows beyond 50ms. +static int64_t g_lastTickStartUs = 0; +static int64_t g_tickIntervalUs = 50000; // default = 50ms = 20 TPS +static bool g_skipSnapshot = false; // suppress catch-up ticks + +static void __fastcall HookedTick(void *thisPtr) +{ + auto originalTick = reinterpret_cast(g_trampoline); + + int64_t tickStart = NowUs(); + int64_t rawInterval = 50000; + if (g_lastTickStartUs > 0) { + rawInterval = tickStart - g_lastTickStartUs; + } + g_lastTickStartUs = tickStart; + + // Detect catch-up ticks: after a stall, run() fires tick() rapidly + // to burn through accumulated unprocessedTime. These have intervals + // well below 50ms. We still call tick() but don't report them to + // the GUI -- they'd flood the TPS average with fake "20 TPS" readings. + // Only the stall tick itself (interval > 50ms) gets reported. + if (rawInterval < 25000) { + // Catch-up tick: run it but don't emit a snapshot + g_skipSnapshot = true; + } else { + g_tickIntervalUs = rawInterval; + g_skipSnapshot = false; + } + + if (!g_clientConnected.load(std::memory_order_relaxed)) { + originalTick(thisPtr); + return; + } + + originalTick(thisPtr); + + // Skip catch-up ticks - only emit real-time ticks to the GUI + if (g_skipSnapshot) return; + + int64_t tickEnd = NowUs(); + int64_t workUs = tickEnd - tickStart; + + g_tickCounter++; + + g_memUpdateCounter++; + if (g_memUpdateCounter >= 20) { + g_memUpdateCounter = 0; + CollectMemoryMetrics(g_memUsedMb, g_memTotalMb); + } + + std::vector levelMetrics; + ReadLevels(thisPtr, levelMetrics); + int totalPlayers = 0; + for (auto &lm : levelMetrics) totalPlayers += lm.playerCount; + + TickSnapshot snap{}; + snap.tick = g_tickCounter; + snap.timestampMs = NowMs(); + snap.totalUs = g_tickIntervalUs; // tick-to-tick interval (for TPS) + snap.poolResetUs = workUs; // tick() work time (for MSPT) + snap.levels = std::move(levelMetrics); + snap.playersTickUs = 0; + snap.connectionTickUs = 0; + snap.consoleTickUs = 0; + snap.totalPlayers = totalPlayers; + snap.memoryUsedMb = g_memUsedMb; + snap.memoryTotalMb = g_memTotalMb; + snap.isAutosaving = false; + snap.isPaused = false; + + PushTickSnapshot(std::move(snap)); +} + +// ----------------------------------------------------------------------- +// Hook installation +// ----------------------------------------------------------------------- + +bool InstallTickHook(uintptr_t tickAddr) +{ + LARGE_INTEGER freq; + QueryPerformanceFrequency(&freq); + g_qpcFreq = freq.QuadPart; + + g_tickAddr = tickAddr; + + // Log first 32 bytes of tick() + { + const uint8_t *p = reinterpret_cast(tickAddr); + char hex[128]; + int pos = 0; + for (int i = 0; i < 32 && pos < 120; i++) + pos += snprintf(hex + pos, 128 - pos, "%02X ", p[i]); + LogWrite("tick() prologue: %s", hex); + } + + memcpy(g_originalBytes, reinterpret_cast(tickAddr), 32); + + // Allocate trampoline + g_trampoline = static_cast( + VirtualAlloc(nullptr, 128, MEM_COMMIT | MEM_RESERVE, + PAGE_EXECUTE_READWRITE)); + if (!g_trampoline) { + LogWrite("VirtualAlloc failed: 0x%08x", GetLastError()); + return false; + } + + // Copy prologue + g_prologueLen = CopyPrologue( + reinterpret_cast(tickAddr), + g_trampoline, HOOK_SIZE); + LogWrite("Copied %d prologue bytes", g_prologueLen); + + // Log decoded instructions + { + const uint8_t *p = reinterpret_cast(tickAddr); + int off = 0; + while (off < g_prologueLen) { + int len = GetInstructionLength(p + off); + char hex[64]; int hpos = 0; + for (int i = 0; i < len && hpos < 60; i++) + hpos += snprintf(hex + hpos, 64 - hpos, "%02X ", p[off + i]); + LogWrite(" +%d: [%d] %s", off, len, hex); + off += len; + } + } + + // Append JMP back to tick + prologueLen + uint8_t *jmpBack = g_trampoline + g_prologueLen; + uintptr_t resumeAddr = tickAddr + g_prologueLen; + jmpBack[0] = 0xFF; + jmpBack[1] = 0x25; + *reinterpret_cast(jmpBack + 2) = 0; + *reinterpret_cast(jmpBack + 6) = resumeAddr; + + // Patch tick() entry to JMP to HookedTick + DWORD oldProtect; + if (!VirtualProtect(reinterpret_cast(tickAddr), HOOK_SIZE, + PAGE_EXECUTE_READWRITE, &oldProtect)) { + LogWrite("VirtualProtect failed: 0x%08x", GetLastError()); + VirtualFree(g_trampoline, 0, MEM_RELEASE); + g_trampoline = nullptr; + return false; + } + + uint8_t *patch = reinterpret_cast(tickAddr); + patch[0] = 0xFF; + patch[1] = 0x25; + *reinterpret_cast(patch + 2) = 0; + *reinterpret_cast(patch + 6) = reinterpret_cast(&HookedTick); + + VirtualProtect(reinterpret_cast(tickAddr), HOOK_SIZE, oldProtect, &oldProtect); + FlushInstructionCache(GetCurrentProcess(), reinterpret_cast(tickAddr), HOOK_SIZE); + + g_hooksInstalled.store(true, std::memory_order_release); + return true; +} + +void RemoveTickHook() +{ + if (!g_hooksInstalled.load()) return; + + DWORD oldProtect; + VirtualProtect(reinterpret_cast(g_tickAddr), 32, + PAGE_EXECUTE_READWRITE, &oldProtect); + memcpy(reinterpret_cast(g_tickAddr), g_originalBytes, g_prologueLen); + VirtualProtect(reinterpret_cast(g_tickAddr), 32, + oldProtect, &oldProtect); + FlushInstructionCache(GetCurrentProcess(), reinterpret_cast(g_tickAddr), 32); + + if (g_trampoline) { + VirtualFree(g_trampoline, 0, MEM_RELEASE); + g_trampoline = nullptr; + } + + g_hooksInstalled.store(false, std::memory_order_release); + LogWrite("Tick hook removed"); +} diff --git a/tools/performance-monitor/dll/src/json_writer.cpp b/tools/performance-monitor/dll/src/json_writer.cpp new file mode 100644 index 00000000..7592f589 --- /dev/null +++ b/tools/performance-monitor/dll/src/json_writer.cpp @@ -0,0 +1,132 @@ +// +// json_writer.cpp -- Hand-rolled JSON serialization for metric snapshots. +// +// No external JSON library dependency. The schema is fixed and simple +// enough that snprintf is cleaner than pulling in nlohmann/json. +// + +#include "perf_monitor.h" +#include + +// Stack buffer size for JSON assembly. A typical tick snapshot is +// 500-1000 bytes. Autosave snapshots are smaller. +static constexpr int BUF_SIZE = 4096; + +std::string SerializeTick(const TickSnapshot &s) +{ + char buf[BUF_SIZE]; + int pos = 0; + + pos += snprintf(buf + pos, BUF_SIZE - pos, + "{\"type\":\"tick\"," + "\"tick\":%d," + "\"timestamp_ms\":%lld," + "\"total_us\":%lld," + "\"phases\":{", + s.tick, + (long long)s.timestampMs, + (long long)s.totalUs); + + pos += snprintf(buf + pos, BUF_SIZE - pos, + "\"pool_reset_us\":%lld,", + (long long)s.poolResetUs); + + // Levels array + pos += snprintf(buf + pos, BUF_SIZE - pos, "\"levels\":["); + for (size_t i = 0; i < s.levels.size(); i++) { + const auto &lm = s.levels[i]; + if (i > 0) buf[pos++] = ','; + + pos += snprintf(buf + pos, BUF_SIZE - pos, + "{" + "\"dimension\":%d," + "\"level_tick_us\":%lld," + "\"entity_tick_us\":%lld," + "\"entity_tick_skipped\":%s," + "\"tracker_tick_us\":%lld," + "\"entity_count\":%d," + "\"global_entity_count\":%d," + "\"player_count\":%d," + "\"loaded_chunks\":%d," + "\"entities_to_remove\":%d," + "\"tile_entity_count\":%d" + "}", + lm.dimension, + (long long)lm.levelTickUs, + (long long)lm.entityTickUs, + lm.entityTickSkipped ? "true" : "false", + (long long)lm.trackerTickUs, + lm.entityCount, + lm.globalEntityCount, + lm.playerCount, + lm.loadedChunks, + lm.entitiesToRemove, + lm.tileEntityCount); + } + pos += snprintf(buf + pos, BUF_SIZE - pos, "],"); + + pos += snprintf(buf + pos, BUF_SIZE - pos, + "\"players_tick_us\":%lld," + "\"connection_tick_us\":%lld," + "\"console_tick_us\":%lld" + "},", + (long long)s.playersTickUs, + (long long)s.connectionTickUs, + (long long)s.consoleTickUs); + + pos += snprintf(buf + pos, BUF_SIZE - pos, + "\"server\":{" + "\"total_players\":%d," + "\"memory_used_mb\":%.1f," + "\"memory_total_mb\":%.1f," + "\"is_autosaving\":%s," + "\"is_paused\":%s," + "\"tps_target\":20" + "}}", + s.totalPlayers, + s.memoryUsedMb, + s.memoryTotalMb, + s.isAutosaving ? "true" : "false", + s.isPaused ? "true" : "false"); + + return std::string(buf, pos); +} + +std::string SerializeAutosave(const AutosaveSnapshot &s) +{ + char buf[BUF_SIZE]; + int pos = snprintf(buf, BUF_SIZE, + "{\"type\":\"autosave\"," + "\"tick\":%d," + "\"timestamp_ms\":%lld," + "\"total_us\":%lld," + "\"breakdown\":{" + "\"players_us\":%lld," + "\"levels_us\":%lld," + "\"rules_us\":%lld," + "\"flush_us\":%lld" + "}}", + s.tick, + (long long)s.timestampMs, + (long long)s.totalUs, + (long long)s.playersUs, + (long long)s.levelsUs, + (long long)s.rulesUs, + (long long)s.flushUs); + + return std::string(buf, pos); +} + +std::string SerializeHelloAck(int levelCount) +{ + char buf[256]; + int pos = snprintf(buf, 256, + "{\"type\":\"hello_ack\"," + "\"version\":1," + "\"server_tps_target\":20," + "\"level_count\":%d," + "\"dimensions\":[0,-1,1]}", + levelCount); + + return std::string(buf, pos); +} diff --git a/tools/performance-monitor/dll/src/metrics.cpp b/tools/performance-monitor/dll/src/metrics.cpp new file mode 100644 index 00000000..8d2108bd --- /dev/null +++ b/tools/performance-monitor/dll/src/metrics.cpp @@ -0,0 +1,384 @@ +// +// metrics.cpp -- Server metric collection and snapshot queuing. +// +// Reads entity counts, chunk counts, player counts, and memory usage +// from the server's live data structures. +// + +#include "perf_monitor.h" +#include +#include + +// ----------------------------------------------------------------------- +// Snapshot queues (game thread pushes, TCP thread drains) +// ----------------------------------------------------------------------- + +static std::mutex g_tickMtx; +static std::deque g_tickQueue; +static constexpr size_t MAX_TICK_QUEUE = 200; // ~10 seconds at 20 TPS + +static std::mutex g_autoMtx; +static std::deque g_autoQueue; +static constexpr size_t MAX_AUTO_QUEUE = 20; + +// ----------------------------------------------------------------------- +// Initialization +// ----------------------------------------------------------------------- + +void MetricsInit() +{ +} + +void MetricsShutdown() +{ + std::lock_guard lock1(g_tickMtx); + g_tickQueue.clear(); + std::lock_guard lock2(g_autoMtx); + g_autoQueue.clear(); +} + +// ----------------------------------------------------------------------- +// Level metrics collection +// +// Level layout on MSVC x64, derived from Level.h and confirmed by +// runtime vector scan (see perf-monitor.log for the calibration output): +// +// offset 0: vtable ptr (from LevelSource) 8 bytes +// offset 8: seaLevel (int) 4 bytes +// offset 12: padding 4 bytes +// offset 16: m_entitiesCS (CRITICAL_SECTION) 40 bytes +// offset 56: padding to 8-byte alignment 8 bytes +// offset 64: entities (vector>) 24 bytes +// offset 88: entitiesToRemove (vector>) 24 bytes +// offset 112: m_bDisableAddNewTileEntities (bool) 1 byte +// offset 113: padding 7 bytes +// offset 120: m_tileEntityListCS (CRITICAL_SECTION) 40 bytes +// offset 160: tileEntityList (vector>) 24 bytes +// offset 184: pendingTileEntities (vector) 24 bytes +// offset 208: tileEntitiesToUnload (vector) 24 bytes +// offset 232: updatingTileEntities (bool) 1 byte +// offset 233: padding 7 bytes +// offset 240: players (vector>) 24 bytes +// offset 264: globalEntities (vector>) 24 bytes +// +// These offsets are validated against a live debug server. If they shift +// in a future build, the SEH guards will catch bad reads and the log will +// show -1 for the affected counts. Re-run the offset scan to recalibrate. +// ----------------------------------------------------------------------- + +// std::vector> on MSVC x64: +// +0: _Myfirst (pointer to first element) +// +8: _Mylast (pointer past last element) +// +16: _Myend (pointer past allocated capacity) +// sizeof(shared_ptr) = 16 (raw ptr + ref count ptr) + +static int ReadVectorSize(uint8_t *vecBase, int elementSize) +{ + auto first = *reinterpret_cast(vecBase + 0); + auto last = *reinterpret_cast(vecBase + 8); + + if (!first || !last || last < first) return 0; + + auto diff = last - first; + if (elementSize <= 0) return 0; + + int count = (int)(diff / elementSize); + + // Sanity: reject absurd values (corrupted memory) + if (count < 0 || count > 100000) return 0; + + return count; +} + +// Level member offsets -- calibrated from runtime scan. +// If these are wrong for a given build, the SEH guards return -1 and the +// log records the failure. Rerun with a fresh scan to recalibrate. +// Level member offsets differ between Debug and Release builds because +// CRITICAL_SECTION is 48 bytes in Debug (extra padding) vs 40 in Release. +// We auto-detect by checking where the entities vector is (the first +// reliably identifiable vector in the object). +// +// Debug layout (CS=48): entities=+64, tileEntityList=+176, players=+280, globalEntities=+256 +// Release layout (CS=40): entities=+56, tileEntityList=+152, players=+232, globalEntities=+256 +namespace LevelOff { + static int entities = 0; + static int entitiesToRemove = 0; + static int tileEntityList = 0; + static int players = 0; + static int globalEntities = 0; + static bool detected = false; + + static void Detect(int entitiesOffset) + { + if (detected) return; + detected = true; + + if (entitiesOffset == 64) { + // Debug build (CS=48 with extra padding) + entities = 64; + entitiesToRemove = 88; + tileEntityList = 176; + players = 280; + globalEntities = 256; + LogWrite("Level offsets: DEBUG layout (entities=+64)"); + } else if (entitiesOffset == 56) { + // Release build (CS=40) + entities = 56; + entitiesToRemove = 80; + tileEntityList = 152; + players = 232; + globalEntities = 256; + LogWrite("Level offsets: RELEASE layout (entities=+56)"); + } else { + // Unknown layout - use the detected entities offset and estimate + entities = entitiesOffset; + LogWrite("Level offsets: UNKNOWN layout (entities=+%d), entity count only", entitiesOffset); + } + } +} + +// Scan for offset calibration -- writes to the log so we can update the +// constants above if the layout changes. Called once on first tick. +static bool g_scanDone = false; + +// SEH-safe inner scan (no C++ objects with destructors) +struct VecCandidate { int offset; int count; }; +static constexpr int MAX_SCAN_CANDIDATES = 64; +static VecCandidate g_scanResults[MAX_SCAN_CANDIDATES]; +static int g_scanCount = 0; + +static void ScanLevelOffsetsInner(void *serverLevel) +{ + auto base = reinterpret_cast(serverLevel); + g_scanCount = 0; + + __try { + for (int off = 0; off < 400 && g_scanCount < MAX_SCAN_CANDIDATES; off += 8) { + auto first = *reinterpret_cast(base + off); + auto last = *reinterpret_cast(base + off + 8); + auto end = *reinterpret_cast(base + off + 16); + + if (!first && !last && !end) continue; + + auto ok = [](uintptr_t p) -> bool { + return p == 0 || (p > 0x10000 && p < 0x7FFFFFFFFFFF); + }; + if (!ok(first) || !ok(last) || !ok(end)) continue; + if (first > last || last > end) continue; + + // Try both shared_ptr (16 bytes) and raw ptr (8 bytes) + int count16 = 0, count8 = 0; + if (first && last > first) { + auto diff = last - first; + if (diff % 16 == 0) count16 = (int)(diff / 16); + if (diff % 8 == 0) count8 = (int)(diff / 8); + } + if (count16 > 100000) count16 = -1; + if (count8 > 100000) count8 = -1; + + // Use shared_ptr count as primary, store raw count for logging + g_scanResults[g_scanCount++] = {off, count16}; + } + } + __except (EXCEPTION_EXECUTE_HANDLER) { + LogWrite("WARNING: Exception during Level offset scan"); + } +} + +// Extended scan: log with both element size interpretations +static void ScanLevelOffsetsExtended(void *serverLevel) +{ + auto base = reinterpret_cast(serverLevel); + + __try { + for (int off = 56; off < 320; off += 8) { + auto first = *reinterpret_cast(base + off); + auto last = *reinterpret_cast(base + off + 8); + auto end = *reinterpret_cast(base + off + 16); + + if (!first && !last && !end) continue; + + auto ok = [](uintptr_t p) -> bool { + return p == 0 || (p > 0x10000 && p < 0x7FFFFFFFFFFF); + }; + if (!ok(first) || !ok(last) || !ok(end)) continue; + if (first > last || last > end) continue; + + auto diff = last - first; + LogWrite(" +%d: diff=%llu /16=%llu /8=%llu /24=%llu", + off, (unsigned long long)diff, + diff / 16, diff / 8, diff / 24); + } + } + __except (EXCEPTION_EXECUTE_HANDLER) { + LogWrite("WARNING: Exception during extended scan"); + } +} + +static void ScanLevelOffsets(void *serverLevel) +{ + if (g_scanDone) return; + g_scanDone = true; + + ScanLevelOffsetsInner(serverLevel); + + LogWrite("Extended vector scan (diff in bytes, divided by element sizes):"); + ScanLevelOffsetsExtended(serverLevel); + + LogWrite("Level offset scan (%d candidates):", g_scanCount); + for (int i = 0; i < g_scanCount; i++) { + LogWrite(" +%d: %d elements", g_scanResults[i].offset, g_scanResults[i].count); + } + + // Auto-detect layout: find the first vector with a plausible entity count + // (10-5000 elements). This is the `entities` vector. + for (int i = 0; i < g_scanCount; i++) { + if (g_scanResults[i].count >= 10 && g_scanResults[i].count <= 5000) { + LevelOff::Detect(g_scanResults[i].offset); + break; + } + } + + if (LevelOff::detected) { + auto has = [](int off) { + for (int i = 0; i < g_scanCount; i++) + if (g_scanResults[i].offset == off) return true; + return false; + }; + LogWrite("Offset validation: entities=%s entitiesToRemove=%s tileEntityList=%s players=%s globalEntities=%s", + has(LevelOff::entities) ? "OK" : "MISS", + has(LevelOff::entitiesToRemove) ? "OK" : "MISS", + has(LevelOff::tileEntityList) ? "OK" : "MISS", + has(LevelOff::players) ? "OK" : "MISS", + has(LevelOff::globalEntities) ? "OK" : "MISS"); + } +} + +void CollectLevelMetrics(void *serverLevel, int index, LevelMetrics &out) +{ + if (!serverLevel) return; + + // Run offset scan once for logging/validation + if (!g_scanDone) { + ScanLevelOffsets(serverLevel); + } + + if (!LevelOff::detected) return; + + auto base = reinterpret_cast(serverLevel); + + __try { + out.entityCount = ReadVectorSize(base + LevelOff::entities, 16); + } + __except (EXCEPTION_EXECUTE_HANDLER) { out.entityCount = -1; } + + __try { + out.globalEntityCount = ReadVectorSize(base + LevelOff::globalEntities, 16); + } + __except (EXCEPTION_EXECUTE_HANDLER) { out.globalEntityCount = -1; } + + __try { + out.playerCount = ReadVectorSize(base + LevelOff::players, 16); + } + __except (EXCEPTION_EXECUTE_HANDLER) { out.playerCount = -1; } + + __try { + out.tileEntityCount = ReadVectorSize(base + LevelOff::tileEntityList, 16); + } + __except (EXCEPTION_EXECUTE_HANDLER) { out.tileEntityCount = -1; } + + __try { + out.entitiesToRemove = ReadVectorSize(base + LevelOff::entitiesToRemove, 16); + } + __except (EXCEPTION_EXECUTE_HANDLER) { out.entitiesToRemove = -1; } + + // Loaded chunk count via ServerLevel::getChunkMap() (Debug builds only). + // In Release builds, getChunkMap() is inlined and can't be resolved from PDB. + // Chunk count will show 0 on Release until we add PDB type info queries. + if (g_symbols.ServerLevel_getChunkMap) { + __try { + using GetChunkMapFn = void *(__fastcall *)(void *); + auto fn = reinterpret_cast(g_symbols.ServerLevel_getChunkMap); + auto chunkMap = reinterpret_cast(fn(serverLevel)); + if (chunkMap) { + static int chunkCountOffset = -1; + if (chunkCountOffset < 0) { + for (int off = 24; off < 120; off += 8) { + auto val = *reinterpret_cast(chunkMap + off); + if (val > 0 && val < 10000) { + chunkCountOffset = off; + LogWrite("PlayerChunkMap chunk count at +%d = %zu", off, val); + break; + } + } + if (chunkCountOffset < 0) chunkCountOffset = 0; + } + if (chunkCountOffset > 0) { + auto count = *reinterpret_cast(chunkMap + chunkCountOffset); + if (count < 10000) { + out.loadedChunks = (int)count; + } + } + } + } + __except (EXCEPTION_EXECUTE_HANDLER) { + out.loadedChunks = -1; + } + } +} + +// ----------------------------------------------------------------------- +// Memory metrics +// ----------------------------------------------------------------------- + +void CollectMemoryMetrics(double &usedMb, double &totalMb) +{ + PROCESS_MEMORY_COUNTERS pmc; + if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc))) { + usedMb = pmc.WorkingSetSize / (1024.0 * 1024.0); + } + + MEMORYSTATUSEX memInfo; + memInfo.dwLength = sizeof(memInfo); + if (GlobalMemoryStatusEx(&memInfo)) { + totalMb = memInfo.ullTotalPhys / (1024.0 * 1024.0); + } +} + +// ----------------------------------------------------------------------- +// Snapshot queuing +// ----------------------------------------------------------------------- + +void PushTickSnapshot(TickSnapshot &&snap) +{ + std::lock_guard lock(g_tickMtx); + if (g_tickQueue.size() >= MAX_TICK_QUEUE) { + g_tickQueue.pop_front(); + } + g_tickQueue.push_back(std::move(snap)); +} + +void PushAutosaveSnapshot(AutosaveSnapshot &&snap) +{ + std::lock_guard lock(g_autoMtx); + if (g_autoQueue.size() >= MAX_AUTO_QUEUE) { + g_autoQueue.pop_front(); + } + g_autoQueue.push_back(std::move(snap)); +} + +std::vector DrainTickSnapshots() +{ + std::lock_guard lock(g_tickMtx); + std::vector out(g_tickQueue.begin(), g_tickQueue.end()); + g_tickQueue.clear(); + return out; +} + +std::vector DrainAutosaveSnapshots() +{ + std::lock_guard lock(g_autoMtx); + std::vector out(g_autoQueue.begin(), g_autoQueue.end()); + g_autoQueue.clear(); + return out; +} diff --git a/tools/performance-monitor/dll/src/perf_monitor.h b/tools/performance-monitor/dll/src/perf_monitor.h new file mode 100644 index 00000000..8a43cfae --- /dev/null +++ b/tools/performance-monitor/dll/src/perf_monitor.h @@ -0,0 +1,143 @@ +#pragma once +// +// perf_monitor.h -- shared types and declarations for the injected +// performance-monitoring DLL. +// +// The DLL is injected into Minecraft.Server.exe at runtime. It uses +// DbgHelp to resolve private symbols from the PDB, installs a +// trampoline on MinecraftServer::tick(), and collects per-phase timing +// from inside the server process. Metrics are served as length-prefixed +// JSON over a lightweight TCP socket. +// + +#include +#include +#include +#include +#include +#include + +#include + +// ----------------------------------------------------------------------- +// Snapshot data -- produced on the game thread, consumed by TCP thread +// ----------------------------------------------------------------------- + +struct LevelMetrics { + int dimension; // 0 = Overworld, -1 = Nether, 1 = End + int64_t levelTickUs; // Level::tick() (weather) + int64_t entityTickUs; // Level::tickEntities() + bool entityTickSkipped; + int64_t trackerTickUs; // EntityTracker::tick() + int entityCount; + int globalEntityCount; + int playerCount; + int loadedChunks; + int entitiesToRemove; + int tileEntityCount; +}; + +struct TickSnapshot { + int tick; + int64_t timestampMs; + int64_t totalUs; + + int64_t poolResetUs; + std::vector levels; + int64_t playersTickUs; + int64_t connectionTickUs; + int64_t consoleTickUs; + + int totalPlayers; + double memoryUsedMb; + double memoryTotalMb; + bool isAutosaving; + bool isPaused; +}; + +struct AutosaveSnapshot { + int tick; + int64_t timestampMs; + int64_t totalUs; + int64_t playersUs; + int64_t levelsUs; + int64_t rulesUs; + int64_t flushUs; +}; + +// ----------------------------------------------------------------------- +// Global state +// ----------------------------------------------------------------------- + +// Set to true once hooks are installed; the tick wrapper checks this. +extern std::atomic g_hooksInstalled; + +// Set to true when at least one TCP client is connected. When false the +// tick wrapper still runs (it must -- it IS the tick) but skips all +// timing and metric collection. +extern std::atomic g_clientConnected; + +// ----------------------------------------------------------------------- +// symbols.cpp -- PDB symbol resolution +// ----------------------------------------------------------------------- + +struct ResolvedSymbols { + uintptr_t MinecraftServer_tick; // private void tick() + uintptr_t MinecraftServer_getInstance; // static MinecraftServer* getInstance() + uintptr_t ServerLevel_getChunkMap; // public PlayerChunkMap* getChunkMap() +}; + +bool ResolveSymbols(HANDLE hProcess, uintptr_t moduleBase, ResolvedSymbols &out); + +// Global resolved symbols (set during init, read from hooks/metrics) +extern ResolvedSymbols g_symbols; + +// ----------------------------------------------------------------------- +// hooks.cpp -- trampoline installation +// ----------------------------------------------------------------------- + +bool InstallTickHook(uintptr_t tickAddr); +void RemoveTickHook(); + +// ----------------------------------------------------------------------- +// metrics.cpp -- timing and data collection +// ----------------------------------------------------------------------- + +void MetricsInit(); +void MetricsShutdown(); + +// Called from inside the hooked tick to collect stats. +// The hook itself is in hooks.cpp; these helpers read server internals. +void CollectLevelMetrics(void *serverLevel, int index, LevelMetrics &out); +void CollectMemoryMetrics(double &usedMb, double &totalMb); + +// Push a completed snapshot to the outbound queue (thread-safe). +void PushTickSnapshot(TickSnapshot &&snap); +void PushAutosaveSnapshot(AutosaveSnapshot &&snap); + +// Pop all pending snapshots (called by TCP thread). +std::vector DrainTickSnapshots(); +std::vector DrainAutosaveSnapshots(); + +// ----------------------------------------------------------------------- +// tcp_server.cpp -- lightweight JSON-over-TCP server +// ----------------------------------------------------------------------- + +bool TcpServerStart(int port); +void TcpServerStop(); + +// ----------------------------------------------------------------------- +// json_writer.cpp -- serialization +// ----------------------------------------------------------------------- + +std::string SerializeTick(const TickSnapshot &s); +std::string SerializeAutosave(const AutosaveSnapshot &s); +std::string SerializeHelloAck(int levelCount); + +// ----------------------------------------------------------------------- +// Logging (writes to perf-monitor.log next to the DLL) +// ----------------------------------------------------------------------- + +void LogInit(HMODULE hModule); +void LogWrite(const char *fmt, ...); +void LogShutdown(); diff --git a/tools/performance-monitor/dll/src/symbols.cpp b/tools/performance-monitor/dll/src/symbols.cpp new file mode 100644 index 00000000..5a14f655 --- /dev/null +++ b/tools/performance-monitor/dll/src/symbols.cpp @@ -0,0 +1,120 @@ +// +// symbols.cpp -- Resolve private C++ symbols from the PDB using DbgHelp. +// +// Since we are injected into the process, we can use SymFromName() to +// look up mangled symbol names. The PDB must be next to the .exe or +// in the _NT_SYMBOL_PATH. +// + +#include "perf_monitor.h" + +#pragma warning(push) +#pragma warning(disable : 4091) // 'typedef ': ignored on left of '' when no variable is declared +#include +#pragma warning(pop) + +#pragma comment(lib, "dbghelp.lib") + +// ----------------------------------------------------------------------- +// Decorated (mangled) names for the symbols we need. +// +// These were determined from the PDB. If the server is rebuilt with +// different compiler settings the manglings may change, so we also +// fall back to undecorated name search. +// ----------------------------------------------------------------------- + +// MinecraftServer::tick() -- private void __cdecl MinecraftServer::tick(void) +// MSVC x64 mangling: ?tick@MinecraftServer@@AEAAXXZ +static const char *TICK_DECORATED = "?tick@MinecraftServer@@AEAAXXZ"; +static const char *TICK_UNDECORATED = "MinecraftServer::tick"; + +// MinecraftServer::getInstance() -- public static MinecraftServer* __cdecl MinecraftServer::getInstance(void) +// This is inlined in the header so may not exist as a symbol. +// Instead we resolve the static member MinecraftServer::server directly. +// ?server@MinecraftServer@@0PEAV1@EA +static const char *SERVER_PTR_DECORATED = "?server@MinecraftServer@@0PEAV1@EA"; +static const char *SERVER_PTR_UNDECORATED = "MinecraftServer::server"; + +// ----------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------- + +static bool TryResolve(HANDLE hProcess, const char *name, uintptr_t &outAddr) +{ + // SYMBOL_INFO needs extra space for the name + char buf[sizeof(SYMBOL_INFO) + MAX_SYM_NAME]; + SYMBOL_INFO *sym = reinterpret_cast(buf); + memset(buf, 0, sizeof(buf)); + sym->SizeOfStruct = sizeof(SYMBOL_INFO); + sym->MaxNameLen = MAX_SYM_NAME; + + if (SymFromName(hProcess, name, sym)) { + outAddr = static_cast(sym->Address); + return true; + } + return false; +} + +// ----------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------- + +bool ResolveSymbols(HANDLE hProcess, uintptr_t moduleBase, ResolvedSymbols &out) +{ + memset(&out, 0, sizeof(out)); + + // Initialize DbgHelp for this process + SymSetOptions(SYMOPT_UNDNAME | SYMOPT_DEFERRED_LOADS | SYMOPT_LOAD_LINES | SYMOPT_DEBUG); + + if (!SymInitialize(hProcess, nullptr, FALSE)) { + LogWrite("SymInitialize failed: 0x%08x", GetLastError()); + return false; + } + + // Load symbols for the main module + char exePath[MAX_PATH]; + GetModuleFileNameA(nullptr, exePath, MAX_PATH); + + DWORD64 base = SymLoadModuleEx(hProcess, nullptr, exePath, nullptr, + moduleBase, 0, nullptr, 0); + if (!base) { + DWORD err = GetLastError(); + if (err != ERROR_SUCCESS) { + LogWrite("SymLoadModuleEx failed: 0x%08x", err); + SymCleanup(hProcess); + return false; + } + } + LogWrite("Symbols loaded for %s", exePath); + + // Resolve MinecraftServer::tick() + if (!TryResolve(hProcess, TICK_DECORATED, out.MinecraftServer_tick)) { + LogWrite("Decorated tick symbol not found, trying undecorated..."); + if (!TryResolve(hProcess, TICK_UNDECORATED, out.MinecraftServer_tick)) { + LogWrite("ERROR: Could not resolve MinecraftServer::tick()"); + SymCleanup(hProcess); + return false; + } + } + + // Resolve MinecraftServer::server (static pointer) + // getInstance() is likely inlined, so we grab the static member address + if (!TryResolve(hProcess, SERVER_PTR_DECORATED, out.MinecraftServer_getInstance)) { + LogWrite("Decorated server ptr not found, trying undecorated..."); + if (!TryResolve(hProcess, SERVER_PTR_UNDECORATED, out.MinecraftServer_getInstance)) { + LogWrite("WARNING: Could not resolve MinecraftServer::server -- metrics will be limited"); + // Not fatal -- we can still time the tick, just can't read members + } + } + + // Resolve ServerLevel::getChunkMap() + // ?getChunkMap@ServerLevel@@QEAAPEAVPlayerChunkMap@@XZ + if (!TryResolve(hProcess, "?getChunkMap@ServerLevel@@QEAAPEAVPlayerChunkMap@@XZ", + out.ServerLevel_getChunkMap)) { + if (!TryResolve(hProcess, "ServerLevel::getChunkMap", out.ServerLevel_getChunkMap)) { + LogWrite("WARNING: Could not resolve ServerLevel::getChunkMap()"); + } + } + + return true; +} diff --git a/tools/performance-monitor/dll/src/tcp_server.cpp b/tools/performance-monitor/dll/src/tcp_server.cpp new file mode 100644 index 00000000..7e72832e --- /dev/null +++ b/tools/performance-monitor/dll/src/tcp_server.cpp @@ -0,0 +1,297 @@ +// +// tcp_server.cpp -- Lightweight TCP server for streaming JSON metrics. +// +// Protocol: +// - Length-prefixed frames: [4 bytes big-endian uint32 length][UTF-8 JSON] +// - Client sends a "hello" message after connecting +// - Server responds with "hello_ack" then streams tick snapshots +// +// Threading: +// - Listener thread: accepts connections, reads client messages +// - Game thread (via hooks.cpp): pushes snapshots to the queue +// - Broadcast thread: drains queue, serializes, sends to all clients +// + +#include "perf_monitor.h" + +#include +#include + +#pragma comment(lib, "ws2_32.lib") + +// ----------------------------------------------------------------------- +// State +// ----------------------------------------------------------------------- + +static SOCKET g_listenSock = INVALID_SOCKET; +static HANDLE g_listenerThread = nullptr; +static HANDLE g_broadcastThread = nullptr; +static std::atomic g_running{false}; + +static std::mutex g_clientsMtx; +static std::vector g_clients; +static constexpr int MAX_CLIENTS = 4; + +static constexpr int SEND_BUF_LIMIT = 64 * 1024; // 64 KB per client + +// ----------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------- + +static void SendFrame(SOCKET sock, const std::string &json) +{ + uint32_t len = (uint32_t)json.size(); + // Big-endian length prefix + uint8_t header[4]; + header[0] = (len >> 24) & 0xFF; + header[1] = (len >> 16) & 0xFF; + header[2] = (len >> 8) & 0xFF; + header[3] = (len ) & 0xFF; + + // Send header + payload (non-blocking, best effort) + send(sock, reinterpret_cast(header), 4, 0); + send(sock, json.c_str(), (int)json.size(), 0); +} + +static void RemoveClient(SOCKET sock) +{ + closesocket(sock); + std::lock_guard lock(g_clientsMtx); + g_clients.erase( + std::remove(g_clients.begin(), g_clients.end(), sock), + g_clients.end()); + + if (g_clients.empty()) { + g_clientConnected.store(false, std::memory_order_release); + LogWrite("Last client disconnected, metrics collection paused"); + } +} + +// ----------------------------------------------------------------------- +// Listener thread -- accepts new connections +// ----------------------------------------------------------------------- + +static DWORD WINAPI ListenerThread(LPVOID) +{ + LogWrite("Listener thread started"); + + while (g_running.load()) { + fd_set readSet; + FD_ZERO(&readSet); + FD_SET(g_listenSock, &readSet); + + timeval timeout; + timeout.tv_sec = 1; + timeout.tv_usec = 0; + + int result = select(0, &readSet, nullptr, nullptr, &timeout); + if (result <= 0) continue; + + if (FD_ISSET(g_listenSock, &readSet)) { + sockaddr_in clientAddr; + int addrLen = sizeof(clientAddr); + SOCKET clientSock = accept(g_listenSock, + reinterpret_cast(&clientAddr), + &addrLen); + if (clientSock == INVALID_SOCKET) continue; + + // Check client limit + { + std::lock_guard lock(g_clientsMtx); + if ((int)g_clients.size() >= MAX_CLIENTS) { + LogWrite("Client rejected: max connections reached"); + closesocket(clientSock); + continue; + } + } + + // Set non-blocking + u_long nonBlock = 1; + ioctlsocket(clientSock, FIONBIO, &nonBlock); + + // Set TCP_NODELAY for low latency + int nodelay = 1; + setsockopt(clientSock, IPPROTO_TCP, TCP_NODELAY, + reinterpret_cast(&nodelay), sizeof(nodelay)); + + char addrStr[64]; + inet_ntop(AF_INET, &clientAddr.sin_addr, addrStr, sizeof(addrStr)); + LogWrite("Client connected from %s:%d", addrStr, ntohs(clientAddr.sin_port)); + + // Send hello_ack + std::string ack = SerializeHelloAck(3); // 3 dimensions + SendFrame(clientSock, ack); + + // Add to client list + { + std::lock_guard lock(g_clientsMtx); + g_clients.push_back(clientSock); + g_clientConnected.store(true, std::memory_order_release); + } + + LogWrite("Client added, metrics collection active"); + } + } + + LogWrite("Listener thread exiting"); + return 0; +} + +// ----------------------------------------------------------------------- +// Broadcast thread -- drains snapshot queue and sends to all clients +// ----------------------------------------------------------------------- + +static DWORD WINAPI BroadcastThread(LPVOID) +{ + LogWrite("Broadcast thread started"); + + while (g_running.load()) { + // Sleep briefly to batch snapshots (50ms = 1 tick at 20 TPS) + Sleep(50); + + if (!g_clientConnected.load(std::memory_order_relaxed)) continue; + + // Drain tick snapshots + auto ticks = DrainTickSnapshots(); + auto autosaves = DrainAutosaveSnapshots(); + + if (ticks.empty() && autosaves.empty()) continue; + + // Serialize all snapshots + std::vector frames; + frames.reserve(ticks.size() + autosaves.size()); + + for (auto &snap : ticks) { + frames.push_back(SerializeTick(snap)); + } + for (auto &snap : autosaves) { + frames.push_back(SerializeAutosave(snap)); + } + + // Send to all clients + std::vector deadClients; + + { + std::lock_guard lock(g_clientsMtx); + for (SOCKET sock : g_clients) { + bool failed = false; + for (auto &json : frames) { + uint32_t len = (uint32_t)json.size(); + uint8_t header[4]; + header[0] = (len >> 24) & 0xFF; + header[1] = (len >> 16) & 0xFF; + header[2] = (len >> 8) & 0xFF; + header[3] = (len ) & 0xFF; + + int r1 = send(sock, reinterpret_cast(header), 4, 0); + int r2 = send(sock, json.c_str(), (int)json.size(), 0); + + if (r1 == SOCKET_ERROR || r2 == SOCKET_ERROR) { + int err = WSAGetLastError(); + if (err != WSAEWOULDBLOCK) { + failed = true; + break; + } + // WOULDBLOCK: client can't keep up, skip this frame + break; + } + } + if (failed) { + deadClients.push_back(sock); + } + } + } + + // Clean up dead clients + for (SOCKET sock : deadClients) { + LogWrite("Client disconnected (send error)"); + RemoveClient(sock); + } + } + + LogWrite("Broadcast thread exiting"); + return 0; +} + +// ----------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------- + +bool TcpServerStart(int port) +{ + WSADATA wsaData; + if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { + // WSA may already be initialized by the server -- that's fine + } + + g_listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (g_listenSock == INVALID_SOCKET) { + LogWrite("socket() failed: %d", WSAGetLastError()); + return false; + } + + // Allow address reuse + int reuse = 1; + setsockopt(g_listenSock, SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&reuse), sizeof(reuse)); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = INADDR_ANY; // Listen on all interfaces + addr.sin_port = htons(static_cast(port)); + + if (bind(g_listenSock, reinterpret_cast(&addr), sizeof(addr)) == SOCKET_ERROR) { + LogWrite("bind() failed on port %d: %d", port, WSAGetLastError()); + closesocket(g_listenSock); + g_listenSock = INVALID_SOCKET; + return false; + } + + if (listen(g_listenSock, 4) == SOCKET_ERROR) { + LogWrite("listen() failed: %d", WSAGetLastError()); + closesocket(g_listenSock); + g_listenSock = INVALID_SOCKET; + return false; + } + + g_running.store(true); + + g_listenerThread = CreateThread(nullptr, 0, ListenerThread, nullptr, 0, nullptr); + g_broadcastThread = CreateThread(nullptr, 0, BroadcastThread, nullptr, 0, nullptr); + + return true; +} + +void TcpServerStop() +{ + g_running.store(false); + + if (g_listenSock != INVALID_SOCKET) { + closesocket(g_listenSock); + g_listenSock = INVALID_SOCKET; + } + + // Close all client sockets + { + std::lock_guard lock(g_clientsMtx); + for (SOCKET s : g_clients) { + closesocket(s); + } + g_clients.clear(); + } + g_clientConnected.store(false); + + // Wait for threads + if (g_listenerThread) { + WaitForSingleObject(g_listenerThread, 3000); + CloseHandle(g_listenerThread); + g_listenerThread = nullptr; + } + if (g_broadcastThread) { + WaitForSingleObject(g_broadcastThread, 3000); + CloseHandle(g_broadcastThread); + g_broadcastThread = nullptr; + } + + LogWrite("TCP server stopped"); +} diff --git a/tools/performance-monitor/gui.py b/tools/performance-monitor/gui.py new file mode 100644 index 00000000..e53668a8 --- /dev/null +++ b/tools/performance-monitor/gui.py @@ -0,0 +1,557 @@ +""" +PySide6 GUI for the server-side performance monitor. + +Connects to the injected DLL's TCP server and visualizes tick-level +performance data: phase breakdown, TPS graph, entity/chunk counts, +memory usage, and lag spike root causes. +""" + +import logging +import time +from collections import deque + +from PySide6.QtCore import Qt, QTimer, Slot +from PySide6.QtGui import QFont +from PySide6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QLineEdit, QPushButton, QGroupBox, + QSpinBox, QSplitter, QTextEdit, QScrollArea, +) + +from connection import PerfConnection +from bots import BotManager +from models import TickSnapshot, AutosaveSnapshot +from widgets import ( + TickBreakdownBar, TPSGraph, StatCard, + EntityChunkPanel, MemoryBar, LagSpikePanel, +) + + +DIMENSION_NAMES = {0: "Overworld", -1: "Nether", 1: "End"} + + +def compute_deltas(prev: TickSnapshot | None, curr: TickSnapshot) -> list[str]: + """Compare two consecutive snapshots and describe what changed.""" + if prev is None: + return [] + + events: list[str] = [] + + prev_levels = {lv.dimension: lv for lv in prev.levels} + curr_levels = {lv.dimension: lv for lv in curr.levels} + + for dim in (0, -1, 1): + p = prev_levels.get(dim) + c = curr_levels.get(dim) + if not p or not c: + continue + + name = DIMENSION_NAMES.get(dim, f"Dim{dim}") + + # Player join/leave + pdiff = c.player_count - p.player_count + if pdiff > 0: + events.append(f"+{pdiff} player{'s' if pdiff > 1 else ''} joined ({name})") + elif pdiff < 0: + events.append(f"{pdiff} player{'s' if pdiff < -1 else ''} left ({name})") + + # Entity spawn/despawn (threshold to avoid noise from normal fluctuation) + ediff = c.entity_count - p.entity_count + if ediff >= 10: + events.append(f"+{ediff} entities spawned ({name}, now {c.entity_count})") + elif ediff <= -10: + events.append(f"{ediff} entities despawned ({name}, now {c.entity_count})") + + # Tile entity changes (redstone, hoppers, etc.) + tdiff = c.tile_entity_count - p.tile_entity_count + if tdiff >= 5: + events.append(f"+{tdiff} tile entities ({name})") + elif tdiff <= -5: + events.append(f"{tdiff} tile entities ({name})") + + # Entities pending removal spike + if c.entities_to_remove > 10 and c.entities_to_remove > p.entities_to_remove + 5: + events.append(f"{c.entities_to_remove} entities queued for removal ({name})") + + # Chunk load/unload + if c.loaded_chunks > 0 and p.loaded_chunks > 0: + cdiff = c.loaded_chunks - p.loaded_chunks + if cdiff >= 5: + events.append(f"+{cdiff} chunks loaded ({name}, now {c.loaded_chunks})") + elif cdiff <= -5: + events.append(f"{cdiff} chunks unloaded ({name}, now {c.loaded_chunks})") + + return events + + +def classify_spike(interval_ms: float, work_ms: float, + snap: TickSnapshot, prev: TickSnapshot | None, + prev_timestamps: list[float]) -> str: + """Classify a lag spike by its most likely cause, with context from deltas.""" + + # Compute what changed since last tick + deltas = compute_deltas(prev, snap) + delta_str = "; ".join(deltas) if deltas else "" + + # Check for player join/leave (takes priority over autosave classification) + player_joined = any("joined" in d for d in deltas) + player_left = any("left" in d for d in deltas) + chunk_change = any("chunk" in d for d in deltas) + + # --- Player join stall --- + # When a player joins, the server loads their chunks, sends world data, + # and initializes the player entity. This blocks the run loop. + if player_joined and interval_ms > 200: + reason = f"Player join (server stalled {interval_ms:.0f}ms loading player data)" + if delta_str: + reason += f" | {delta_str}" + return reason + + # --- Player leave --- + if player_left and interval_ms > 200: + reason = f"Player leave (cleanup stalled {interval_ms:.0f}ms)" + if delta_str: + reason += f" | {delta_str}" + return reason + + # --- Autosave detection --- + # Large stall (>500ms) with low tick work and NO player join/leave. + # Autosave's synchronous Flush() compresses + writes the save file, + # blocking the entire run() loop. Typically recurs at a regular interval. + if interval_ms > 500 and work_ms < 50: + pattern = "" + if len(prev_timestamps) >= 1: + gap = time.time() - prev_timestamps[-1] + pattern = f", ~{gap:.0f}s since last spike" + reason = f"Autosave (sync flush for {interval_ms:.0f}ms{pattern})" + if delta_str: + reason += f" | {delta_str}" + return reason + + # --- Heavy tick (entity/redstone overload) --- + if work_ms > 50: + reason = f"Heavy tick ({work_ms:.1f}ms work, {snap.total_entity_count} entities)" + if delta_str: + reason += f" | {delta_str}" + return reason + + # --- Moderate tick slowdown --- + if work_ms > 25: + reason = f"Slow tick ({work_ms:.1f}ms work, {snap.total_entity_count} entities)" + if delta_str: + reason += f" | {delta_str}" + return reason + + # --- Short stall with context --- + if delta_str: + return f"Stall ({interval_ms:.0f}ms gap, tick {work_ms:.1f}ms) | {delta_str}" + + # --- Minor external stall (no obvious context) --- + if interval_ms < 200: + return f"Minor stall ({interval_ms:.0f}ms gap, tick only {work_ms:.1f}ms)" + + # --- Medium external stall --- + return f"External stall ({interval_ms:.0f}ms gap, tick only {work_ms:.1f}ms)" + + +class PerfMonitorWindow(QMainWindow): + + def __init__(self): + super().__init__() + self.setWindowTitle("LCE Server Performance Monitor") + self.setMinimumSize(1000, 700) + + self._connection = PerfConnection(self) + self._bot_manager = BotManager(self) + self._flog = logging.getLogger("perfmon") + + # History for timeline + self._history: deque[TickSnapshot] = deque(maxlen=6000) # 5 min at 20 TPS + + # Wire signals + self._connection.connected.connect(self._on_connected) + self._connection.disconnected.connect(self._on_disconnected) + self._connection.tick_received.connect(self._on_tick) + self._connection.autosave_received.connect(self._on_autosave) + self._connection.error.connect(self._on_error) + self._connection.log.connect(self._append_log) + + self._bot_manager.log.connect(self._append_log) + self._bot_manager.bot_added.connect(self._on_bot_added) + self._bot_manager.bot_removed.connect(self._on_bot_removed) + + self._build_ui() + + # Graph refresh timer + self._refresh = QTimer(self) + self._refresh.timeout.connect(self._refresh_graph) + self._refresh.start(500) + + # Periodic TPS log (every 5 seconds) + self._tps_log_timer = QTimer(self) + self._tps_log_timer.timeout.connect(self._log_tps) + self._tps_log_timer.start(5000) + self._last_logged_tps = 0.0 + + def _build_ui(self): + central = QWidget() + self.setCentralWidget(central) + root = QVBoxLayout(central) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(8) + + # --- Connection bar --- + conn_box = QGroupBox("Connection") + conn_box.setStyleSheet( + "QGroupBox { color: #aaaacc; border: 1px solid #333348; " + "border-radius: 6px; margin-top: 8px; padding-top: 14px; }" + "QGroupBox::title { subcontrol-position: top left; padding: 2px 8px; }" + ) + conn_layout = QHBoxLayout(conn_box) + + conn_layout.addWidget(QLabel("Host:")) + self._host_input = QLineEdit("127.0.0.1") + self._host_input.setFixedWidth(140) + conn_layout.addWidget(self._host_input) + + conn_layout.addWidget(QLabel("Port:")) + self._port_input = QSpinBox() + self._port_input.setRange(1, 65535) + self._port_input.setValue(19800) + self._port_input.setFixedWidth(80) + conn_layout.addWidget(self._port_input) + + self._connect_btn = QPushButton("Connect") + self._connect_btn.setFixedWidth(100) + self._connect_btn.clicked.connect(self._toggle_connection) + conn_layout.addWidget(self._connect_btn) + + self._status_label = QLabel("Disconnected") + self._status_label.setStyleSheet("color: #ff5555; font-weight: bold;") + conn_layout.addWidget(self._status_label) + conn_layout.addStretch() + root.addWidget(conn_box) + + # --- Bot controls --- + bot_box = QGroupBox("Load Testing") + bot_box.setStyleSheet( + "QGroupBox { color: #aaaacc; border: 1px solid #333348; " + "border-radius: 6px; margin-top: 8px; padding-top: 14px; }" + "QGroupBox::title { subcontrol-position: top left; padding: 2px 8px; }" + ) + bot_layout = QHBoxLayout(bot_box) + + bot_layout.addWidget(QLabel("Game Port:")) + self._game_port_input = QSpinBox() + self._game_port_input.setRange(1, 65535) + self._game_port_input.setValue(25565) + self._game_port_input.setFixedWidth(80) + bot_layout.addWidget(self._game_port_input) + + self._add_bot_btn = QPushButton("Add Bot") + self._add_bot_btn.setFixedWidth(80) + self._add_bot_btn.clicked.connect(self._add_bot) + bot_layout.addWidget(self._add_bot_btn) + + self._remove_bot_btn = QPushButton("Remove Bot") + self._remove_bot_btn.setFixedWidth(100) + self._remove_bot_btn.clicked.connect(self._remove_bot) + bot_layout.addWidget(self._remove_bot_btn) + + self._remove_all_bots_btn = QPushButton("Remove All") + self._remove_all_bots_btn.setFixedWidth(90) + self._remove_all_bots_btn.clicked.connect(self._remove_all_bots) + bot_layout.addWidget(self._remove_all_bots_btn) + + self._bot_count_label = QLabel("Bots: 0") + self._bot_count_label.setStyleSheet("color: #aaaacc; font-weight: bold;") + bot_layout.addWidget(self._bot_count_label) + + bot_layout.addStretch() + root.addWidget(bot_box) + + # --- Stat cards --- + stats_layout = QHBoxLayout() + self._tps_card = StatCard("TPS (est.)") + self._mspt_card = StatCard("Tick Time") + self._entities_card = StatCard("Entities") + self._players_card = StatCard("Players") + self._memory_card = StatCard("Memory") + self._spikes_card = StatCard("Lag Spikes") + + for card in [ + self._tps_card, self._mspt_card, self._entities_card, + self._players_card, self._memory_card, self._spikes_card, + ]: + stats_layout.addWidget(card) + root.addLayout(stats_layout) + + # --- Tick breakdown bar --- + self._breakdown = TickBreakdownBar() + root.addWidget(self._breakdown) + + # --- Main content splitter --- + hsplit = QSplitter(Qt.Horizontal) + + # Left: graph + memory + left = QWidget() + left_layout = QVBoxLayout(left) + left_layout.setContentsMargins(0, 0, 0, 0) + + self._graph = TPSGraph() + left_layout.addWidget(self._graph) + + self._memory_bar = MemoryBar() + left_layout.addWidget(self._memory_bar) + + hsplit.addWidget(left) + + # Right: entity panel + spike panel + right = QWidget() + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(0, 0, 0, 0) + + self._entity_panel = EntityChunkPanel() + right_layout.addWidget(self._entity_panel) + + self._spike_panel = LagSpikePanel() + spike_scroll = QScrollArea() + spike_scroll.setWidget(self._spike_panel) + spike_scroll.setWidgetResizable(True) + spike_scroll.setStyleSheet("QScrollArea { border: none; background: transparent; }") + right_layout.addWidget(spike_scroll) + + hsplit.addWidget(right) + hsplit.setStretchFactor(0, 3) + hsplit.setStretchFactor(1, 2) + root.addWidget(hsplit) + + # --- Log pane --- + log_box = QGroupBox("Log") + log_box.setStyleSheet( + "QGroupBox { color: #aaaacc; border: 1px solid #333348; " + "border-radius: 6px; margin-top: 8px; padding-top: 14px; }" + "QGroupBox::title { subcontrol-position: top left; padding: 2px 8px; }" + ) + log_layout = QVBoxLayout(log_box) + self._log = QTextEdit() + self._log.setReadOnly(True) + self._log.setFont(QFont("Consolas", 9)) + self._log.setStyleSheet("background: #12121a; color: #ccccdd; border: none;") + self._log.setMaximumHeight(150) + log_layout.addWidget(self._log) + root.addWidget(log_box) + + self._apply_theme() + + def _apply_theme(self): + self.setStyleSheet(""" + QMainWindow { background: #0e0e16; } + QWidget { color: #ccccdd; font-family: 'Segoe UI', sans-serif; } + QLabel { color: #aaaacc; } + QLineEdit, QSpinBox { + background: #1a1a24; color: #ffffff; border: 1px solid #333348; + border-radius: 4px; padding: 4px 8px; + } + QPushButton { + background: #2a2a3a; color: #ffffff; border: 1px solid #444466; + border-radius: 4px; padding: 6px 16px; + } + QPushButton:hover { background: #3a3a4a; } + QPushButton:pressed { background: #1a1a2a; } + QGroupBox { background: #14141e; } + """) + + # --- Spike counter --- + _spike_count = 0 + _spike_timestamps: list[float] = [] + _prev_snap: TickSnapshot | None = None + + # TPS tracking + _tps_samples: list[float] = [] + + # --- Slots --- + + def _toggle_connection(self): + if self._connection.is_connected: + self._connection.disconnect() + else: + host = self._host_input.text().strip() + port = self._port_input.value() + self._history.clear() + self._spike_count = 0 + self._spike_timestamps = [] + self._prev_snap = None + self._tps_samples = [] + self._connection.connect_to(host, port) + + @Slot(dict) + def _on_connected(self, hello: dict): + self._status_label.setText("Connected") + self._status_label.setStyleSheet("color: #50c878; font-weight: bold;") + self._connect_btn.setText("Disconnect") + + @Slot(str) + def _on_disconnected(self, reason: str): + self._status_label.setText(f"Disconnected: {reason}") + self._status_label.setStyleSheet("color: #ff5555; font-weight: bold;") + self._connect_btn.setText("Connect") + + @Slot(object) + def _on_tick(self, snap: TickSnapshot): + self._history.append(snap) + now = time.time() + + # TPS from tick-to-tick interval (total_us). + # This measures the real time between consecutive tick() calls, + # including any stalls from autosave or other work in run(). + interval_us = snap.total_us + if interval_us > 0: + instant_tps = min(1_000_000.0 / interval_us, 20.0) + else: + instant_tps = 20.0 + + self._tps_samples.append(instant_tps) + if len(self._tps_samples) > 20: + self._tps_samples = self._tps_samples[-20:] + tps = sum(self._tps_samples) / len(self._tps_samples) + + if tps >= 19.0: + tps_color = "#50c878" + elif tps >= 15.0: + tps_color = "#ffc83d" + else: + tps_color = "#ff5050" + self._tps_card.set_value(f"{tps:.1f}", tps_color) + + # MSPT = actual tick() work time (carried in pool_reset_us) + mspt = snap.pool_reset_us / 1000.0 + if mspt <= 50: + mspt_color = "#50c878" + elif mspt <= 65: + mspt_color = "#ffc83d" + else: + mspt_color = "#ff5050" + self._mspt_card.set_value(f"{mspt:.1f}ms", mspt_color) + + total_ent = snap.total_entity_count + self._entities_card.set_value(str(total_ent)) + total_players = sum(lv.player_count for lv in snap.levels) + self._players_card.set_value(str(total_players)) + + self._memory_card.set_value(f"{snap.memory_used_mb:.0f}MB") + + # Update breakdown bar + self._breakdown.update_from_snapshot(snap) + + # Update entity/chunk panel + self._entity_panel.update_from_snapshot(snap) + + # Update memory bar + self._memory_bar.update_memory(snap.memory_used_mb, snap.memory_total_mb) + + # TPS graph data point + self._graph.add_point(now, tps) + + # Check for lag spikes: tick interval exceeded 150ms (3x the 50ms budget). + # This filters out steady-state slowness (e.g. 15 TPS = 66ms intervals) + # and only reports true spikes -- autosave stalls, player joins, etc. + if snap.total_us > 150000: + self._spike_count += 1 + interval_ms = snap.total_us / 1000.0 + work_ms = snap.pool_reset_us / 1000.0 + + reason = classify_spike( + interval_ms, work_ms, snap, self._prev_snap, + self._spike_timestamps) + self._spike_timestamps.append(now) + + self._spike_panel.add_spike_with_reason(snap, reason) + self._append_log( + f"LAG SPIKE: {interval_ms:.0f}ms interval, " + f"tick work={work_ms:.1f}ms, " + f"entities={snap.total_entity_count}, " + f"TPS={tps:.1f}, " + f"cause={reason}" + ) + + self._spikes_card.set_value( + str(self._spike_count), + "#ff5050" if self._spike_count > 0 else "#50c878", + ) + + # Log notable events even without a spike + if self._prev_snap is not None and snap.total_us <= 75000: + deltas = compute_deltas(self._prev_snap, snap) + for d in deltas: + if "joined" in d or "left" in d: + self._append_log(f"EVENT: {d}") + + self._prev_snap = snap + + @Slot(object) + def _on_autosave(self, snap: AutosaveSnapshot): + self._spike_panel.add_autosave(snap) + self._append_log( + f"AUTOSAVE: {snap.total_ms:.0f}ms total " + f"(players={snap.players_us / 1000:.0f}ms, " + f"levels={snap.levels_us / 1000:.0f}ms, " + f"rules={snap.rules_us / 1000:.0f}ms, " + f"flush={snap.flush_us / 1000:.0f}ms)" + ) + + @Slot(str) + def _on_error(self, msg: str): + self._append_log(f"ERROR: {msg}") + + # --- Bot controls --- + + def _add_bot(self): + host = self._host_input.text().strip() + port = self._game_port_input.value() + self._bot_manager.add_bot(host, port) + + def _remove_bot(self): + self._bot_manager.remove_bot() + + def _remove_all_bots(self): + self._bot_manager.remove_all() + + @Slot(str) + def _on_bot_added(self, name: str): + self._bot_count_label.setText(f"Bots: {self._bot_manager.bot_count}") + + @Slot(str) + def _on_bot_removed(self, name: str): + self._bot_count_label.setText(f"Bots: {self._bot_manager.bot_count}") + + def _append_log(self, msg: str): + self._flog.info(msg) + ts = time.strftime("%H:%M:%S") + self._log.append(f"[{ts}] {msg}") + + def _refresh_graph(self): + self._graph.update() + + def _log_tps(self): + if not self._connection.is_connected or not self._tps_samples: + return + tps = sum(self._tps_samples) / len(self._tps_samples) + snap = self._prev_snap + if snap: + work_ms = snap.pool_reset_us / 1000.0 + ent = snap.total_entity_count + players = sum(lv.player_count for lv in snap.levels) + chunks = sum(lv.loaded_chunks for lv in snap.levels if lv.loaded_chunks > 0) + self._append_log( + f"TPS: {tps:.1f} | MSPT: {work_ms:.1f}ms | " + f"Players: {players} | Entities: {ent} | Chunks: {chunks} | " + f"Memory: {snap.memory_used_mb:.0f}MB" + ) + else: + self._append_log(f"TPS: {tps:.1f}") + + def closeEvent(self, event): + self._bot_manager.remove_all() + self._connection.disconnect() + super().closeEvent(event) diff --git a/tools/performance-monitor/inject.bat b/tools/performance-monitor/inject.bat new file mode 100644 index 00000000..d742a1a4 --- /dev/null +++ b/tools/performance-monitor/inject.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0" +python inject.py %* +pause diff --git a/tools/performance-monitor/inject.py b/tools/performance-monitor/inject.py new file mode 100644 index 00000000..53139437 --- /dev/null +++ b/tools/performance-monitor/inject.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +DLL Injector for perf-monitor.dll. + +Finds a running Minecraft.Server.exe process and injects the performance +monitoring DLL using the standard CreateRemoteThread + LoadLibraryA technique. + +Usage: + python inject.py # Auto-find server process + python inject.py --pid 12345 # Inject into specific PID + python inject.py --dll path/to.dll # Use a specific DLL path + python inject.py --eject # Unload the DLL from the server +""" + +import argparse +import ctypes +import ctypes.wintypes as wt +import os +import sys + +# Win32 constants +PROCESS_ALL_ACCESS = 0x1F0FFF +MEM_COMMIT = 0x1000 +MEM_RESERVE = 0x2000 +MEM_RELEASE = 0x8000 +PAGE_READWRITE = 0x04 +INFINITE = 0xFFFFFFFF + +# Win32 API +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) +psapi = ctypes.WinDLL("psapi", use_last_error=True) + +# Type aliases +DWORD = wt.DWORD +HANDLE = wt.HANDLE +LPVOID = wt.LPVOID +LPCSTR = wt.LPCSTR +BOOL = wt.BOOL +SIZE_T = ctypes.c_size_t + +# Function prototypes +kernel32.OpenProcess.restype = HANDLE +kernel32.OpenProcess.argtypes = [DWORD, BOOL, DWORD] + +kernel32.VirtualAllocEx.restype = LPVOID +kernel32.VirtualAllocEx.argtypes = [HANDLE, LPVOID, SIZE_T, DWORD, DWORD] + +kernel32.VirtualFreeEx.restype = BOOL +kernel32.VirtualFreeEx.argtypes = [HANDLE, LPVOID, SIZE_T, DWORD] + +kernel32.WriteProcessMemory.restype = BOOL +kernel32.WriteProcessMemory.argtypes = [HANDLE, LPVOID, ctypes.c_void_p, SIZE_T, ctypes.POINTER(SIZE_T)] + +kernel32.CreateRemoteThread.restype = HANDLE +kernel32.CreateRemoteThread.argtypes = [HANDLE, ctypes.c_void_p, SIZE_T, LPVOID, LPVOID, DWORD, ctypes.POINTER(DWORD)] + +kernel32.WaitForSingleObject.restype = DWORD +kernel32.WaitForSingleObject.argtypes = [HANDLE, DWORD] + +kernel32.GetExitCodeThread.restype = BOOL +kernel32.GetExitCodeThread.argtypes = [HANDLE, ctypes.POINTER(DWORD)] + +kernel32.CloseHandle.restype = BOOL +kernel32.CloseHandle.argtypes = [HANDLE] + +kernel32.GetModuleHandleA.restype = HANDLE +kernel32.GetModuleHandleA.argtypes = [LPCSTR] + +kernel32.GetProcAddress.restype = ctypes.c_void_p +kernel32.GetProcAddress.argtypes = [HANDLE, LPCSTR] + + +def find_server_pid() -> int | None: + """Find the PID of a running Minecraft.Server.exe.""" + # Use EnumProcesses to list all PIDs + arr = (DWORD * 4096)() + cb_needed = DWORD() + if not psapi.EnumProcesses(ctypes.byref(arr), ctypes.sizeof(arr), ctypes.byref(cb_needed)): + return None + + num_procs = cb_needed.value // ctypes.sizeof(DWORD) + + for i in range(num_procs): + pid = arr[i] + if pid == 0: + continue + + h = kernel32.OpenProcess(0x0410, False, pid) # PROCESS_QUERY_INFORMATION | PROCESS_VM_READ + if not h: + continue + + try: + name_buf = (ctypes.c_char * 260)() + if psapi.GetModuleBaseNameA(h, None, name_buf, 260): + name = name_buf.value.decode("utf-8", errors="ignore") + if name.lower() == "minecraft.server.exe": + return pid + finally: + kernel32.CloseHandle(h) + + return None + + +def inject_dll(pid: int, dll_path: str) -> bool: + """Inject a DLL into the target process.""" + dll_path = os.path.abspath(dll_path) + if not os.path.isfile(dll_path): + print(f"ERROR: DLL not found: {dll_path}") + return False + + dll_bytes = dll_path.encode("utf-8") + b"\x00" + print(f"Injecting {dll_path} into PID {pid}...") + + # Open the target process + h_process = kernel32.OpenProcess(PROCESS_ALL_ACCESS, False, pid) + if not h_process: + print(f"ERROR: OpenProcess failed (error {ctypes.get_last_error()})") + print(" Are you running as Administrator?") + return False + + try: + # Allocate memory in the target for the DLL path string + remote_mem = kernel32.VirtualAllocEx( + h_process, None, len(dll_bytes), + MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE + ) + if not remote_mem: + print(f"ERROR: VirtualAllocEx failed (error {ctypes.get_last_error()})") + return False + + # Write the DLL path into the allocated memory + written = SIZE_T(0) + if not kernel32.WriteProcessMemory( + h_process, remote_mem, dll_bytes, len(dll_bytes), ctypes.byref(written) + ): + print(f"ERROR: WriteProcessMemory failed (error {ctypes.get_last_error()})") + kernel32.VirtualFreeEx(h_process, remote_mem, 0, MEM_RELEASE) + return False + + # Get the address of LoadLibraryA in kernel32 + h_kernel32 = kernel32.GetModuleHandleA(b"kernel32.dll") + load_library_addr = kernel32.GetProcAddress(h_kernel32, b"LoadLibraryA") + if not load_library_addr: + print("ERROR: Could not find LoadLibraryA") + kernel32.VirtualFreeEx(h_process, remote_mem, 0, MEM_RELEASE) + return False + + # Create a remote thread that calls LoadLibraryA(dll_path) + thread_id = DWORD(0) + h_thread = kernel32.CreateRemoteThread( + h_process, None, 0, + ctypes.cast(load_library_addr, LPVOID), + remote_mem, 0, ctypes.byref(thread_id) + ) + if not h_thread: + print(f"ERROR: CreateRemoteThread failed (error {ctypes.get_last_error()})") + kernel32.VirtualFreeEx(h_process, remote_mem, 0, MEM_RELEASE) + return False + + print(f"Remote thread created (TID {thread_id.value}), waiting...") + + # Wait for LoadLibrary to complete + kernel32.WaitForSingleObject(h_thread, 10000) # 10 second timeout + + # Check the exit code (HMODULE of the loaded DLL, or 0 on failure) + exit_code = DWORD(0) + kernel32.GetExitCodeThread(h_thread, ctypes.byref(exit_code)) + kernel32.CloseHandle(h_thread) + + # Free the remote string memory + kernel32.VirtualFreeEx(h_process, remote_mem, 0, MEM_RELEASE) + + if exit_code.value == 0: + print("ERROR: LoadLibraryA returned NULL in the target process.") + print(" Check that the DLL and its dependencies (dbghelp.dll) are accessible.") + print(" Check perf-monitor.log next to the DLL for details.") + return False + + print(f"DLL loaded at 0x{exit_code.value:X}") + print("Injection successful! The performance monitor is now active.") + print("Connect the GUI to localhost:19800 to view metrics.") + return True + + finally: + kernel32.CloseHandle(h_process) + + +def eject_dll(pid: int, dll_name: str = "perf-monitor.dll") -> bool: + """Unload the DLL from the target process.""" + print(f"Ejecting {dll_name} from PID {pid}...") + + h_process = kernel32.OpenProcess(PROCESS_ALL_ACCESS, False, pid) + if not h_process: + print(f"ERROR: OpenProcess failed (error {ctypes.get_last_error()})") + return False + + try: + # Find the DLL's HMODULE in the target process + h_modules = (HANDLE * 1024)() + cb_needed = DWORD() + if not psapi.EnumProcessModules(h_process, ctypes.byref(h_modules), + ctypes.sizeof(h_modules), ctypes.byref(cb_needed)): + print("ERROR: EnumProcessModules failed") + return False + + num_modules = cb_needed.value // ctypes.sizeof(HANDLE) + target_module = None + + for i in range(num_modules): + name_buf = (ctypes.c_char * 260)() + if psapi.GetModuleBaseNameA(h_process, h_modules[i], name_buf, 260): + name = name_buf.value.decode("utf-8", errors="ignore") + if name.lower() == dll_name.lower(): + target_module = h_modules[i] + break + + if not target_module: + print(f"ERROR: {dll_name} not found in process") + return False + + # Get FreeLibrary address + h_kernel32 = kernel32.GetModuleHandleA(b"kernel32.dll") + free_library_addr = kernel32.GetProcAddress(h_kernel32, b"FreeLibrary") + + # Call FreeLibrary(hModule) in the target + thread_id = DWORD(0) + h_thread = kernel32.CreateRemoteThread( + h_process, None, 0, + ctypes.cast(free_library_addr, LPVOID), + target_module, 0, ctypes.byref(thread_id) + ) + if not h_thread: + print(f"ERROR: CreateRemoteThread failed (error {ctypes.get_last_error()})") + return False + + kernel32.WaitForSingleObject(h_thread, 10000) + kernel32.CloseHandle(h_thread) + + print("DLL ejected successfully.") + return True + + finally: + kernel32.CloseHandle(h_process) + + +def main(): + parser = argparse.ArgumentParser(description="Inject perf-monitor.dll into Minecraft.Server.exe") + parser.add_argument("--pid", type=int, default=None, help="Target process ID (auto-detect if omitted)") + parser.add_argument("--dll", default=None, help="Path to perf-monitor.dll") + parser.add_argument("--eject", action="store_true", help="Eject (unload) the DLL instead of injecting") + args = parser.parse_args() + + # Find or validate the PID + pid = args.pid + if pid is None: + pid = find_server_pid() + if pid is None: + print("ERROR: No running Minecraft.Server.exe found.") + print(" Start the server first, or specify --pid manually.") + sys.exit(1) + print(f"Found Minecraft.Server.exe (PID {pid})") + + if args.eject: + success = eject_dll(pid) + sys.exit(0 if success else 1) + + # Find the DLL + dll_path = args.dll + if dll_path is None: + # Look for it relative to this script + script_dir = os.path.dirname(os.path.abspath(__file__)) + candidates = [ + os.path.join(script_dir, "dll", "build", "Release", "perf-monitor.dll"), + os.path.join(script_dir, "dll", "build", "Debug", "perf-monitor.dll"), + os.path.join(script_dir, "dll", "build", "perf-monitor.dll"), + os.path.join(script_dir, "perf-monitor.dll"), + ] + for candidate in candidates: + if os.path.isfile(candidate): + dll_path = candidate + break + + if dll_path is None: + print("ERROR: Could not find perf-monitor.dll") + print(" Build it first or specify --dll path") + print(" Searched:", "\n ".join(candidates)) + sys.exit(1) + + success = inject_dll(pid, dll_path) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/tools/performance-monitor/main.py b/tools/performance-monitor/main.py new file mode 100644 index 00000000..6cdb4e37 --- /dev/null +++ b/tools/performance-monitor/main.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +LCE Server Performance Monitor -- server-side instrumentation GUI. + +Connects to a performance monitoring DLL injected into the running +Minecraft.Server.exe process. Displays real-time tick phase breakdowns, +entity/chunk counts, memory usage, and lag spike root causes. + +Requires the DLL to be injected first: + python inject.py + +Usage: + python main.py + python main.py --host 127.0.0.1 --port 19800 +""" + +import sys +import os +import logging +import argparse + +from PySide6.QtWidgets import QApplication +from gui import PerfMonitorWindow + + +def setup_logging(): + """Configure file logger next to the executable/script.""" + if getattr(sys, "frozen", False): + base = os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.abspath(__file__)) + + log_path = os.path.join(base, "perfmon.log") + + logger = logging.getLogger("perfmon") + logger.setLevel(logging.DEBUG) + + fh = logging.FileHandler(log_path, mode="w", encoding="utf-8") + fh.setLevel(logging.DEBUG) + fh.setFormatter(logging.Formatter("%(asctime)s %(message)s", datefmt="%H:%M:%S")) + + logger.addHandler(fh) + logger.info(f"Log file: {log_path}") + return logger + + +def main(): + parser = argparse.ArgumentParser(description="LCE Server Performance Monitor") + parser.add_argument("--host", default=None, help="Monitor host address") + parser.add_argument("--port", type=int, default=None, help="Monitor port") + args = parser.parse_args() + + setup_logging() + + app = QApplication(sys.argv) + app.setApplicationName("LCE Server Performance Monitor") + + window = PerfMonitorWindow() + + if args.host: + window._host_input.setText(args.host) + if args.port: + window._port_input.setValue(args.port) + + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/tools/performance-monitor/models.py b/tools/performance-monitor/models.py new file mode 100644 index 00000000..077d2d53 --- /dev/null +++ b/tools/performance-monitor/models.py @@ -0,0 +1,122 @@ +""" +Data models for performance monitor snapshots. + +All dataclasses are frozen (immutable) per project coding style. +Parsing functions validate at the boundary. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LevelSnapshot: + dimension: int + level_tick_us: int + entity_tick_us: int + entity_tick_skipped: bool + tracker_tick_us: int + entity_count: int + global_entity_count: int + player_count: int + loaded_chunks: int + entities_to_remove: int + tile_entity_count: int + + +@dataclass(frozen=True) +class TickSnapshot: + tick: int + timestamp_ms: int + total_us: int + + pool_reset_us: int + levels: tuple[LevelSnapshot, ...] + players_tick_us: int + connection_tick_us: int + console_tick_us: int + + total_players: int + memory_used_mb: float + memory_total_mb: float + is_autosaving: bool + is_paused: bool + + @property + def total_ms(self) -> float: + return self.total_us / 1000.0 + + @property + def tps(self) -> float: + """Estimated TPS: 20 if tick fits in budget, lower if it overruns.""" + if self.total_us <= 50000: + return 20.0 + # Tick took longer than 50ms -- server can't keep up + return min(1_000_000 / self.total_us, 20.0) + + @property + def total_entity_count(self) -> int: + return sum(lv.entity_count for lv in self.levels) + + +@dataclass(frozen=True) +class AutosaveSnapshot: + tick: int + timestamp_ms: int + total_us: int + players_us: int + levels_us: int + rules_us: int + flush_us: int + + @property + def total_ms(self) -> float: + return self.total_us / 1000.0 + + +def parse_level(data: dict) -> LevelSnapshot: + return LevelSnapshot( + dimension=data.get("dimension", 0), + level_tick_us=data.get("level_tick_us", 0), + entity_tick_us=data.get("entity_tick_us", 0), + entity_tick_skipped=data.get("entity_tick_skipped", False), + tracker_tick_us=data.get("tracker_tick_us", 0), + entity_count=data.get("entity_count", 0), + global_entity_count=data.get("global_entity_count", 0), + player_count=data.get("player_count", 0), + loaded_chunks=data.get("loaded_chunks", 0), + entities_to_remove=data.get("entities_to_remove", 0), + tile_entity_count=data.get("tile_entity_count", 0), + ) + + +def parse_tick(data: dict) -> TickSnapshot: + phases = data.get("phases", {}) + levels_raw = phases.get("levels", []) + return TickSnapshot( + tick=data.get("tick", 0), + timestamp_ms=data.get("timestamp_ms", 0), + total_us=data.get("total_us", 0), + pool_reset_us=phases.get("pool_reset_us", 0), + levels=tuple(parse_level(lv) for lv in levels_raw), + players_tick_us=phases.get("players_tick_us", 0), + connection_tick_us=phases.get("connection_tick_us", 0), + console_tick_us=phases.get("console_tick_us", 0), + total_players=data.get("server", {}).get("total_players", 0), + memory_used_mb=data.get("server", {}).get("memory_used_mb", 0.0), + memory_total_mb=data.get("server", {}).get("memory_total_mb", 0.0), + is_autosaving=data.get("server", {}).get("is_autosaving", False), + is_paused=data.get("server", {}).get("is_paused", False), + ) + + +def parse_autosave(data: dict) -> AutosaveSnapshot: + breakdown = data.get("breakdown", {}) + return AutosaveSnapshot( + tick=data.get("tick", 0), + timestamp_ms=data.get("timestamp_ms", 0), + total_us=data.get("total_us", 0), + players_us=breakdown.get("players_us", 0), + levels_us=breakdown.get("levels_us", 0), + rules_us=breakdown.get("rules_us", 0), + flush_us=breakdown.get("flush_us", 0), + ) diff --git a/tools/performance-monitor/requirements.txt b/tools/performance-monitor/requirements.txt new file mode 100644 index 00000000..1966c69e --- /dev/null +++ b/tools/performance-monitor/requirements.txt @@ -0,0 +1 @@ +PySide6>=6.6 diff --git a/tools/performance-monitor/start.bat b/tools/performance-monitor/start.bat new file mode 100644 index 00000000..796679cc --- /dev/null +++ b/tools/performance-monitor/start.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0" +python main.py %* diff --git a/tools/performance-monitor/widgets.py b/tools/performance-monitor/widgets.py new file mode 100644 index 00000000..49efcd2b --- /dev/null +++ b/tools/performance-monitor/widgets.py @@ -0,0 +1,494 @@ +""" +Custom widgets for the performance monitor GUI. + + - TickBreakdownBar: stacked horizontal bar showing per-phase timing + - TPSGraph: real-time TPS line chart (adapted from server-monitor) + - EntityChunkPanel: per-dimension entity/chunk counts + - MemoryBar: process memory usage bar + - LagSpikePanel: recent lag spikes with root cause +""" + +import time +from collections import deque + +from PySide6.QtCore import Qt +from PySide6.QtGui import QPainter, QColor, QPen, QFont, QBrush, QLinearGradient +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QGroupBox, QFrame, QScrollArea, QSizePolicy, +) + +from models import TickSnapshot, AutosaveSnapshot + +# ----------------------------------------------------------------------- +# Color palette for tick phases +# ----------------------------------------------------------------------- + +PHASE_COLORS = { + "pool_reset": QColor(100, 100, 120), + "level_tick_0": QColor(70, 130, 220), # Overworld level tick + "entity_tick_0": QColor(230, 120, 50), # Overworld entity tick + "tracker_tick_0": QColor(200, 200, 70), # Overworld tracker + "level_tick_1": QColor(150, 60, 180), # Nether level tick + "entity_tick_1": QColor(220, 60, 80), # Nether entity tick + "tracker_tick_1": QColor(180, 180, 50), # Nether tracker + "level_tick_2": QColor(50, 180, 170), # End level tick + "entity_tick_2": QColor(220, 100, 140), # End entity tick + "tracker_tick_2": QColor(160, 160, 50), # End tracker + "players_tick": QColor(80, 200, 120), + "connection_tick": QColor(80, 180, 220), + "console_tick": QColor(120, 120, 140), + "other": QColor(60, 60, 80), +} + +DIMENSION_NAMES = {0: "Overworld", -1: "Nether", 1: "End"} + + +# ----------------------------------------------------------------------- +# Tick Breakdown Bar +# ----------------------------------------------------------------------- + +class TickBreakdownBar(QWidget): + """Horizontal stacked bar showing which phase consumed time.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumHeight(40) + self.setMaximumHeight(50) + self._segments: list[tuple[str, int, QColor]] = [] # (label, us, color) + self._total_us: int = 0 + self._bg = QColor(24, 24, 32) + + def update_from_snapshot(self, snap: TickSnapshot): + segments = [] + + if snap.pool_reset_us > 0: + segments.append(("Pool Reset", snap.pool_reset_us, PHASE_COLORS["pool_reset"])) + + for i, lv in enumerate(snap.levels): + suffix = str(i) + dim_name = DIMENSION_NAMES.get(lv.dimension, f"Dim{lv.dimension}") + + if lv.level_tick_us > 0: + segments.append( + (f"{dim_name} Weather", lv.level_tick_us, + PHASE_COLORS.get(f"level_tick_{suffix}", PHASE_COLORS["other"]))) + + if not lv.entity_tick_skipped and lv.entity_tick_us > 0: + segments.append( + (f"{dim_name} Entities", lv.entity_tick_us, + PHASE_COLORS.get(f"entity_tick_{suffix}", PHASE_COLORS["other"]))) + + if lv.tracker_tick_us > 0: + segments.append( + (f"{dim_name} Tracker", lv.tracker_tick_us, + PHASE_COLORS.get(f"tracker_tick_{suffix}", PHASE_COLORS["other"]))) + + if snap.players_tick_us > 0: + segments.append(("Players", snap.players_tick_us, PHASE_COLORS["players_tick"])) + + if snap.connection_tick_us > 0: + segments.append(("Network", snap.connection_tick_us, PHASE_COLORS["connection_tick"])) + + if snap.console_tick_us > 0: + segments.append(("Console", snap.console_tick_us, PHASE_COLORS["console_tick"])) + + # "Other" time = total - sum of known phases + accounted = sum(s[1] for s in segments) + other = snap.total_us - accounted + if other > 0: + segments.append(("Other", other, PHASE_COLORS["other"])) + + self._segments = segments + self._total_us = snap.total_us + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + w, h = self.width(), self.height() + painter.fillRect(0, 0, w, h, self._bg) + + if self._total_us <= 0 or not self._segments: + painter.setPen(QPen(QColor(140, 140, 160), 1)) + painter.setFont(QFont("Consolas", 10)) + painter.drawText(0, 0, w, h, Qt.AlignCenter, "No data") + painter.end() + return + + # Draw the 50ms budget line + budget_x = min(w * (50000 / max(self._total_us, 50000)), w) + + # Draw segments + x = 0.0 + painter.setFont(QFont("Consolas", 8)) + for label, us, color in self._segments: + seg_w = (us / self._total_us) * w + if seg_w < 1: + continue + + painter.fillRect(int(x), 2, int(seg_w), h - 4, color) + + # Label if wide enough + if seg_w > 60: + painter.setPen(QPen(QColor(255, 255, 255), 1)) + text = f"{label} {us / 1000:.1f}ms" + painter.drawText(int(x) + 4, 2, int(seg_w) - 8, h - 4, + Qt.AlignVCenter | Qt.AlignLeft, text) + + x += seg_w + + # Budget line (50ms = 1 tick at 20 TPS) + if self._total_us > 50000: + bx = int(w * 50000 / self._total_us) + painter.setPen(QPen(QColor(255, 80, 80, 180), 2, Qt.DashLine)) + painter.drawLine(bx, 0, bx, h) + + # Total label on the right + painter.setPen(QPen(QColor(200, 200, 220), 1)) + painter.setFont(QFont("Consolas", 9)) + total_text = f"{self._total_us / 1000:.1f}ms" + painter.drawText(w - 80, 0, 75, h, Qt.AlignVCenter | Qt.AlignRight, total_text) + + painter.end() + + +# ----------------------------------------------------------------------- +# TPS Graph +# ----------------------------------------------------------------------- + +class TPSGraph(QWidget): + """Real-time TPS line chart.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumHeight(200) + self.setMinimumWidth(400) + self._data: deque[tuple[float, float]] = deque(maxlen=6000) # 5 min at 20 TPS + self._bg = QColor(24, 24, 32) + self._grid = QColor(48, 48, 64) + self._line_good = QColor(80, 200, 120) + self._line_warn = QColor(255, 200, 60) + self._line_bad = QColor(255, 80, 80) + + def add_point(self, timestamp: float, tps: float): + self._data.append((timestamp, tps)) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + w, h = self.width(), self.height() + ml, mb, mt, mr = 50, 30, 20, 20 + + painter.fillRect(0, 0, w, h, self._bg) + + gw = w - ml - mr + gh = h - mt - mb + if gw < 10 or gh < 10: + painter.end() + return + + # Grid + painter.setPen(QPen(self._grid, 1)) + painter.setFont(QFont("Consolas", 8)) + for tps_val in [0, 5, 10, 15, 20]: + y = mt + gh - (tps_val / 20.0) * gh + painter.drawLine(ml, int(y), w - mr, int(y)) + painter.setPen(QPen(QColor(140, 140, 160), 1)) + painter.drawText(5, int(y) + 4, f"{tps_val}") + painter.setPen(QPen(self._grid, 1)) + + # 15 TPS warning line + painter.setPen(QPen(QColor(255, 200, 60, 80), 1, Qt.DashLine)) + y15 = mt + gh - (15.0 / 20.0) * gh + painter.drawLine(ml, int(y15), w - mr, int(y15)) + + if len(self._data) < 2: + painter.setPen(QPen(QColor(140, 140, 160), 1)) + painter.setFont(QFont("Consolas", 12)) + painter.drawText(ml, mt, gw, gh, Qt.AlignCenter, "Waiting for data...") + painter.end() + return + + data_list = list(self._data) + now = data_list[-1][0] + time_window = 300.0 + + points = [] + for t, tps in data_list: + age = now - t + if age > time_window: + continue + x = ml + (1.0 - age / time_window) * gw + y = mt + gh - (min(tps, 20.0) / 20.0) * gh + points.append((x, y, tps)) + + for i in range(1, len(points)): + x1, y1, t1 = points[i - 1] + x2, y2, t2 = points[i] + avg = (t1 + t2) / 2 + + if avg >= 18: + color = self._line_good + elif avg >= 15: + color = self._line_warn + else: + color = self._line_bad + + painter.setPen(QPen(color, 2)) + painter.drawLine(int(x1), int(y1), int(x2), int(y2)) + + # Time axis + painter.setPen(QPen(QColor(140, 140, 160), 1)) + painter.setFont(QFont("Consolas", 8)) + for sec in [0, 60, 120, 180, 240, 300]: + x = ml + (1.0 - sec / time_window) * gw + if x >= ml: + label = "now" if sec == 0 else f"-{sec}s" + painter.drawText(int(x) - 15, h - 8, label) + + painter.end() + + +# ----------------------------------------------------------------------- +# Stat Card (reused from server-monitor) +# ----------------------------------------------------------------------- + +class StatCard(QFrame): + """Compact stat display card.""" + + def __init__(self, title: str, parent=None): + super().__init__(parent) + self.setFrameStyle(QFrame.Box | QFrame.Raised) + self.setStyleSheet( + "StatCard { background: #1a1a24; border: 1px solid #333348; border-radius: 6px; }" + ) + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 8, 12, 8) + + self._title = QLabel(title) + self._title.setStyleSheet("color: #8888aa; font-size: 11px;") + self._title.setAlignment(Qt.AlignCenter) + layout.addWidget(self._title) + + self._value = QLabel("--") + self._value.setStyleSheet("color: #ffffff; font-size: 24px; font-weight: bold;") + self._value.setAlignment(Qt.AlignCenter) + layout.addWidget(self._value) + + def set_value(self, text: str, color: str = "#ffffff"): + self._value.setText(text) + self._value.setStyleSheet( + f"color: {color}; font-size: 24px; font-weight: bold;" + ) + + +# ----------------------------------------------------------------------- +# Entity / Chunk Panel +# ----------------------------------------------------------------------- + +class EntityChunkPanel(QGroupBox): + """Per-dimension entity and chunk counts.""" + + def __init__(self, parent=None): + super().__init__("World Stats", parent) + self.setStyleSheet( + "QGroupBox { color: #aaaacc; border: 1px solid #333348; " + "border-radius: 6px; margin-top: 8px; padding-top: 14px; }" + "QGroupBox::title { subcontrol-position: top left; padding: 2px 8px; }" + ) + + layout = QGridLayout(self) + layout.setSpacing(4) + + # Headers + headers = ["", "Overworld", "Nether", "End"] + for col, h in enumerate(headers): + lbl = QLabel(h) + lbl.setStyleSheet("color: #8888aa; font-size: 10px; font-weight: bold;") + lbl.setAlignment(Qt.AlignCenter) + layout.addWidget(lbl, 0, col) + + # Rows + row_labels = ["Entities", "Global", "Players", "Chunks", "Tile Ent.", "To Remove"] + self._cells: dict[tuple[str, int], QLabel] = {} + + for row, label in enumerate(row_labels, start=1): + lbl = QLabel(label) + lbl.setStyleSheet("color: #8888aa; font-size: 10px;") + layout.addWidget(lbl, row, 0) + + for col in range(3): + cell = QLabel("--") + cell.setStyleSheet("color: #ccccdd; font-size: 11px; font-family: Consolas;") + cell.setAlignment(Qt.AlignCenter) + layout.addWidget(cell, row, col + 1) + self._cells[(label, col)] = cell + + def update_from_snapshot(self, snap: TickSnapshot): + dim_order = [0, -1, 1] # Overworld, Nether, End + dim_to_col = {0: 0, -1: 1, 1: 2} + + level_map = {lv.dimension: lv for lv in snap.levels} + + for dim in dim_order: + col = dim_to_col[dim] + lv = level_map.get(dim) + + if lv: + self._set_cell("Entities", col, str(lv.entity_count)) + self._set_cell("Global", col, str(lv.global_entity_count)) + self._set_cell("Players", col, str(lv.player_count)) + self._set_cell("Chunks", col, str(lv.loaded_chunks)) + self._set_cell("Tile Ent.", col, str(lv.tile_entity_count)) + self._set_cell("To Remove", col, str(lv.entities_to_remove)) + else: + for label in ["Entities", "Global", "Players", "Chunks", "Tile Ent.", "To Remove"]: + self._set_cell(label, col, "--") + + def _set_cell(self, label: str, col: int, text: str): + cell = self._cells.get((label, col)) + if cell: + cell.setText(text) + + +# ----------------------------------------------------------------------- +# Memory Bar +# ----------------------------------------------------------------------- + +class MemoryBar(QWidget): + """Simple horizontal bar showing memory usage.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumHeight(24) + self.setMaximumHeight(30) + self._used_mb = 0.0 + self._total_mb = 1.0 + self._bg = QColor(24, 24, 32) + + def update_memory(self, used_mb: float, total_mb: float): + self._used_mb = used_mb + self._total_mb = max(total_mb, 1.0) + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + w, h = self.width(), self.height() + + painter.fillRect(0, 0, w, h, self._bg) + + ratio = min(self._used_mb / self._total_mb, 1.0) + bar_w = int(w * ratio) + + if ratio < 0.6: + color = QColor(80, 200, 120) + elif ratio < 0.8: + color = QColor(255, 200, 60) + else: + color = QColor(255, 80, 80) + + painter.fillRect(0, 2, bar_w, h - 4, color) + + painter.setPen(QPen(QColor(220, 220, 240), 1)) + painter.setFont(QFont("Consolas", 9)) + text = f"Memory: {self._used_mb:.0f} / {self._total_mb:.0f} MB ({ratio * 100:.0f}%)" + painter.drawText(8, 0, w - 16, h, Qt.AlignVCenter, text) + + painter.end() + + +# ----------------------------------------------------------------------- +# Lag Spike Panel +# ----------------------------------------------------------------------- + +class LagSpikePanel(QGroupBox): + """Shows recent lag spikes with the exact causal phase.""" + + def __init__(self, parent=None): + super().__init__("Lag Spikes", parent) + self.setStyleSheet( + "QGroupBox { color: #aaaacc; border: 1px solid #333348; " + "border-radius: 6px; margin-top: 8px; padding-top: 14px; }" + "QGroupBox::title { subcontrol-position: top left; padding: 2px 8px; }" + ) + + self._layout = QVBoxLayout(self) + self._layout.setSpacing(2) + + self._entries: deque[QLabel] = deque(maxlen=50) + self._placeholder = QLabel("No lag spikes detected") + self._placeholder.setStyleSheet("color: #50c878; font-size: 11px;") + self._placeholder.setAlignment(Qt.AlignCenter) + self._layout.addWidget(self._placeholder) + self._layout.addStretch() + + def add_spike(self, snap: TickSnapshot): + """Legacy - called without reason.""" + self.add_spike_with_reason(snap, "Unknown") + + def add_spike_with_reason(self, snap: TickSnapshot, reason: str): + """Add a spike entry with a classified reason.""" + if self._placeholder.isVisible(): + self._placeholder.setVisible(False) + + interval_ms = snap.total_us / 1000.0 + if interval_ms >= 1000: + dur_str = f"{interval_ms / 1000:.1f}s" + else: + dur_str = f"{interval_ms:.0f}ms" + + ts = time.strftime("%H:%M:%S") + text = f"[{ts}] {dur_str} - {reason}" + + # Color: red for major (>500ms), orange for moderate + if interval_ms >= 500: + color = "#ff5050" + else: + color = "#ffc83d" + + lbl = QLabel(text) + lbl.setStyleSheet(f"color: {color}; font-size: 10px; font-family: Consolas;") + lbl.setWordWrap(True) + + # Insert at top + self._layout.insertWidget(0, lbl) + self._entries.appendleft(lbl) + + # Remove old entries if over limit + while len(self._entries) > 50: + old = self._entries.pop() + self._layout.removeWidget(old) + old.deleteLater() + + def add_autosave(self, snap: AutosaveSnapshot): + """Add an autosave event.""" + if self._placeholder.isVisible(): + self._placeholder.setVisible(False) + + # Find dominant sub-phase + parts = [ + ("Players", snap.players_us), + ("Levels", snap.levels_us), + ("Rules", snap.rules_us), + ("Flush", snap.flush_us), + ] + cause, cause_us = max(parts, key=lambda x: x[1]) + + ts = time.strftime("%H:%M:%S") + text = (f"[{ts}] AUTOSAVE {snap.total_ms:.0f}ms " + f"- {cause} ({cause_us / 1000:.1f}ms)") + + lbl = QLabel(text) + lbl.setStyleSheet("color: #ffc83d; font-size: 10px; font-family: Consolas;") + + self._layout.insertWidget(0, lbl) + self._entries.appendleft(lbl) + + while len(self._entries) > 50: + old = self._entries.pop() + self._layout.removeWidget(old) + old.deleteLater()