diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..c3154574 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,17 @@ +name: Build APK +on: workflow_dispatch +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - run: chmod +x gradlew + - run: ./gradlew assembleRelease + - uses: actions/upload-artifact@v4 + with: + name: apk + path: app/build/outputs/apk/**/*.apk diff --git a/PLACEMENT_FIX_NOTES.md b/PLACEMENT_FIX_NOTES.md new file mode 100644 index 00000000..ef99ca1a --- /dev/null +++ b/PLACEMENT_FIX_NOTES.md @@ -0,0 +1,12 @@ +# Placement fix + +This revision aligns PistonCrystal/Surround placement with the decompiled latest WClient: +- cache a real outgoing ITEM_USE placement transaction as a template; +- clone that transaction for module placements so newer protocol fields are preserved; +- use the protocol BlockDefinition from the held ItemData directly; +- sanitize ItemData net-id before sending; +- rebuild server-authoritative inventory actions; +- keep local block state updated immediately. + +The decompiled latest WClient Scaffold (`com.retrivedmods.wclient.game.module.misc.a1`) +was used as the behavioral reference. diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 07bb9ae9..03b83522 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -3,6 +3,17 @@ -keep class io.netty.** { *; } -keep class org.cloudburstmc.netty.** { *; } -keep class org.cloudburstmc.protocol.bedrock.codec.** { *; } +# com.retrivedmods.wclient.util.PacketFieldUtil sets several packet fields (TextPacket.message, +# MovePlayerPacket.onGround, PlayerHotbarPacket.selectHotbarSlot, etc.) via raw reflection using +# string field names, because those fields no longer have public setters in the current Bedrock +# protocol library. Nothing else in the app references those fields by name, so without a keep +# rule R8 is free to rename or strip them in a release build - which is exactly what caused +# NoSuchFieldException crashes (e.g. "Field 'message' not found in ...") the moment a module that +# hits one of these reflective sets actually ran. Keeping the whole packet/data packages (not just +# the specific classes/fields used today) means future PacketFieldUtil usages don't silently +# reintroduce the same crash. +-keep class org.cloudburstmc.protocol.bedrock.packet.** { *; } +-keep class org.cloudburstmc.protocol.bedrock.data.** { *; } -keep @io.netty.channel.ChannelHandler$Sharable class * -keepclassmembers class * { @com.google.gson.annotations.SerializedName ; diff --git a/app/src/main/java/com/retrivedmods/wclient/activity/MainActivity.kt b/app/src/main/java/com/retrivedmods/wclient/activity/MainActivity.kt index cb331aca..0b5ac8e7 100644 --- a/app/src/main/java/com/retrivedmods/wclient/activity/MainActivity.kt +++ b/app/src/main/java/com/retrivedmods/wclient/activity/MainActivity.kt @@ -76,22 +76,8 @@ class MainActivity : ComponentActivity() { if (showLoading) { LoadingScreen( onDone = { - lifecycleScope.launch { - wclientId = VerificationManager.getWClientId(this@MainActivity) - - if (VerificationManager.isWhitelisted(this@MainActivity, wclientId)) { - showLoading = false - return@launch - } - - if (VerificationManager.isVerified(this@MainActivity, wclientId)) { - showLoading = false - return@launch - } - - showLoading = false - showVerificationDialog = true - } + // WClient ID verification (ad-gate) disabled - go straight in. + showLoading = false } ) } else if (showVerificationDialog) { diff --git a/app/src/main/java/com/retrivedmods/wclient/game/AccountManager.kt b/app/src/main/java/com/retrivedmods/wclient/game/AccountManager.kt index 061ccd36..fe422661 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/AccountManager.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/AccountManager.kt @@ -6,37 +6,46 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.google.gson.JsonParser import com.retrivedmods.wclient.application.AppContext -import com.retrivedmods.wclient.game.RealmsAuthFlow import com.retrivedmods.wclient.service.RealmsManager import com.retrivedmods.wrelay.util.AuthUtils -import com.retrivedmods.wrelay.util.refresh import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.raphimc.minecraftauth.MinecraftAuth -import net.raphimc.minecraftauth.step.bedrock.session.StepFullBedrockSession.FullBedrockSession +import net.raphimc.minecraftauth.bedrock.BedrockAuthManager import java.io.File import java.util.concurrent.TimeUnit object AccountManager { + // Bedrock game version reported to Mojang/Xbox services when negotiating a Minecraft + // session (net.raphimc.minecraftauth.bedrock.model.MinecraftSession). Keep this in sync with + // com.retrivedmods.wclient.util.MinecraftUtils.RECOMMENDED_VERSION. + const val GAME_VERSION = "1.26.40" + + // BedrockAuthManager (5.x) is a live, self-refreshing manager object rather than the old + // immutable FullBedrockSession, so we pair it with a plain cached display name for UI use + // (looking up the name via the manager would be a network call if the token had expired). + data class WAccount( + val authManager: BedrockAuthManager, + val displayName: String + ) + private val coroutineScope = CoroutineScope(Dispatchers.IO + CoroutineName("AccountManagerCoroutine")) - private val _accounts: MutableList = mutableStateListOf() + private val _accounts: MutableList = mutableStateListOf() - val accounts: List + val accounts: List get() = _accounts - var selectedAccount: FullBedrockSession? by mutableStateOf(null) + var selectedAccount: WAccount? by mutableStateOf(null) private set private val TOKEN_REFRESH_INTERVAL_MS = TimeUnit.MINUTES.toMillis(30) - private val TOKEN_REFRESH_THRESHOLD_MS = TimeUnit.HOURS.toMillis(2) - init { val fetchedAccounts = fetchAccounts() @@ -48,114 +57,92 @@ object AccountManager { startTokenRefreshScheduler() } - fun addAccount(fullBedrockSession: FullBedrockSession) { - val existingAccount = _accounts.find { it.mcChain.displayName == fullBedrockSession.mcChain.displayName } + fun addAccount(authManager: BedrockAuthManager) { + val displayName = authManager.minecraftCertificateChain.getUpToDate().identityDisplayName + val account = WAccount(authManager, displayName) + + val existingAccount = _accounts.find { it.displayName == displayName } if (existingAccount != null) { _accounts.remove(existingAccount) } - _accounts.add(fullBedrockSession) - - coroutineScope.launch { - val file = File(AppContext.instance.cacheDir, "accounts") - file.mkdirs() + _accounts.add(account) - try { - val json = if (fullBedrockSession.realmsXsts != null) { - RealmsAuthFlow.BEDROCK_DEVICE_CODE_LOGIN_WITH_REALMS.toJson(fullBedrockSession) - } else { - println("No Realms token available, saving with regular auth flow") - MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN.toJson(fullBedrockSession) - } - file.resolve("${fullBedrockSession.mcChain.displayName}.json") - .writeText(AuthUtils.gson.toJson(json)) - println("Successfully saved account: ${fullBedrockSession.mcChain.displayName} - Realms support: ${fullBedrockSession.realmsXsts != null}") - } catch (e: Exception) { - println("Failed to save account with Realms support, trying fallback: ${e.message}") - try { - val json = MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN.toJson(fullBedrockSession) - file.resolve("${fullBedrockSession.mcChain.displayName}.json") - .writeText(AuthUtils.gson.toJson(json)) - println("Successfully saved account with fallback method: ${fullBedrockSession.mcChain.displayName}") - } catch (fallbackException: Exception) { - println("Failed to save account even with fallback: ${fallbackException.message}") - fallbackException.printStackTrace() - } - } + if (existingAccount == selectedAccount) { + selectAccount(account) } + + saveAccountToDisk(account) } - fun removeAccount(fullBedrockSession: FullBedrockSession) { - _accounts.remove(fullBedrockSession) + fun removeAccount(account: WAccount) { + _accounts.remove(account) coroutineScope.launch { val file = File(AppContext.instance.cacheDir, "accounts") file.mkdirs() - file.resolve("${fullBedrockSession.mcChain.displayName}.json") - .delete() + file.resolve("${account.displayName}.json").delete() } } - fun selectAccount(fullBedrockSession: FullBedrockSession?) { - selectedAccount = fullBedrockSession + fun selectAccount(account: WAccount?) { + selectedAccount = account - RealmsManager.updateSession(fullBedrockSession) + RealmsManager.updateSession(account) coroutineScope.launch { val file = File(AppContext.instance.cacheDir, "accounts") file.mkdirs() runCatching { - val selectedAccount = file.resolve("selectedAccount") - if (fullBedrockSession != null) { - selectedAccount.writeText(fullBedrockSession.mcChain.displayName) + val selectedAccountFile = file.resolve("selectedAccount") + if (account != null) { + selectedAccountFile.writeText(account.displayName) } else { - selectedAccount.delete() + selectedAccountFile.delete() } } } } - private fun fetchAccounts(): List { + private fun fetchAccounts(): List { val file = File(AppContext.instance.cacheDir, "accounts") file.mkdirs() - val accounts = ArrayList() + val accounts = ArrayList() val listFiles = file.listFiles() ?: emptyArray() for (child in listFiles) { runCatching { if (child.isFile && child.extension == "json") { - val account = try { - RealmsAuthFlow.BEDROCK_DEVICE_CODE_LOGIN_WITH_REALMS - .fromJson(JsonParser.parseString(child.readText()).asJsonObject) - } catch (e: Exception) { - println("Failed to load account with Realms support from ${child.name}, trying legacy format: ${e.message}") - MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN - .fromJson(JsonParser.parseString(child.readText()).asJsonObject) - } - accounts.add(account) - println("Loaded account ${account.mcChain.displayName} - Realms support: ${account.realmsXsts != null}") + val httpClient = MinecraftAuth.createHttpClient() + val json = JsonParser.parseString(child.readText()).asJsonObject + val authManager = BedrockAuthManager.fromJson(httpClient, GAME_VERSION, json) + val displayName = + authManager.minecraftCertificateChain.getUpToDate().identityDisplayName + accounts.add(WAccount(authManager, displayName)) + println("Loaded account $displayName") } }.onFailure { println("Failed to load account from ${child.name}: ${it.message}") + it.printStackTrace() } } return accounts } - private fun fetchSelectedAccount(): FullBedrockSession? { + private fun fetchSelectedAccount(): WAccount? { val file = File(AppContext.instance.cacheDir, "accounts") file.mkdirs() - val selectedAccount = file.resolve("selectedAccount") - if (!selectedAccount.exists() || selectedAccount.isDirectory) { + val selectedAccountFile = file.resolve("selectedAccount") + if (!selectedAccountFile.exists() || selectedAccountFile.isDirectory) { return null } - val displayName = selectedAccount.readText() - return accounts.find { it.mcChain.displayName == displayName } + val displayName = selectedAccountFile.readText() + return accounts.find { it.displayName == displayName } } private fun startTokenRefreshScheduler() { @@ -178,95 +165,34 @@ object AccountManager { return } - val accountsToRefresh = _accounts.filter { account -> - shouldRefreshToken(account) - } - - if (accountsToRefresh.isNotEmpty()) { - println("Found ${accountsToRefresh.size} accounts that need token refresh") - } - - accountsToRefresh.forEach { account -> + _accounts.forEach { account -> try { - println("Refreshing token for account: ${account.mcChain.displayName}") - val httpClient = MinecraftAuth.createHttpClient() - httpClient.connectTimeout = 10000 - httpClient.readTimeout = 10000 - - val refreshedAccount = try { - if (account.realmsXsts != null) { - RealmsAuthFlow.BEDROCK_DEVICE_CODE_LOGIN_WITH_REALMS.refresh(httpClient, account) - } else { - account.refresh() - } - } catch (e: Exception) { - println("Failed to refresh with Realms support, trying regular refresh: ${e.message}") - account.refresh() - } - - val index = _accounts.indexOf(account) - if (index >= 0) { - _accounts[index] = refreshedAccount - - if (selectedAccount == account) { - selectedAccount = refreshedAccount - RealmsManager.updateSession(refreshedAccount) - } - - saveAccountToDisk(refreshedAccount) - } - - println("Successfully refreshed token for: ${refreshedAccount.mcChain.displayName}") + // getUpToDate() only performs a network refresh when the cached value is + // missing/expired, so this is a no-op for accounts that are already fresh - + // no more manually tracking expiry thresholds or swapping session objects. + account.authManager.minecraftCertificateChain.getUpToDate() + account.authManager.playFabToken.getUpToDate() + saveAccountToDisk(account) } catch (e: Exception) { - println("Failed to refresh token for ${account.mcChain.displayName}: ${e.message}") + println("Failed to refresh token for ${account.displayName}: ${e.message}") e.printStackTrace() } } } - private fun shouldRefreshToken(account: FullBedrockSession): Boolean { - val currentTime = System.currentTimeMillis() - - val msaToken = account.mcChain.xblXsts.initialXblSession.msaToken - if (msaToken.expireTimeMs - currentTime < TOKEN_REFRESH_THRESHOLD_MS) { - return true - } - - val xblExpireTime = account.mcChain.xblXsts.expireTimeMs - if (xblExpireTime - currentTime < TOKEN_REFRESH_THRESHOLD_MS) { - return true - } - - val playFabExpireTime = account.playFabToken.expireTimeMs - if (playFabExpireTime - currentTime < TOKEN_REFRESH_THRESHOLD_MS) { - return true - } - - return false - } - - private fun saveAccountToDisk(account: FullBedrockSession) { - val file = File(AppContext.instance.cacheDir, "accounts") - file.mkdirs() + private fun saveAccountToDisk(account: WAccount) { + coroutineScope.launch { + val file = File(AppContext.instance.cacheDir, "accounts") + file.mkdirs() - try { - val json = if (account.realmsXsts != null) { - RealmsAuthFlow.BEDROCK_DEVICE_CODE_LOGIN_WITH_REALMS.toJson(account) - } else { - MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN.toJson(account) - } - file.resolve("${account.mcChain.displayName}.json") - .writeText(AuthUtils.gson.toJson(json)) - } catch (e: Exception) { - println("Failed to save account with Realms support, trying fallback: ${e.message}") try { - val json = MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN.toJson(account) - file.resolve("${account.mcChain.displayName}.json") + val json = BedrockAuthManager.toJson(account.authManager) + file.resolve("${account.displayName}.json") .writeText(AuthUtils.gson.toJson(json)) - } catch (fallbackException: Exception) { - println("Failed to save account even with fallback: ${fallbackException.message}") - fallbackException.printStackTrace() + } catch (e: Exception) { + println("Failed to save account ${account.displayName}: ${e.message}") + e.printStackTrace() } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/retrivedmods/wclient/game/ActionBarManager.kt b/app/src/main/java/com/retrivedmods/wclient/game/ActionBarManager.kt index bfde30bd..d56bf38f 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/ActionBarManager.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/ActionBarManager.kt @@ -1,5 +1,6 @@ package com.retrivedmods.wclient.game +import com.retrivedmods.wclient.util.setPacketField import net.kyori.adventure.text.Component import org.cloudburstmc.protocol.bedrock.packet.SetTitlePacket @@ -25,7 +26,7 @@ object ActionBarManager { session.clientBound(SetTitlePacket().apply { type = SetTitlePacket.Type.ACTIONBAR - text = combinedText + setPacketField("text", combinedText) fadeInTime = 0 fadeOutTime = 0 stayTime = 2 diff --git a/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt b/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt new file mode 100644 index 00000000..b974e883 --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt @@ -0,0 +1,148 @@ +package com.retrivedmods.wclient.game + +import com.retrivedmods.wclient.util.PacketDebugLog +import org.cloudburstmc.math.vector.Vector3i +import org.cloudburstmc.protocol.bedrock.data.definitions.BlockDefinition +import org.cloudburstmc.protocol.bedrock.data.inventory.ItemData +import org.cloudburstmc.protocol.bedrock.data.inventory.transaction.InventoryActionData +import org.cloudburstmc.protocol.bedrock.data.inventory.transaction.InventorySource +import org.cloudburstmc.protocol.bedrock.packet.InventoryTransactionPacket + +/** + * Shared helpers for modules that place blocks via InventoryTransactionPacket (PistonCrystalModule, + * SurroundModule). Originally ported by comparing against ProtoHax's EntityLocalPlayer.placeBlock(), + * but a real placement packet captured in-game (via PacketLoggerModule, comparing a manual placement + * against what these modules were actually sending) turned up two further problems beyond what was + * fixed here initially: + * - InventoryTransactionPacket.blockDefinition describes the EXISTING block being clicked (used by + * the server to sanity-check the client's view of the world) - NOT the new block being placed. + * Setting it to the placed block's own definition (this file's old blockDefinitionFor() misuse) + * made the server see a mismatch against its own world state and silently reject the whole + * transaction. + * - inventoriesServerAuthoritative servers require an InventoryActionData entry describing the + * consumed item alongside the ITEM_USE transaction; a real client always sends one. Without it + * the transaction gets silently dropped on any server using that (the modern default) mode. + * - blockPosition/blockFace were pointing at the *empty* target spot itself instead of an existing + * solid neighbor block being "clicked" - which is what those two fields actually mean on the + * wire: the new block appears on the far side of the clicked face of an *existing* block, you + * can't click a position that's air. + */ +object BlockPlacementUtils { + + /** (face normal, WClient block-face index) pairs, tried in this order. 0=down,1=up,2=north,3=south,4=west,5=east. */ + private val FACES = listOf( + Vector3i.from(0, -1, 0) to 0, // down - tried first: the common "build on top of solid ground" case + Vector3i.from(0, 1, 0) to 1, // up + Vector3i.from(0, 0, -1) to 2, // north + Vector3i.from(0, 0, 1) to 3, // south + Vector3i.from(-1, 0, 0) to 4, // west + Vector3i.from(1, 0, 0) to 5 // east + ) + + /** + * Finds an existing non-air neighbor of [pos] to use as the InventoryTransactionPacket's + * blockPosition/blockFace (the new block ends up placed at [pos], on the far side of the + * returned face). Returns null if [pos] is fully isolated (no solid neighbor at all) - a real + * Minecraft client couldn't place there either in that case. + */ + fun findReferenceBlock(session: GameSession, pos: Vector3i): Pair? { + // Prefer a neighbor we can positively confirm is solid. Sending blockDefinition as + // "minecraft:unknown" for an untracked neighbor gets the whole transaction rejected by + // the server (confirmed via [AutoPlaceLog] - a real attempt with blockDefinition=unknown + // never resulted in a placed block), since the server validates that field against its + // own real world state. Only fall back to an unconfirmed guess - still worth trying, it + // might happen to be right - if nothing around pos is actually known. + var fallback: Pair? = null + for ((normal, face) in FACES) { + val neighbor = pos.add(-normal.x, -normal.y, -normal.z) + val identifier = session.level.getBlockAt(neighbor).identifier + if (identifier != "minecraft:air" && identifier != "minecraft:unknown") { + return neighbor to face + } + if (identifier == "minecraft:unknown" && fallback == null) { + fallback = neighbor to face + } + } + return fallback + } + + /** + * The block InventoryTransactionPacket.blockDefinition actually needs: the EXISTING block at + * [referencePos] (the one being clicked) - see the class doc above. Use this, not + * [blockDefinitionFor], when building the packet. + */ + fun referenceBlockDefinition(session: GameSession, referencePos: Vector3i): BlockDefinition { + return session.level.getBlockAt(referencePos) + } + + /** + * Runtime block definition for [identifier] (e.g. "minecraft:piston") - the block *being + * placed*. Only useful for [predictLocalBlockChange]; do NOT use this for + * InventoryTransactionPacket.blockDefinition (see [referenceBlockDefinition] for that). Returns + * null for non-block items (like "minecraft:end_crystal") that aren't in the block mapping. + */ + fun blockDefinitionFor(session: GameSession, identifier: String): BlockDefinition? { + if (!session.isBlockMappingInitialized) return null + val runtimeId = session.blockMapping.getRuntimeIdByIdentifier(identifier) ?: return null + return session.blockMapping.getDefinition(runtimeId) + } + + /** + * The InventoryActionData a real client always includes alongside an ITEM_USE placement + * transaction, describing the held item being consumed from the hotbar slot. Missing this is + * what made every placement from these modules get silently dropped on + * inventoriesServerAuthoritative servers (the modern default) - confirmed by comparing against + * a real captured placement packet, which always had exactly one of these. + */ + fun consumeItemAction(hotbarSlot: Int, current: ItemData): InventoryActionData { + val afterUse = if (current.count > 1) { + current.toBuilder().count(current.count - 1).build() + } else { + ItemData.AIR + } + return InventoryActionData( + InventorySource.fromContainerWindowId(0), + hotbarSlot, + current, + afterUse + ) + } + + /** + * Sends [packet] and unconditionally logs it via PacketDebugLog (shown as [AutoPlaceLog] in + * chat when PacketLoggerModule is enabled) - use this instead of calling + * session.serverBound(packet) directly for placement transactions, so it's always possible to + * tell "nothing is being sent" apart from "something is being sent and rejected" by the + * server. + */ + fun sendAndLog(session: GameSession, packet: InventoryTransactionPacket) { + session.serverBound(packet) + PacketDebugLog.log( + session, + "AutoPlaceLog", + buildString { + append("blockPosition: ${packet.blockPosition}\n") + append("blockFace: ${packet.blockFace}\n") + append("blockDefinition: ${packet.blockDefinition}\n") + append("clickPosition: ${packet.clickPosition}\n") + append("playerPosition: ${packet.playerPosition}\n") + append("hotbarSlot: ${packet.hotbarSlot}\n") + append("itemInHand: ${packet.itemInHand}\n") + append("actions: ${packet.actions}") + } + ) + } + + /** + * Updates WClient's own tracked world state immediately instead of waiting for the server to + * echo an UpdateBlockPacket back. Matters for placement sequences (piston -> crystal -> + * redstone, or Surround's many-blocks-per-tick ring) where a later step's validity checks query + * session.level.getBlockAt() and would otherwise still see stale (air) data for up to a full + * round-trip. Mirrors ProtoHax's EntityLocalPlayer.placeBlock(), which does the same local + * prediction via session.level.setBlockIdAt() before sending the transaction. + */ + fun predictLocalBlockChange(session: GameSession, pos: Vector3i, definition: BlockDefinition?) { + if (definition == null) return + session.level.setBlockIdAt(pos.x, pos.y, pos.z, definition.runtimeId) + } +} diff --git a/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt b/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt index ec40751e..6b69fc92 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt @@ -8,6 +8,7 @@ import com.retrivedmods.wclient.game.registry.BlockMappingProvider import com.retrivedmods.wclient.game.registry.ItemMapping import com.retrivedmods.wclient.game.registry.ItemMappingProvider import com.retrivedmods.wclient.game.world.Level +import com.retrivedmods.wclient.util.setPacketField import com.retrivedmods.wrelay.WRelaySession import org.cloudburstmc.protocol.bedrock.data.definitions.ItemDefinition import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket @@ -33,6 +34,13 @@ class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { lateinit var blockMapping: BlockMapping lateinit var itemMapping: ItemMapping + // Exposed instead of letting other classes do `session::blockMapping.isInitialized` + // directly - checking a lateinit property's isInitialized from outside its declaring + // class can fail to compile ("Backing field ... is not accessible at this point"), + // so we keep the check here where it's always safe and hand out a plain Boolean. + val isBlockMappingInitialized: Boolean + get() = ::blockMapping.isInitialized + private var startGameReceived = false fun clientBound(packet: BedrockPacket) { @@ -43,7 +51,26 @@ class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { wRelaySession.serverBound(packet) } + // ComposedPacketHandler's default beforeClientBound()/beforeServerBound() both just call + // this single method, so modules previously had no way to tell which direction a packet + // was going (e.g. AntiCrystalModule rewriting position needs to only touch outgoing/ + // server-bound packets, not incoming ones). We now override beforeClientBound/ + // beforeServerBound below instead, so this is only kept to satisfy the interface and + // defaults to treating the packet as server-bound if anything else ends up calling it + // directly. override fun beforePacketBound(packet: BedrockPacket): Boolean { + return handlePacketBound(packet, isClientBound = false) + } + + override fun beforeClientBound(packet: BedrockPacket): Boolean { + return handlePacketBound(packet, isClientBound = true) + } + + override fun beforeServerBound(packet: BedrockPacket): Boolean { + return handlePacketBound(packet, isClientBound = false) + } + + private fun handlePacketBound(packet: BedrockPacket, isClientBound: Boolean): Boolean { when (packet) { is StartGamePacket -> { try { @@ -63,14 +90,57 @@ class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { startGameReceived = true Log.i("GameSession", "StartGamePacket received") + // Prefer the block palette the *server itself* sent in this exact packet over + // our bundled per-protocol-version asset files. Those asset files only go up to + // an old protocol (they need to be hand-updated every Minecraft version), so on + // any server newer than that, block runtime IDs looked up from them are silently + // wrong (e.g. asking for "minecraft:obsidian" and getting back some unrelated + // block) - which makes every placement attempt get rejected by the server, since + // the block it's told to place doesn't match the item actually being used. + // The server's own palette is *always* correct for whatever version it's running, + // so this can never go stale the way the bundled files do. + val livePalette = extractBlockPaletteFromStartGame(packet) + if (livePalette != null) { + try { + blockMapping = BlockMapping.fromPalette(livePalette) + Log.i("GameSession", "Loaded block mapping from the server's own StartGamePacket palette (${livePalette.size} entries)") + } catch (e: Exception) { + Log.e("GameSession", "Failed to build block mapping from StartGamePacket palette, falling back to bundled asset", e) + } + } else { + Log.w("GameSession", "StartGamePacket.blockProperties was empty - falling back to the bundled per-protocol asset file, which may be outdated for this server's version") + } + try { - blockMapping = blockMappingProvider.craftMapping(protocolVersion) + if (!isBlockMappingInitialized) { + blockMapping = blockMappingProvider.craftMapping(protocolVersion) + } itemMapping = itemMappingProvider.craftMapping(protocolVersion) Log.i("GameSession", "Loaded mappings for protocol $protocolVersion") } catch (e: Exception) { Log.e("GameSession", "Failed to load mappings for protocol $protocolVersion", e) } + + // CRITICAL: codecHelper.blockDefinitions was never being set (only + // itemDefinitions was, above/in ItemComponentPacket) - meaning the actual + // packet encoder/decoder was reading and writing every block-definition field + // on every packet (SubChunkPacket's block data, InventoryTransactionPacket's + // blockDefinition, etc.) using its own default/empty registry instead of the + // real per-server blockMapping we just built above. That's consistent with + // what packet logging showed: a fixed, wrong block definition (birch_hanging_ + // sign) no matter what was actually being clicked - and it would equally well + // explain the world's block data (via SubChunkPacket) never looking right, + // which is why canPlaceCrystal/findValidPlacement kept rejecting everything. + if (isBlockMappingInitialized) { + try { + wRelaySession.server.peer.codecHelper.blockDefinitions = blockMapping + wRelaySession.client?.peer?.codecHelper?.blockDefinitions = blockMapping + Log.i("GameSession", "Successfully set up codecHelper blockDefinitions") + } catch (e: Exception) { + Log.e("GameSession", "Failed to set up codecHelper blockDefinitions", e) + } + } } } @@ -93,7 +163,7 @@ class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { localPlayer.onPacketBound(packet) level.onPacketBound(packet) - val interceptablePacket = InterceptablePacket(packet) + val interceptablePacket = InterceptablePacket(packet, isClientBound) for (module in ModuleManager.modules) { // Set session if not already set @@ -129,11 +199,34 @@ class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { val textPacket = TextPacket() textPacket.type = type textPacket.sourceName = "" - textPacket.message = message + textPacket.setPacketField("message", message) textPacket.xuid = "" textPacket.platformChatId = "" - textPacket.filteredMessage = "" + textPacket.setPacketField("filteredMessage", "") clientBound(textPacket) } -} \ No newline at end of file + /** + * Reflection-based lookup for StartGamePacket's block palette. Used instead of referencing a + * property directly (like `packet.itemDefinitions` above) because - unlike itemDefinitions, + * which is already used elsewhere in this codebase against the current bedrock-codec version + * and therefore known to exist - the exact accessor name for the block palette on this specific + * library version hasn't been confirmed against source. Trying several plausible candidates via + * reflection means a wrong guess here just falls through to the next candidate (or the bundled + * asset fallback) instead of breaking the build. + */ + /** + * Reads the server's own block palette directly off StartGamePacket. Confirmed via the real + * bedrock-codec source: the field is `blockProperties: List` (not the + * NbtMap list this code originally guessed at and reflection-searched several possible names + * for) - each entry has a plain `.name: String` and `.properties: NbtMap`, no runtimeId field + * at all. No more reflection/guessing needed now that this is confirmed. + */ + private fun extractBlockPaletteFromStartGame( + packet: StartGamePacket + ): List? { + val list = packet.blockProperties + return list.takeIf { it.isNotEmpty() } + } + +} diff --git a/app/src/main/java/com/retrivedmods/wclient/game/InterceptablePacket.kt b/app/src/main/java/com/retrivedmods/wclient/game/InterceptablePacket.kt index 3c592169..4638d6d4 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/InterceptablePacket.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/InterceptablePacket.kt @@ -2,7 +2,14 @@ package com.retrivedmods.wclient.game import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket -data class InterceptablePacket(val packet: BedrockPacket) { +data class InterceptablePacket( + val packet: BedrockPacket, + // true = server -> client (about to reach the game client), false = client -> server + // (about to reach the real server). Modules that only make sense in one direction - e.g. + // AntiCrystalModule rewriting the position we report to the server - need this to avoid + // acting on packets going the wrong way. + val isClientBound: Boolean +) { var isIntercepted = false private set diff --git a/app/src/main/java/com/retrivedmods/wclient/game/ModuleManager.kt b/app/src/main/java/com/retrivedmods/wclient/game/ModuleManager.kt index 9e32f08f..c6da3209 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/ModuleManager.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/ModuleManager.kt @@ -19,6 +19,8 @@ import com.retrivedmods.wclient.game.module.combat.AutoHvHModule import com.retrivedmods.wclient.game.module.combat.AutoTotemModule import com.retrivedmods.wclient.game.module.combat.HotbarSwitcherModule import com.retrivedmods.wclient.game.module.combat.InfiniteAuraModule +import com.retrivedmods.wclient.game.module.combat.PistonCrystalModule +import com.retrivedmods.wclient.game.module.combat.SurroundModule import com.retrivedmods.wclient.game.module.misc.ArrayListModule import com.retrivedmods.wclient.game.module.motion.NoClipModule import com.retrivedmods.wclient.game.module.misc.AutoDisconnectModule @@ -30,6 +32,7 @@ import com.retrivedmods.wclient.game.module.misc.FakeXPModule import com.retrivedmods.wclient.game.module.misc.MinerModule import com.retrivedmods.wclient.game.module.misc.NoChatModule import com.retrivedmods.wclient.game.module.misc.PieChartModule +import com.retrivedmods.wclient.game.module.misc.PacketLoggerModule import com.retrivedmods.wclient.game.module.misc.PositionLoggerModule import com.retrivedmods.wclient.game.module.misc.ReplayModule import com.retrivedmods.wclient.game.module.misc.ChestStealerModule @@ -99,6 +102,8 @@ object ModuleManager { add(AntiKnockbackModule()) add(AntiCrystalModule()) + add(SurroundModule()) + add(PistonCrystalModule()) add(HitAndRunModule()) add(HitboxModule()) add(CrystalSmashModule()) @@ -152,6 +157,7 @@ object ModuleManager { add(SpammerModule()) add(WaterMarkModule()) add(PositionLoggerModule()) + add(PacketLoggerModule()) add(NoChatModule()) add(CommandHandlerModule()) add(ReplayModule()) diff --git a/app/src/main/java/com/retrivedmods/wclient/game/RealmsAuthFlow.kt b/app/src/main/java/com/retrivedmods/wclient/game/RealmsAuthFlow.kt index 7e5fc042..75407831 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/RealmsAuthFlow.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/RealmsAuthFlow.kt @@ -1,19 +1,15 @@ package com.retrivedmods.wclient.game -import net.raphimc.minecraftauth.MinecraftAuth -import net.raphimc.minecraftauth.step.AbstractStep -import net.raphimc.minecraftauth.step.bedrock.session.StepFullBedrockSession -import net.raphimc.minecraftauth.util.MicrosoftConstants +import net.raphimc.minecraftauth.msa.data.MsaConstants +import net.raphimc.minecraftauth.msa.model.MsaApplicationConfig object RealmsAuthFlow { - val BEDROCK_DEVICE_CODE_LOGIN_WITH_REALMS: AbstractStep<*, StepFullBedrockSession.FullBedrockSession> = - MinecraftAuth.builder() - .withClientId(MicrosoftConstants.BEDROCK_ANDROID_TITLE_ID) - .withScope(MicrosoftConstants.SCOPE_TITLE_AUTH) - .deviceCode() - .withDeviceToken("Android") - .sisuTitleAuthentication(MicrosoftConstants.BEDROCK_XSTS_RELYING_PARTY) - .buildMinecraftBedrockChainStep(true, true) + // Bedrock Android title ID + the "title auth" scope - same values the old MinecraftAuth 4.x + // MicrosoftConstants.BEDROCK_ANDROID_TITLE_ID / SCOPE_TITLE_AUTH pointed to. Using a "title" + // client ID is what lets BedrockAuthManager also fetch a Realms XSTS token later, on demand - + // in 5.x there's no separate "Realms-capable chain" to build up front like there was in 4.x. + val BEDROCK_ANDROID_APPLICATION_CONFIG: MsaApplicationConfig = + MsaApplicationConfig(MsaConstants.BEDROCK_ANDROID_TITLE_ID, MsaConstants.SCOPE_TITLE_AUTH) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/retrivedmods/wclient/game/entity/LocalPlayer.kt b/app/src/main/java/com/retrivedmods/wclient/game/entity/LocalPlayer.kt index 5b4b742f..299b8f8c 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/entity/LocalPlayer.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/entity/LocalPlayer.kt @@ -4,9 +4,16 @@ import com.retrivedmods.wclient.game.GameSession import com.retrivedmods.wclient.game.inventory.AbstractInventory import com.retrivedmods.wclient.game.inventory.ContainerInventory import com.retrivedmods.wclient.game.inventory.PlayerInventory +import com.retrivedmods.wclient.game.registry.BlockDefinition +import com.retrivedmods.wclient.game.utils.misc.removeNetInfo +import com.retrivedmods.wclient.util.PacketDebugLog import org.cloudburstmc.math.vector.Vector3f +import org.cloudburstmc.math.vector.Vector3i import org.cloudburstmc.protocol.bedrock.data.AuthoritativeMovementMode import org.cloudburstmc.protocol.bedrock.data.SoundEvent +import org.cloudburstmc.protocol.bedrock.data.inventory.ItemData +import org.cloudburstmc.protocol.bedrock.data.inventory.transaction.InventoryActionData +import org.cloudburstmc.protocol.bedrock.data.inventory.transaction.InventorySource import org.cloudburstmc.protocol.bedrock.data.inventory.transaction.InventoryTransactionType import org.cloudburstmc.protocol.bedrock.packet.AnimatePacket import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket @@ -86,6 +93,99 @@ class LocalPlayer(val session: GameSession) : Player(0L, 0L, UUID.randomUUID(), } } + /** + * Places [definition] at [target] by "clicking" the existing block at [referencePos] from + * [face] (0=down,1=up,2=north,3=south,4=west,5=east; [target] must equal [referencePos] plus + * that face's direction - see Level.findPlacementReference, which computes both). Ported from + * ProtoHax's EntityLocalPlayer.placeBlock. + * + * Predicts the placement into our own world tracking immediately (matching real client/server + * behavior - the server doesn't wait for round-trip confirmation before the block "exists" + * locally), and - critically - attaches an inventory action for the consumed item when + * [inventoriesServerAuthoritative] is true, which most modern servers require or they silently + * drop the whole transaction. + */ + fun placeBlock(target: Vector3i, referencePos: Vector3i, face: Int, definition: BlockDefinition) { + session.level.setBlockIdAt(target.x, target.y, target.z, definition.runtimeId) + + val packet = InventoryTransactionPacket().apply { + transactionType = InventoryTransactionType.ITEM_USE + actionType = 0 + blockPosition = referencePos + blockFace = face + hotbarSlot = inventory.heldItemSlot + itemInHand = inventory.hand + playerPosition = vec3Position + // headPosition: confirmed via a real captured placement packet (see PacketLoggerModule) + // that the actual Minecraft client sends this as null - leaving it unset entirely + // (previously this was set to an eye-height offset, which was wrong). + clickPosition = clickPositionForFace(face) + // blockDefinition must describe the EXISTING block at referencePos (the thing being + // clicked), not the new block being placed - also confirmed from a real captured + // packet, where this field held the wall block that was clicked, not the obsidian + // being placed. Using `definition` (the new block) here was backwards. + blockDefinition = session.level.getBlockAt(referencePos) + + // A real captured placement packet (via PacketLoggerModule) always included this + // inventory action alongside the ITEM_USE transaction, so it's sent unconditionally + // now rather than only when inventoriesServerAuthoritative reads true - trusting what + // the real client actually does over that flag's value on any particular server. + val current = itemInHand + val afterUse = if (current.count > 1) { + current.toBuilder().count(current.count - 1).build() + } else { + ItemData.AIR + } + actions.add( + InventoryActionData( + InventorySource.fromContainerWindowId(0), + hotbarSlot, + current, + afterUse + ) + ) + } + + session.serverBound(packet) + PacketDebugLog.log( + session, + "AutoPlaceLog", + buildString { + append("blockPosition: ${packet.blockPosition}\n") + append("blockFace: ${packet.blockFace}\n") + append("blockDefinition: ${packet.blockDefinition}\n") + append("clickPosition: ${packet.clickPosition}\n") + append("playerPosition: ${packet.playerPosition}\n") + append("headPosition: ${packet.headPosition}\n") + append("hotbarSlot: ${packet.hotbarSlot}\n") + append("itemInHand: ${packet.itemInHand}\n") + append("actions: ${packet.actions}") + } + ) + } + + /** + * A click position consistent with [face] - the axis matching the clicked face pinned to its + * boundary (0f or 1f), the other two randomized within the face like a real click would be. + * Matches the shape of a real captured placement packet's clickPosition (e.g. (0.875, 1.0, + * 0.125) for an up-face click), rather than the old fixed/fully-random point which didn't + * correspond to the face at all. + */ + private fun clickPositionForFace(face: Int): Vector3f { + val rx = Math.random().toFloat() + val ry = Math.random().toFloat() + val rz = Math.random().toFloat() + return when (face) { + 0 -> Vector3f.from(rx, 0f, rz) + 1 -> Vector3f.from(rx, 1f, rz) + 2 -> Vector3f.from(rx, ry, 0f) + 3 -> Vector3f.from(rx, ry, 1f) + 4 -> Vector3f.from(0f, ry, rz) + 5 -> Vector3f.from(1f, ry, rz) + else -> Vector3f.from(0.5f, 0.5f, 0.5f) + } + } + fun swing() { val animatePacket = AnimatePacket() animatePacket.action = AnimatePacket.Action.SWING_ARM diff --git a/app/src/main/java/com/retrivedmods/wclient/game/inventory/PlayerInventory.kt b/app/src/main/java/com/retrivedmods/wclient/game/inventory/PlayerInventory.kt index 3c6bfc70..ad491dcd 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/inventory/PlayerInventory.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/inventory/PlayerInventory.kt @@ -32,6 +32,22 @@ class PlayerInventory(private val player: LocalPlayer) : EntityInventory(player) var heldItemSlot = 0 private set + /** + * Modules that switch the hotbar slot themselves (Surround, PistonCrystal, ...) via + * session.serverBound(PlayerHotbarPacket) need to call this right after, since that send + * bypasses the normal interception pipeline entirely (see GameSession/WRelaySession) - the + * only place heldItemSlot otherwise gets updated is by observing the real client's own + * traffic passing through there. Without calling this, heldItemSlot silently never changes, + * so `hand` (= content[heldItemSlot]) keeps pointing at whatever was selected before the + * module ran - which then mismatches the hotbarSlot in every placement packet the module + * sends, and gets the whole transaction rejected by any server that validates inventory + * state. This predicts the same way a real client's own selection updates immediately, + * without waiting on a round trip. + */ + fun predictHeldItemSlot(slot: Int) { + heldItemSlot = slot + } + private var requestId = -1 private val requestIdMap = mutableMapOf() private val pendingRequests = LinkedList() diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/ACAModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/ACAModule.kt index fbce872e..4b9257bb 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/ACAModule.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/ACAModule.kt @@ -1,5 +1,7 @@ package com.retrivedmods.wclient.game.module.combat +import com.retrivedmods.wclient.util.setPacketField + import com.retrivedmods.wclient.game.InterceptablePacket import com.retrivedmods.wclient.game.Module import com.retrivedmods.wclient.game.ModuleCategory @@ -70,7 +72,7 @@ class ACAModule : Module("ACA", ModuleCategory.Combat) { position = newPosition rotation = entity.vec3Rotation mode = MovePlayerPacket.Mode.NORMAL - onGround = false + setPacketField("onGround", false) tick = session.localPlayer.tickExists } diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/AntiCrystalModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/AntiCrystalModule.kt index d772e969..3d216e13 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/AntiCrystalModule.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/AntiCrystalModule.kt @@ -4,20 +4,40 @@ package com.retrivedmods.wclient.game.module.combat import com.retrivedmods.wclient.game.InterceptablePacket import com.retrivedmods.wclient.game.Module import com.retrivedmods.wclient.game.ModuleCategory +import org.cloudburstmc.math.vector.Vector3f +import org.cloudburstmc.protocol.bedrock.packet.MovePlayerPacket import org.cloudburstmc.protocol.bedrock.packet.PlayerAuthInputPacket class AntiCrystalModule : Module("anti_crystal", ModuleCategory.Combat) { - private var ylevel by floatValue("ylevel", 0.4f, 0.1f..1.61f) + private var reduce by floatValue("reduce", 0.6f, 0.1f..1f) override fun beforePacketBound(interceptablePacket: InterceptablePacket) { if (!isEnabled) { return } + // MovePlayerPacket can be sent by either side; PlayerAuthInputPacket is always + // client -> server. We only want to rewrite what WE send to the server (matching the + // C++ reference's onSendPacket, which only fires for outgoing packets) - rewriting an + // incoming MovePlayerPacket wouldn't affect the server at all, and could corrupt other + // entities' rendered positions if the packet wasn't even about the local player. + if (interceptablePacket.isClientBound) { + return + } + + val actorPos = session.localPlayer.vec3Position + val newY = actorPos.y - reduce + val packet = interceptablePacket.packet - if (packet is PlayerAuthInputPacket) { - packet.position.add(0.0, -ylevel.toDouble(), 0.0) + when (packet) { + is PlayerAuthInputPacket -> { + packet.position = Vector3f.from(packet.position.x, newY, packet.position.z) + } + + is MovePlayerPacket -> { + packet.position = Vector3f.from(packet.position.x, newY, packet.position.z) + } } } diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/AutoHvHModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/AutoHvHModule.kt index f63c5274..de42d24f 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/AutoHvHModule.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/AutoHvHModule.kt @@ -1,5 +1,7 @@ package com.retrivedmods.wclient.game.module.combat +import com.retrivedmods.wclient.util.setPacketField + import com.retrivedmods.wclient.game.InterceptablePacket import com.retrivedmods.wclient.game.Module import com.retrivedmods.wclient.game.ModuleCategory @@ -92,7 +94,7 @@ class AutoHvHModule : Module("Auto HVH", ModuleCategory.Combat) { position = newPosition rotation = player.vec3Rotation mode = MovePlayerPacket.Mode.NORMAL - onGround = false + setPacketField("onGround", false) ridingRuntimeEntityId = 0 tick = player.tickExists }) diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/EnemyHunterModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/EnemyHunterModule.kt index d5a3e214..090adebf 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/EnemyHunterModule.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/EnemyHunterModule.kt @@ -1,5 +1,7 @@ package com.retrivedmods.wclient.game.module.combat +import com.retrivedmods.wclient.util.setPacketField + import com.retrivedmods.wclient.game.InterceptablePacket import com.retrivedmods.wclient.game.Module import com.retrivedmods.wclient.game.ModuleCategory @@ -106,7 +108,7 @@ class EnemyHunterModule : Module("EnemyHunter", ModuleCategory.Combat) { position = newPosition rotation = rotationVec mode = MovePlayerPacket.Mode.NORMAL - onGround = false + setPacketField("onGround", false) ridingRuntimeEntityId = 0 tick = player.tickExists }) diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/HotbarSwitcherModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/HotbarSwitcherModule.kt index a842f626..921cecb9 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/HotbarSwitcherModule.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/HotbarSwitcherModule.kt @@ -3,6 +3,7 @@ package com.retrivedmods.wclient.game.module.combat import com.retrivedmods.wclient.game.InterceptablePacket import com.retrivedmods.wclient.game.Module import com.retrivedmods.wclient.game.ModuleCategory +import com.retrivedmods.wclient.util.setPacketField import org.cloudburstmc.protocol.bedrock.packet.PlayerHotbarPacket class HotbarSwitcherModule : Module("switcher", ModuleCategory.Combat) { @@ -67,7 +68,7 @@ class HotbarSwitcherModule : Module("switcher", ModuleCategory.Combat) { val packet = PlayerHotbarPacket() packet.selectedHotbarSlot = slot packet.containerId = 0 - packet.selectHotbarSlot = true + packet.setPacketField("selectHotbarSlot", true) session.clientBound(packet) } diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/KillauraModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/KillauraModule.kt index 1e25b541..b6506135 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/KillauraModule.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/KillauraModule.kt @@ -1,5 +1,7 @@ package com.retrivedmods.wclient.game.module.combat +import com.retrivedmods.wclient.util.setPacketField + import com.retrivedmods.wclient.game.InterceptablePacket import com.retrivedmods.wclient.game.Module import com.retrivedmods.wclient.game.ModuleCategory @@ -149,7 +151,7 @@ class KillauraModule : Module("killaura", ModuleCategory.Combat) { position = tpPos rotation = entity.vec3Rotation mode = MovePlayerPacket.Mode.NORMAL - onGround = false + setPacketField("onGround", false) tick = player.tickExists } ) @@ -170,7 +172,7 @@ class KillauraModule : Module("killaura", ModuleCategory.Combat) { position = pos.add(x.toFloat(), 0f, z.toFloat()) rotation = Vector3f.ZERO mode = MovePlayerPacket.Mode.NORMAL - onGround = true + setPacketField("onGround", true) tick = session.localPlayer.tickExists } ) diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/PistonCrystalModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/PistonCrystalModule.kt new file mode 100644 index 00000000..8077dec2 --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/PistonCrystalModule.kt @@ -0,0 +1,464 @@ +package com.retrivedmods.wclient.game.module.combat + +import com.retrivedmods.wclient.game.InterceptablePacket +import com.retrivedmods.wclient.game.BlockPlacementUtils +import com.retrivedmods.wclient.game.Module +import com.retrivedmods.wclient.game.ModuleCategory +import com.retrivedmods.wclient.game.entity.Entity +import com.retrivedmods.wclient.game.entity.EntityUnknown +import com.retrivedmods.wclient.game.entity.LocalPlayer +import com.retrivedmods.wclient.game.entity.Player +import com.retrivedmods.wclient.game.friend.FriendManager +import org.cloudburstmc.math.vector.Vector3f +import org.cloudburstmc.math.vector.Vector3i +import org.cloudburstmc.protocol.bedrock.data.inventory.transaction.InventoryTransactionType +import org.cloudburstmc.protocol.bedrock.packet.InventoryTransactionPacket +import org.cloudburstmc.protocol.bedrock.packet.PlayerAuthInputPacket +import org.cloudburstmc.protocol.bedrock.packet.PlayerHotbarPacket +import kotlin.math.floor + +/** + * Ported from the PistonCrystal.h/.cpp reference, now backed by real block data + * (session.level.getBlockAt) rather than placing blind. Follows the original's + * findValidPlacement/calculatePlacement/isValidPlacement structure: + * - try each of the 4 cardinal directions from the target, closest-to-player first + * - for each direction, try yLevel 0 then 1 (target's feet level, then one above) + * - a placement is valid if the crystal spot is air with air above and an obsidian/bedrock + * base below it, and both the piston and redstone spots are placeable + * + * Not ported: the "dynamic"/shift perpendicular-offset placement variant, and the + * target/player AABB-collision checks (WClient's Entity has no hitbox width/height to check + * against). Piston orientation is approximated the same way Surround/AntiCrystal do it - faking + * the outgoing PlayerAuthInputPacket's rotation just before placing - which is best-effort, not + * guaranteed reliable. Direct-attack of any spawned crystal remains the fallback that actually + * guarantees a detonation regardless of whether the piston geometry landed cleanly. + */ +class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { + + private var range by floatValue("range", 6f, 2f..10f) + private var placeDelayTicks by intValue("place_delay", 2, 0..20) + private var usePiston by boolValue("use_piston", true) + private var fakeRotation by boolValue("fake_rotation", true) + private var autoAttackCrystal by boolValue("auto_attack_crystal", true) + private var selfDamageLimit by floatValue("self_damage_limit", 12f, 0f..36f) + private var targetDamageMin by floatValue("target_damage_min", 4f, 0f..36f) + private var playersOnly by boolValue("players_only", true) + private var antiBot by boolValue("anti_bot", true) + + private companion object { + const val OBSIDIAN = "minecraft:obsidian" + const val BEDROCK = "minecraft:bedrock" + const val CRYSTAL_ITEM = "minecraft:end_crystal" + const val CRYSTAL_ENTITY = "minecraft:ender_crystal" + const val PISTON = "minecraft:piston" + const val REDSTONE_BLOCK = "minecraft:redstone_block" + const val EXPLOSION_SIZE = 6f + + // Direction.X_PLUS, X_MINUS, Z_PLUS, Z_MINUS from PistonCrystal.h + val DIRECTIONS = listOf(Vector3i.from(1, 0, 0), Vector3i.from(-1, 0, 0), Vector3i.from(0, 0, 1), Vector3i.from(0, 0, -1)) + + // If a step (piston/crystal/redstone) hasn't advanced within this long, assume the server + // silently rejected that placement - Bedrock has no NACK packet for a bad transaction, it + // just does nothing, so our own predictLocalBlockChange() optimistic update (needed so the + // *next* step's placement checks don't see stale data) would otherwise keep believing that + // step succeeded forever, with nothing to ever correct it. Give up and let the search for + // a fresh placement run again next tick instead of getting stuck mid-sequence forever. + const val STEP_TIMEOUT_MS = 3000L + } + + private data class Placement(val crystalPos: Vector3i, val pistonPos: Vector3i, val redstonePos: Vector3i, val dir: Vector3i) + + private enum class Step { PISTON, CRYSTAL, REDSTONE, DONE } + + private var step = Step.DONE + set(value) { + field = value + stepStartedAt = System.currentTimeMillis() + } + private var stepStartedAt = 0L + private var placement: Placement? = null + private var tickCounter = 0 + private var oldSlot = -1 + private val lastCrystalAttack = HashMap() + + override fun onEnabled() { + super.onEnabled() + resetState() + } + + override fun onDisabled() { + super.onDisabled() + if (oldSlot != -1 && isSessionCreated) { + switchToSlot(oldSlot) + } + resetState() + } + + private fun resetState() { + step = Step.DONE + placement = null + tickCounter = 0 + oldSlot = -1 + lastCrystalAttack.clear() + lastWarnedMessage = null + } + + // --- TEMPORARY diagnostic logging ----------------------------------------------------- + // Prints why placement isn't happening, at most once every ~40 ticks (~2s) so chat doesn't + // get flooded. Remove once we know whether the problem is "no target" vs "target found but + // every placement candidate rejected". + private var diagTickCounter = 0 + private fun diag(msg: String) { + diagTickCounter++ + if (diagTickCounter % 40 != 0) return + session.displayClientMessage("§d[PistonCrystalDiag] §f$msg") + } + // ----------------------------------------------------------------------------------------- + + override fun beforePacketBound(interceptablePacket: InterceptablePacket) { + if (!isEnabled || !isSessionCreated) return + val packet = interceptablePacket.packet + if (packet !is PlayerAuthInputPacket) return + + // Guaranteed detonation path - runs every tick regardless of placement state. + if (autoAttackCrystal) { + attackNearbyCrystals() + } + + if (step != Step.DONE && System.currentTimeMillis() - stepStartedAt > STEP_TIMEOUT_MS) { + diag("step $step timed out after ${STEP_TIMEOUT_MS}ms with no progress (server likely rejected the placement) - resetting") + resetState() + } + + if (step == Step.DONE) { + val target = findTarget() + if (target != null) { + val found = findValidPlacement(target) + if (found != null) { + placement = found + step = if (usePiston) Step.PISTON else Step.CRYSTAL + tickCounter = 0 + diag("placement found, starting sequence") + } else { + diag("target found (${target.javaClass.simpleName}, dist=${"%.1f".format(target.distance(session.localPlayer))}) but no valid placement. First failure: ${lastPlacementFailureReason}") + } + } else { + val nearby = session.level.entityMap.values.filter { it.distance(session.localPlayer) <= range } + val breakdown = nearby.joinToString("; ") { e -> + val why = when (e) { + is LocalPlayer -> "self" + is Player -> when { + !playersOnly -> "playersOnly=false" + FriendManager.isFriend(e.uuid) -> "is friend" + antiBot && session.level.playerMap[e.uuid] == null -> "antiBot: not in playerMap (uuid=${e.uuid})" + else -> "SHOULD BE VALID?!" + } + is EntityUnknown -> "EntityUnknown(identifier=${e.identifier}), playersOnly=$playersOnly" + else -> "other type: ${e.javaClass.simpleName}" + } + "${e.javaClass.simpleName}@${"%.1f".format(e.distance(session.localPlayer))}m: $why" + } + diag("no target found (range=$range). Nearby entities: $breakdown") + } + } + + if (tickCounter < placeDelayTicks) { + tickCounter++ + return + } + tickCounter = 0 + + advanceStateMachine(packet) + } + + // --- target selection ------------------------------------------------------------------- + + private fun findTarget(): Entity? { + val localPlayer = session.localPlayer + return session.level.entityMap.values + .filter { it.distance(localPlayer) <= range } + .filter { it.isValidTarget() } + .sortedBy { it.distance(localPlayer) } + .firstOrNull() + } + + private fun Entity.isValidTarget(): Boolean { + return when (this) { + is LocalPlayer -> false + is Player -> { + if (!playersOnly) return false + if (FriendManager.isFriend(this.uuid)) return false + if (antiBot && session.level.playerMap[this.uuid] == null) return false + true + } + + is EntityUnknown -> !playersOnly + else -> false + } + } + + // --- placement search (PistonCrystal.cpp: findValidPlacement/calculatePlacement) -------- + + private fun findValidPlacement(target: Entity): Placement? { + val targetPos = target.vec3Position + val targetBlockPos = Vector3i.from(floor(targetPos.x).toInt(), floor(targetPos.y).toInt(), floor(targetPos.z).toInt()) + + val localPlayer = session.localPlayer + val orderedDirs = DIRECTIONS.sortedBy { dir -> + val testPos = Vector3f.from( + targetBlockPos.x + dir.x * 3f, + targetBlockPos.y.toFloat(), + targetBlockPos.z + dir.z * 3f + ) + localPlayer.distance(testPos) + } + + var firstFailureReason: String? = null + for (dir in orderedDirs) { + for (yLevel in 0..1) { + val config = calculatePlacement(targetBlockPos, dir, yLevel) + val reason = isValidPlacementVerbose(config, target) + if (reason == null) { + return config + } else if (firstFailureReason == null) { + firstFailureReason = "dir=$dir yLevel=$yLevel: $reason" + } + } + } + lastPlacementFailureReason = firstFailureReason + return null + } + + private var lastPlacementFailureReason: String? = null + + private fun calculatePlacement(targetPos: Vector3i, dir: Vector3i, yLevel: Int): Placement { + val crystalPos = targetPos.add(dir.x, yLevel, dir.z) + val pistonPos = targetPos.add(dir.x * 2, yLevel, dir.z * 2) + val redstonePos = targetPos.add(dir.x * 3, yLevel, dir.z * 3) + return Placement(crystalPos, pistonPos, redstonePos, dir) + } + + private fun isAir(pos: Vector3i): Boolean { + val identifier = session.level.getBlockAt(pos).identifier + // Many servers use chunk-loading methods this Level doesn't track (blob cache / per- + // subchunk requests), so block state often comes back "minecraft:unknown" rather than a + // confirmed real block - see SurroundModule.canPlaceAt for the same issue, confirmed via + // [SurroundDiag] logging. Treat unknown the same as air for "is there space here" checks; + // isValidCrystalBase below deliberately stays strict since it needs to tell a real + // obsidian/bedrock base apart from "don't know" - our own just-placed piston obsidian is + // already visible there via predictLocalBlockChange by the time it's checked. + return identifier == "minecraft:air" || identifier == "minecraft:unknown" + } + + private fun isValidCrystalBase(pos: Vector3i): Boolean { + val id = session.level.getBlockAt(pos).identifier + return id == OBSIDIAN || id == BEDROCK + } + + private fun canPlaceCrystal(pos: Vector3i): Boolean { + return isAir(pos) && isAir(pos.add(0, 1, 0)) && isValidCrystalBase(pos.add(0, -1, 0)) + } + + /** Loosely matches canBeBuiltOver(): only air here (WClient has no full block-property table). */ + private fun canPlaceBlock(pos: Vector3i): Boolean = isAir(pos) + + private fun isValidPlacement(config: Placement, target: Entity): Boolean = isValidPlacementVerbose(config, target) == null + + /** Same checks as isValidPlacement, but returns *why* it failed (or null if it passed) for diagnostics. */ + private fun isValidPlacementVerbose(config: Placement, target: Entity): String? { + if (!canPlaceCrystal(config.crystalPos)) { + val base = session.level.getBlockAt(config.crystalPos.add(0, -1, 0)).identifier + val spot = session.level.getBlockAt(config.crystalPos).identifier + return "canPlaceCrystal failed (crystal spot=$spot, base below=$base, need air/air/obsidian-or-bedrock)" + } + if (!canPlaceBlock(config.pistonPos)) return "piston spot not air (${session.level.getBlockAt(config.pistonPos).identifier})" + if (!canPlaceBlock(config.redstonePos)) return "redstone spot not air (${session.level.getBlockAt(config.redstonePos).identifier})" + + val crystalCenter = Vector3f.from(config.crystalPos.x + 0.5f, config.crystalPos.y + 0.5f, config.crystalPos.z + 0.5f) + if (target.distance(crystalCenter) > range) return "target too far from crystal spot (${"%.1f".format(target.distance(crystalCenter))} > $range)" + + var estimatedTargetDamage = 0f + var estimatedSelfDamage = 0f + session.level.simulateExplosionDamage( + Vector3f.from(config.crystalPos.x + 0.5f, config.crystalPos.y + 0.5f, config.crystalPos.z + 0.5f), + EXPLOSION_SIZE, + extraEntities = listOf(session.localPlayer) + ) { entity, damage -> + if (entity.runtimeEntityId == target.runtimeEntityId) estimatedTargetDamage = damage + if (entity is LocalPlayer) estimatedSelfDamage = damage + } + if (estimatedTargetDamage < targetDamageMin) return "target damage too low (${"%.1f".format(estimatedTargetDamage)} < $targetDamageMin)" + if (estimatedSelfDamage > selfDamageLimit) return "self damage too high (${"%.1f".format(estimatedSelfDamage)} > $selfDamageLimit)" + + return null + } + + // --- placement sequencing ---------------------------------------------------------------- + // Order matches the PistonCrystal.cpp reference's placementStep (1=piston, 2=crystal, + // 3=redstone): the piston has to already be sitting there, unpowered, *before* the crystal + // spawns next to it - only then does placing the redstone block power it and have it punch + // straight into the crystal. Placing the crystal first (the previous order here) left the + // piston step arriving too late to matter for most servers. + + private fun advanceStateMachine(packet: PlayerAuthInputPacket) { + val config = placement ?: return resetState() + + when (step) { + Step.PISTON -> { + if (!usePiston) { + step = Step.CRYSTAL + return advanceStateMachine(packet) + } + if (place(PISTON, config.pistonPos, packet, lookTowards = config.crystalPos, pushDir = Vector3i.from(-config.dir.x, 0, -config.dir.z))) { + step = Step.CRYSTAL + } + } + + Step.CRYSTAL -> { + if (place(CRYSTAL_ITEM, config.crystalPos, packet, lookTowards = config.crystalPos)) { + step = if (usePiston) Step.REDSTONE else Step.DONE + if (!usePiston) resetState() + } + } + + Step.REDSTONE -> { + if (place(REDSTONE_BLOCK, config.redstonePos, packet, lookTowards = null)) { + resetState() + } + } + + Step.DONE -> {} + } + } + + private fun place( + identifier: String, + pos: Vector3i, + authInput: PlayerAuthInputPacket, + lookTowards: Vector3i?, + pushDir: Vector3i? = null + ): Boolean { + val localPlayer = session.localPlayer + val slot = localPlayer.inventory.searchForItemInHotbar { + it.definition?.identifier == identifier + } + if (slot == null) { + warnMissingItem("§c${itemDisplayName(identifier)}を持っていません!") + return true // don't get stuck retrying forever if we're out of the item + } + + if (oldSlot == -1) { + oldSlot = localPlayer.inventory.heldItemSlot + } + if (localPlayer.inventory.heldItemSlot != slot) { + switchToSlot(slot) + } + + if (fakeRotation && lookTowards != null) { + val eye = localPlayer.vec3Position + val dx = (lookTowards.x + 0.5f) - eye.x + val dz = (lookTowards.z + 0.5f) - eye.z + val yaw = Math.toDegrees(kotlin.math.atan2(-dx.toDouble(), dz.toDouble())).toFloat() + authInput.rotation = Vector3f.from(0f, yaw, yaw) + } + + // The crystal item itself isn't a block (it spawns an entity) - but blockDefinition still + // needs to describe whatever's being clicked (the obsidian/bedrock base), same as any + // other placement. For the crystal specifically we already know pos.add(0,-1,0) is a + // valid obsidian/bedrock base (isValidCrystalBase checked it before we ever got here), so + // reference that directly rather than running the general neighbor search on it. + val (refPos, refFace) = if (identifier == CRYSTAL_ITEM) { + pos.add(0, -1, 0) to 1 + } else { + BlockPlacementUtils.findReferenceBlock(session, pos) + ?: return false // no solid neighbor to click yet - retry next tick + } + // The block being *placed* (piston/redstone_block) - null for the crystal item, which is + // correct: nothing appears in our own block tracking when a crystal entity spawns. + val placedDefinition = BlockPlacementUtils.blockDefinitionFor(session, identifier) + val heldItem = localPlayer.inventory.hand + + val transaction = InventoryTransactionPacket().apply { + transactionType = InventoryTransactionType.ITEM_USE + actionType = 0 + blockPosition = refPos + blockFace = refFace + hotbarSlot = slot + itemInHand = heldItem + playerPosition = localPlayer.vec3Position + clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) + // blockDefinition must always describe the EXISTING block being clicked (refPos) - + // including for the crystal item, which previously left this null entirely. See + // BlockPlacementUtils' class doc for how a real captured packet confirmed this. + blockDefinition = BlockPlacementUtils.referenceBlockDefinition(session, refPos) + actions.add(BlockPlacementUtils.consumeItemAction(slot, heldItem)) + } + BlockPlacementUtils.sendAndLog(session, transaction) + BlockPlacementUtils.predictLocalBlockChange(session, pos, placedDefinition) + return true + } + + /** Bedrock block face indices: 0=down,1=up,2=north,3=south,4=west,5=east */ + private fun faceForDirection(dir: Vector3i): Int { + return when { + dir.x > 0 -> 5 + dir.x < 0 -> 4 + dir.z > 0 -> 3 + dir.z < 0 -> 2 + else -> 1 + } + } + + private fun switchToSlot(slot: Int) { + val packet = PlayerHotbarPacket().apply { + selectedHotbarSlot = slot + containerId = 0 + isSelectHotbarSlot = true + } + // Must go to the real server (this is what tells it which item we're now holding), not + // just update our own local display - sending it clientBound only meant the server never + // learned about the switch, so every placement afterwards referenced a hotbar slot/item + // the server didn't think was selected and rejected it. + session.serverBound(packet) + + // session.serverBound() bypasses the interception pipeline that's the only place + // PlayerInventory.heldItemSlot gets updated, so without this it never changes and + // localPlayer.inventory.hand keeps pointing at the wrong item - see SurroundModule's + // switchToSlot for the full explanation. + session.localPlayer.inventory.predictHeldItemSlot(slot) + } + + private var lastWarnedMessage: String? = null + + /** Warns once per distinct message while the module stays enabled, instead of spamming chat every tick. */ + private fun warnMissingItem(message: String) { + if (lastWarnedMessage == message) return + lastWarnedMessage = message + session.displayClientMessage(message) + } + + private fun itemDisplayName(identifier: String): String = when (identifier) { + PISTON -> "ピストン" + CRYSTAL_ITEM -> "エンドクリスタル" + REDSTONE_BLOCK -> "レッドストーンブロック" + else -> identifier + } + + // --- crystal detonation fallback ---------------------------------------------------------- + + private fun attackNearbyCrystals() { + val localPlayer = session.localPlayer + val now = System.currentTimeMillis() + + session.level.entityMap.values + .filterIsInstance() + .filter { it.identifier == CRYSTAL_ENTITY } + .filter { it.distance(localPlayer) <= range } + .forEach { crystal -> + val last = lastCrystalAttack[crystal.runtimeEntityId] ?: 0L + if (now - last < 100L) return@forEach + lastCrystalAttack[crystal.runtimeEntityId] = now + localPlayer.attack(crystal) + } + } +} diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/combat/SurroundModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/SurroundModule.kt new file mode 100644 index 00000000..9d3f1728 --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/SurroundModule.kt @@ -0,0 +1,309 @@ +package com.retrivedmods.wclient.game.module.combat + +import com.retrivedmods.wclient.game.InterceptablePacket +import com.retrivedmods.wclient.game.BlockPlacementUtils +import com.retrivedmods.wclient.game.Module +import com.retrivedmods.wclient.game.ModuleCategory +import org.cloudburstmc.math.vector.Vector3f +import org.cloudburstmc.math.vector.Vector3i +import org.cloudburstmc.protocol.bedrock.data.inventory.transaction.InventoryTransactionType +import org.cloudburstmc.protocol.bedrock.packet.InventoryTransactionPacket +import org.cloudburstmc.protocol.bedrock.packet.PlayerAuthInputPacket +import org.cloudburstmc.protocol.bedrock.packet.PlayerHotbarPacket +import kotlin.math.floor + +/** + * Ported from the reference Surround.cpp, now using real block data (session.level.getBlockAt) + * instead of blindly firing placements. Follows the same ring-without-corners shape and + * dynamic-expansion idea as the original, adapted to what WClient can actually see: entities + * don't carry a hitbox width/height here, so "dynamic" expansion uses a flat per-entity margin + * check against the ring cells instead of true AABB intersection. + */ +class SurroundModule : Module("surround", ModuleCategory.Combat) { + + private var placeDelayTicks by intValue("place_delay", 1, 0..20) + private var blocksPerTick by intValue("blocks_per_tick", 1, 1..10) + private var airPlace by boolValue("air_place", false) + private var center by boolValue("center", true) + private var dynamic by boolValue("dynamic", true) + private var dynamicMargin by floatValue("dynamic_margin", 0.6f, 0f..2f) + private var placeButton by boolValue("button", true) + private var fakeRotation by boolValue("rotate", false) + + private companion object { + const val OBSIDIAN = "minecraft:obsidian" + const val BUTTON = "minecraft:stone_button" + } + + private var placeList: MutableList = mutableListOf() + private var tickCounter = 0 + private var oldSlot = -1 + private var hasCentered = false + private var surroundDiagTickCounter = 0 + + override fun onEnabled() { + super.onEnabled() + placeList = mutableListOf() + tickCounter = 0 + oldSlot = -1 + hasCentered = false + lastWarnedMessage = null + } + + override fun onDisabled() { + super.onDisabled() + placeList.clear() + if (oldSlot != -1 && isSessionCreated) { + switchToSlot(oldSlot) + } + oldSlot = -1 + hasCentered = false + } + + override fun beforePacketBound(interceptablePacket: InterceptablePacket) { + if (!isEnabled || !isSessionCreated) return + val packet = interceptablePacket.packet + if (packet !is PlayerAuthInputPacket) return + + val localPlayer = session.localPlayer + + if (center && !hasCentered) { + val pos = localPlayer.vec3Position + packet.position = Vector3f.from(floor(pos.x) + 0.5f, pos.y, floor(pos.z) + 0.5f) + hasCentered = true + } + + val obsidianSlot = localPlayer.inventory.searchForItemInHotbar { + it.definition?.identifier == OBSIDIAN + } + + if (obsidianSlot == null) { + warnMissingItem("§c黒曜石を持っていません!") + } + + placeList = computePlaceList() + + run { + surroundDiagTickCounter++ + if (surroundDiagTickCounter % 40 == 0) { + val pos = localPlayer.vec3Position + // floor(pos.y), not floor(pos.y + 0.5f) - that extra 0.5 effectively rounded to + // the nearest block level instead of taking the block the feet actually occupy, + // so it silently referenced a level 1 too high whenever the fractional part of + // pos.y was >= 0.5 (which is most of the time - players are rarely exactly on a + // block boundary). Must match computePlaceList's currentPos below exactly, or + // this diagnostic prints a different ring than the one actually being computed. + val currentPos = Vector3i.from(floor(pos.x).toInt(), floor(pos.y).toInt(), floor(pos.z).toInt()) + val cells = listOf( + "N(0,-1)" to Vector3i.from(0, 0, -1), + "S(0,1)" to Vector3i.from(0, 0, 1), + "W(-1,0)" to Vector3i.from(-1, 0, 0), + "E(1,0)" to Vector3i.from(1, 0, 0), + "center-below(0,0)" to Vector3i.from(0, -1, 0) + ) + val cellDump = cells.joinToString(" | ") { (label, offset) -> + val cellPos = currentPos.add(offset.x, offset.y, offset.z) + val below = currentPos.add(offset.x, offset.y - 1, offset.z) + val cellId = session.level.getBlockAt(cellPos).identifier + val belowId = session.level.getBlockAt(below).identifier + "$label:cell=$cellId,below=$belowId,canPlace=${canPlaceAt(cellPos)}" + } + session.displayClientMessage( + "§b[SurroundDiag] placeList size=${placeList.size}, airPlace=$airPlace, obsidianSlot=$obsidianSlot\n$cellDump" + ) + } + } + + if (fakeRotation && placeList.isNotEmpty()) { + val eye = localPlayer.vec3Position + val target = placeList.first() + val dx = (target.x + 0.5f) - eye.x + val dz = (target.z + 0.5f) - eye.z + val yaw = Math.toDegrees(kotlin.math.atan2(-dx.toDouble(), dz.toDouble())).toFloat() + packet.rotation = Vector3f.from(packet.rotation.x, yaw, yaw) + } + + if (obsidianSlot != null && placeList.isNotEmpty()) { + if (oldSlot == -1) { + oldSlot = localPlayer.inventory.heldItemSlot + } + + if (tickCounter >= placeDelayTicks) { + tickCounter = 0 + if (localPlayer.inventory.heldItemSlot != obsidianSlot) { + switchToSlot(obsidianSlot) + } + + var placed = 0 + val iterator = placeList.iterator() + while (iterator.hasNext() && placed < blocksPerTick) { + val pos = iterator.next() + place(OBSIDIAN, pos, obsidianSlot) + placed++ + } + } else { + tickCounter++ + } + } + + if (placeButton) { + val buttonSlot = localPlayer.inventory.searchForItemInHotbar { + it.definition?.identifier == BUTTON + } + if (buttonSlot == null) { + warnMissingItem("§cボタン(stone_button)を持っていません!") + } else { + val buttonPos = Vector3i.from( + floor(localPlayer.vec3Position.x).toInt(), + floor(localPlayer.vec3Position.y).toInt() - 1, + floor(localPlayer.vec3Position.z).toInt() + ) + place(BUTTON, buttonPos, buttonSlot) + } + } + } + + private var lastWarnedMessage: String? = null + + /** Warns once per distinct message while the module stays enabled, instead of spamming chat every tick. */ + private fun warnMissingItem(message: String) { + if (lastWarnedMessage == message) return + lastWarnedMessage = message + session.displayClientMessage(message) + } + + /** [Surround.cpp]'s canPlaceBlock(): the target itself must be air (or, with airPlace, anything). */ + private fun canPlaceAt(pos: Vector3i): Boolean { + if (airPlace) return true + val identifier = session.level.getBlockAt(pos).identifier + // Many servers use chunk-loading methods this Level doesn't track (blob cache / per- + // subchunk requests), so block state often comes back "minecraft:unknown" rather than a + // confirmed real block - confirmed via [SurroundDiag]: every position read back unknown on + // such a server, which made this always return false and silently disabled placement + // entirely (placeList size was always 0). Treat unknown the same as air - only refuse when + // we positively know a real block is already there. + return identifier == "minecraft:air" || identifier == "minecraft:unknown" + } + + private fun computePlaceList(): MutableList { + val localPlayer = session.localPlayer + val pos = localPlayer.vec3Position + // floor(pos.y), not floor(pos.y + 0.5f) - see the diagnostic block above for why the +0.5 + // was wrong (silently referenced a level 1 too high most of the time). + val currentPos = Vector3i.from(floor(pos.x).toInt(), floor(pos.y).toInt(), floor(pos.z).toInt()) + + var xStart = -1 + var zStart = -1 + var xEnd = 1 + var zEnd = 1 + + if (dynamic) { + session.level.entityMap.values.forEach { entity -> + val d = entity.distance(pos) + if (d > 4f) return@forEach + val dx = entity.posX - pos.x + val dz = entity.posZ - pos.z + if (dx <= xStart + 1 + dynamicMargin && dx >= xStart - dynamicMargin) xStart -= 1 + if (dz <= zStart + 1 + dynamicMargin && dz >= zStart - dynamicMargin) zStart -= 1 + if (dx >= xEnd - 1 - dynamicMargin && dx <= xEnd + dynamicMargin) xEnd += 1 + if (dz >= zEnd - 1 - dynamicMargin && dz <= zEnd + dynamicMargin) zEnd += 1 + } + } + + val result = mutableListOf() + for (x in xStart..xEnd) { + for (z in zStart..zEnd) { + // skip the 4 corners, matching Surround.cpp's ring-without-corners shape + if ((x == xStart || x == xEnd) && (z == zStart || z == zEnd)) continue + + if (x > xStart && x < xEnd && z > zStart && z < zEnd) { + // strictly interior cell (only reachable once dynamic expansion has grown the + // box past the base 3x3): floor it in, one level down + val placePos = currentPos.add(x, -1, z) + if (canPlaceAt(placePos)) result.add(placePos) + continue + } + + val placePos = currentPos.add(x, 0, z) + val below = currentPos.add(x, -1, z) + // only bother with the "wall" cell if there's solid ground for it to stand on + if (session.level.getBlockAt(below).identifier == "minecraft:air" && !airPlace) continue + + if (canPlaceAt(placePos)) { + result.add(placePos) + } else if (canPlaceAt(below)) { + result.add(below) + } + } + } + + result.sortBy { it.distanceSq(currentPos) } + return result + } + + private fun Vector3i.distanceSq(other: Vector3i): Int { + val dx = x - other.x + val dy = y - other.y + val dz = z - other.z + return dx * dx + dy * dy + dz * dz + } + + private fun place(identifier: String, pos: Vector3i, slot: Int) { + val localPlayer = session.localPlayer + val (refPos, refFace) = BlockPlacementUtils.findReferenceBlock(session, pos) + ?: return // no solid neighbor to click yet - skip this cell, the ring pass will retry it + val heldItem = localPlayer.inventory.hand + + val packet = InventoryTransactionPacket().apply { + transactionType = InventoryTransactionType.ITEM_USE + actionType = 0 + blockPosition = refPos + blockFace = refFace + hotbarSlot = slot + itemInHand = heldItem + playerPosition = localPlayer.vec3Position + clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) + // blockDefinition must describe the EXISTING block being clicked (refPos), not the + // item being placed - see BlockPlacementUtils' class doc for how a real captured + // packet confirmed this. + blockDefinition = BlockPlacementUtils.referenceBlockDefinition(session, refPos) + actions.add(BlockPlacementUtils.consumeItemAction(slot, heldItem)) + } + session.serverBound(packet) + // Deliberately NOT calling BlockPlacementUtils.predictLocalBlockChange() here (unlike + // PistonCrystalModule, which needs it for its piston->crystal->redstone sequencing). + // Surround recomputes its whole ring from scratch every tick anyway, so there's no + // sequencing need for an immediate local update - and doing it unconditionally caused a + // real bug: since Bedrock servers silently ignore/reject invalid transactions (no NACK + // packet), if a placement got rejected, the optimistic prediction would still mark that + // cell as "already obsidian" in our own world model forever, with nothing to ever correct + // it - so canPlaceAt() kept refusing to retry a spot that, on the real server, still had + // nothing there. Letting the real UpdateBlockPacket (handled in Level.kt) be the only + // source of truth means a rejected placement is naturally retried next tick instead of + // being permanently (and incorrectly) considered done. + } + + private fun switchToSlot(slot: Int) { + val packet = PlayerHotbarPacket().apply { + selectedHotbarSlot = slot + containerId = 0 + isSelectHotbarSlot = true + } + // Must go to the real server (this is what tells it which item we're now holding), not + // just update our own local display - sending it clientBound only meant the server never + // learned about the switch, so every placement afterwards referenced a hotbar slot/item + // the server didn't think was selected and rejected it. + session.serverBound(packet) + + // session.serverBound() bypasses the normal interception pipeline entirely (see + // GameSession/WRelaySession), which is the ONLY place PlayerInventory.heldItemSlot gets + // updated (it only listens for packets that pass through there, i.e. the real client's + // own traffic). Without this, heldItemSlot silently never changes, so + // localPlayer.inventory.hand (= content[heldItemSlot]) kept pointing at whatever was + // selected before Surround/PistonCrystal ever ran - meaning every placement packet's + // itemInHand didn't actually match its own hotbarSlot, which is exactly the kind of + // mismatch a server's inventory validation rejects outright. Predict it locally, the same + // way a real client's own selection updates immediately without waiting on a round trip. + session.localPlayer.inventory.predictHeldItemSlot(slot) + } +} diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/misc/AutoDisconnectModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/misc/AutoDisconnectModule.kt index 78b4d841..f3c8a36c 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/module/misc/AutoDisconnectModule.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/misc/AutoDisconnectModule.kt @@ -1,5 +1,7 @@ package com.retrivedmods.wclient.game.module.misc +import com.retrivedmods.wclient.util.setPacketField + import com.retrivedmods.wclient.game.Module import com.retrivedmods.wclient.game.ModuleCategory import com.retrivedmods.wclient.game.InterceptablePacket @@ -36,7 +38,7 @@ class AutoDisconnectModule : Module("auto_disconnect", ModuleCategory.Misc) { private fun disconnectPlayer(currentHealth: Int) { val message = "§cAutoDisconnected at $currentHealth HP" val disconnectPacket = DisconnectPacket().apply { - kickMessage = message + setPacketField("kickMessage", message) } session.clientBound(disconnectPacket) diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/misc/PacketLoggerModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/misc/PacketLoggerModule.kt new file mode 100644 index 00000000..1f7194f1 --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/misc/PacketLoggerModule.kt @@ -0,0 +1,75 @@ +package com.retrivedmods.wclient.game.module.misc + +import com.retrivedmods.wclient.game.InterceptablePacket +import com.retrivedmods.wclient.game.Module +import com.retrivedmods.wclient.game.ModuleCategory +import com.retrivedmods.wclient.util.PacketDebugLog +import com.retrivedmods.wclient.util.setPacketField +import org.cloudburstmc.protocol.bedrock.data.inventory.transaction.InventoryTransactionType +import org.cloudburstmc.protocol.bedrock.packet.InventoryTransactionPacket +import org.cloudburstmc.protocol.bedrock.packet.TextPacket + +/** + * Debug tool: prints the fields of every ITEM_USE (block place) InventoryTransactionPacket to + * chat, tagged by where it came from: + * - [PlaceLog] - real packets the actual Minecraft client sends, e.g. when you manually place a + * block by hand, seen here via the normal beforePacketBound intercept pipeline. + * - [AutoPlaceLog] - packets our own modules (PistonCrystalModule/SurroundModule, via + * LocalPlayer.placeBlock) send directly with session.serverBound(...), which bypass that + * pipeline - these come through PacketDebugLog instead, toggled on/off by this module. + * Enable this, place one block yourself and let an auto-place module try one too, then compare + * the two logs field by field. + */ +class PacketLoggerModule : Module("packet_logger", ModuleCategory.Misc) { + + private var logPlacements by boolValue("log_placements", true) + + override fun onEnabled() { + super.onEnabled() + PacketDebugLog.enabled = true + } + + override fun onDisabled() { + super.onDisabled() + PacketDebugLog.enabled = false + } + + override fun beforePacketBound(interceptablePacket: InterceptablePacket) { + if (!isEnabled || !logPlacements) return + + val packet = interceptablePacket.packet + if (packet is InventoryTransactionPacket && + packet.transactionType == InventoryTransactionType.ITEM_USE && + packet.actionType == 0 + ) { + logPlacement(packet) + } + } + + private fun logPlacement(packet: InventoryTransactionPacket) { + val msg = buildString { + append("§l§b[PlaceLog]§r\n") + append("§eblockPosition: §f${packet.blockPosition}\n") + append("§eblockFace: §f${packet.blockFace}\n") + append("§eblockDefinition: §f${packet.blockDefinition}\n") + append("§eclickPosition: §f${packet.clickPosition}\n") + append("§eplayerPosition: §f${packet.playerPosition}\n") + append("§eheadPosition: §f${packet.headPosition}\n") + append("§ehotbarSlot: §f${packet.hotbarSlot}\n") + append("§eitemInHand: §f${packet.itemInHand}\n") + append("§eactions: §f${packet.actions}") + } + sendMessage(msg) + } + + private fun sendMessage(msg: String) { + val textPacket = TextPacket().apply { + type = TextPacket.Type.RAW + setPacketField("needsTranslation", false) + setPacketField("message", msg) + xuid = "" + sourceName = "" + } + session.clientBound(textPacket) + } +} diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/misc/PositionLoggerModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/misc/PositionLoggerModule.kt index 3dabbd97..d62a3c9a 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/module/misc/PositionLoggerModule.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/misc/PositionLoggerModule.kt @@ -1,5 +1,7 @@ package com.retrivedmods.wclient.game.module.misc +import com.retrivedmods.wclient.util.setPacketField + import com.retrivedmods.wclient.game.InterceptablePacket import com.retrivedmods.wclient.game.Module import com.retrivedmods.wclient.game.ModuleCategory @@ -174,8 +176,8 @@ class PositionLoggerModule : Module("position_logger", ModuleCategory.Misc) { private fun sendMessage(msg: String) { val textPacket = TextPacket().apply { type = TextPacket.Type.RAW - isNeedsTranslation = false - message = msg + setPacketField("needsTranslation", false) + setPacketField("message", msg) xuid = "" sourceName = "" } diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/misc/SpammerModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/misc/SpammerModule.kt index d5817f2f..08ede466 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/module/misc/SpammerModule.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/misc/SpammerModule.kt @@ -1,5 +1,7 @@ package com.retrivedmods.wclient.game.module.misc +import com.retrivedmods.wclient.util.setPacketField + import android.util.Log import com.retrivedmods.wclient.game.InterceptablePacket import com.retrivedmods.wclient.game.Module @@ -86,10 +88,10 @@ class SpammerModule : Module("Spammer", ModuleCategory.Misc) { val textPacket = TextPacket() textPacket.type = TextPacket.Type.CHAT textPacket.sourceName = "" - textPacket.message = messageToSend + textPacket.setPacketField("message", messageToSend) textPacket.xuid = "" textPacket.platformChatId = "" - textPacket.needsTranslation = false + textPacket.setPacketField("needsTranslation", false) session.serverBound(textPacket) diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/motion/PlayerTPModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/motion/PlayerTPModule.kt index 8ea16eec..aa959aed 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/module/motion/PlayerTPModule.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/motion/PlayerTPModule.kt @@ -1,5 +1,7 @@ package com.retrivedmods.wclient.game.module.motion +import com.retrivedmods.wclient.util.setPacketField + import com.retrivedmods.wclient.game.InterceptablePacket import com.retrivedmods.wclient.game.Module import com.retrivedmods.wclient.game.ModuleCategory @@ -88,7 +90,7 @@ class PlayerTPModule : Module("PlayerTP", ModuleCategory.Motion) { Vector3f.from(derpYaw, derpPitch, 0f) else player.vec3Rotation mode = MovePlayerPacket.Mode.NORMAL - onGround = false + setPacketField("onGround", false) ridingRuntimeEntityId = 0 tick = player.tickExists }) diff --git a/app/src/main/java/com/retrivedmods/wclient/game/module/world/FreeCameraModule.kt b/app/src/main/java/com/retrivedmods/wclient/game/module/world/FreeCameraModule.kt index b84798de..682994f4 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/module/world/FreeCameraModule.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/world/FreeCameraModule.kt @@ -1,5 +1,7 @@ package com.retrivedmods.wclient.game.module.world +import com.retrivedmods.wclient.util.setPacketField + import com.retrivedmods.wclient.game.InterceptablePacket import com.retrivedmods.wclient.game.Module import com.retrivedmods.wclient.game.ModuleCategory @@ -118,7 +120,7 @@ class FreeCameraModule : Module("free_camera", ModuleCategory.World) { private fun sendCountdownMessage(message: String) { val textPacket = TextPacket().apply { type = TextPacket.Type.RAW - this.message = message + setPacketField("message", message) xuid = "" sourceName = "" } diff --git a/app/src/main/java/com/retrivedmods/wclient/game/registry/BlockMapping.kt b/app/src/main/java/com/retrivedmods/wclient/game/registry/BlockMapping.kt index ca25e72a..82aefcd8 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/registry/BlockMapping.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/registry/BlockMapping.kt @@ -26,7 +26,69 @@ class BlockMapping( return definition is UnknownBlockDefinition || getDefinition(definition.runtimeId) == definition } + /** + * Returns the first runtime id whose identifier matches [identifier] (e.g. "minecraft:obsidian"). + * Block states with extra properties share the same base identifier, so this returns + * the default/first state found, which is fine for simple full blocks like obsidian. + */ + fun getRuntimeIdByIdentifier(identifier: String): Int? { + return runtimeToGameMap.entries.firstOrNull { it.value.identifier == identifier }?.key + } + + /** + * Runtime id of "minecraft:air", used by chunk/block storage as the default/empty block. + * Falls back to 0 if not found (shouldn't happen with a valid mapping). + */ + val airId: Int by lazy { getRuntimeIdByIdentifier("minecraft:air") ?: 0 } + companion object { + /** + * Builds a BlockMapping straight from the server's own StartGamePacket block palette, + * instead of a bundled per-version asset file. + * + * The runtime id is NOT simply an entry's position in [palette] as sent by the server + * (that was our original, wrong assumption here), nor does BlockPropertyData carry an + * explicit runtime id field at all (confirmed against the real bedrock-codec source - + * it's just `name: String` + `properties: NbtMap`). Since Minecraft 1.18.30, the real + * runtime id assignment is: sort every block identifier by its FNV-1a 64-bit hash, then + * assign sequential ids 0, 1, 2... in THAT sorted order. This is a real, documented + * Bedrock protocol algorithm (see https://gist.github.com/SupremeMortal/5e09c8b0eb6b3a30439b317b875bc29c), + * confirmed against ProtoHax's own working BlockMapping (HashedPaletteComparator) - not + * something invented here. Getting this wrong means every single runtime id lookup is + * silently wrong, which would explain every block reading back as "unknown". + */ + fun fromPalette(palette: List): BlockMapping { + val runtimeToBlock = mutableMapOf() + palette + .sortedWith(compareBy(FnvHashComparator) { it.name }) + .forEachIndexed { index, entry -> + runtimeToBlock[index] = BlockDefinition(index, entry.name) + } + return BlockMapping(runtimeToBlock) + } + + /** + * FNV-1a 64-bit hash comparator for block identifier strings, matching the real Bedrock + * palette-ordering algorithm (see fromPalette() above). Compared as unsigned 64-bit values. + */ + private object FnvHashComparator : Comparator { + private const val FNV1_64_INIT = -0x340d631b7bdddcdbL + private const val FNV1_PRIME_64 = 1099511628211L + + override fun compare(a: String, b: String): Int { + return java.lang.Long.compareUnsigned(hash(a), hash(b)) + } + + private fun hash(value: String): Long { + var hash = FNV1_64_INIT + for (byte in value.toByteArray(Charsets.UTF_8)) { + hash *= FNV1_PRIME_64 + hash = hash xor (byte.toInt() and 0xff).toLong() + } + return hash + } + } + fun read(context: Context, version: Short): BlockMapping { val path = "mcpedata/blocks/runtime_block_states_$version.dat" context.assets.open(path).use { stream -> diff --git a/app/src/main/java/com/retrivedmods/wclient/game/world/Level.kt b/app/src/main/java/com/retrivedmods/wclient/game/world/Level.kt index ed20d4e8..2b0e7c8c 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/world/Level.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/world/Level.kt @@ -5,16 +5,30 @@ import com.retrivedmods.wclient.game.entity.Entity import com.retrivedmods.wclient.game.entity.EntityUnknown import com.retrivedmods.wclient.game.entity.Item import com.retrivedmods.wclient.game.entity.Player +import com.retrivedmods.wclient.game.registry.BlockDefinition +import com.retrivedmods.wclient.game.registry.UnknownBlockDefinition +import com.retrivedmods.wclient.game.world.chunk.Chunk import org.cloudburstmc.protocol.bedrock.packet.AddEntityPacket import org.cloudburstmc.protocol.bedrock.packet.AddItemEntityPacket import org.cloudburstmc.protocol.bedrock.packet.AddPlayerPacket import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket +import org.cloudburstmc.protocol.bedrock.packet.ChangeDimensionPacket +import org.cloudburstmc.protocol.bedrock.packet.ChunkRadiusUpdatedPacket +import org.cloudburstmc.protocol.bedrock.packet.ClientCacheBlobStatusPacket +import org.cloudburstmc.protocol.bedrock.packet.ClientCacheMissResponsePacket +import org.cloudburstmc.protocol.bedrock.packet.LevelChunkPacket import org.cloudburstmc.protocol.bedrock.packet.PlayerListPacket import org.cloudburstmc.protocol.bedrock.packet.RemoveEntityPacket import org.cloudburstmc.protocol.bedrock.packet.StartGamePacket +import org.cloudburstmc.protocol.bedrock.packet.SubChunkPacket import org.cloudburstmc.protocol.bedrock.packet.TakeItemEntityPacket +import org.cloudburstmc.protocol.bedrock.packet.UpdateBlockPacket +import org.cloudburstmc.protocol.bedrock.packet.UpdateSubChunkBlocksPacket +import org.cloudburstmc.math.vector.Vector3f +import org.cloudburstmc.math.vector.Vector3i import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import kotlin.math.pow @Suppress("MemberVisibilityCanBePrivate") class Level(val session: GameSession) { @@ -23,9 +37,44 @@ class Level(val session: GameSession) { val playerMap = ConcurrentHashMap() + // --- world/chunk block tracking ----------------------------------------------------------- + // Ported (with real changes, see Chunk/ChunkSection/BlockStorage) from ProtoHax. Handles the + // "normal" full LevelChunkPacket path, the newer per-subchunk request system (SubChunkPacket), + // and blob-cache chunk loading (via ClientCacheMissResponsePacket, see pendingCacheBlobs + // below) - so real block data is tracked regardless of which of the three loading methods a + // given server uses. UpdateBlockPacket (single block changes) and UpdateSubChunkBlocksPacket + // (batch changes - explosions, redstone, etc.) are both handled, so blocks placed/broken + // after a chunk loads stay accurate. + + val chunks = ConcurrentHashMap() + + /** + * Blob-cache loading (LevelChunkPacket.isCachingEnabled) doesn't put block data directly in + * the LevelChunkPacket - instead it lists content-hash IDs (one per subchunk, plus one for + * biome data, in bottom-to-top order) and the real client separately negotiates with the + * server over which hashes it already has cached (ClientCacheBlobStatusPacket) vs needs sent + * (ClientCacheMissResponsePacket, hash -> raw payload). As a relay we don't need to run that + * negotiation ourselves - the real client does it - we just need to passively watch + * ClientCacheMissResponsePacket go by and match each hash back to the (chunk, section) it + * belongs to, which we recorded here when the LevelChunkPacket first listed it. A hash that + * was already in the real client's own cache never triggers a miss response and so never + * arrives at all - that subchunk just silently stays untracked (same as any other subchunk we + * haven't seen data for yet), which is a value-if-known / not-an-error position, matching + * every other gap in this tracking (unloaded chunks, servers that don't use this feature). + */ + private val pendingCacheBlobs = ConcurrentHashMap>() // blobId -> (chunkHash, sectionIndex); sectionIndex == -1 means "biome blob, ignore" + + var is384WorldSupported = false + private set + + var viewDistance = -1 + private set + fun onDisconnect() { entityMap.clear() playerMap.clear() + chunks.clear() + pendingCacheBlobs.clear() } fun onPacketBound(packet: BedrockPacket) { @@ -33,6 +82,159 @@ class Level(val session: GameSession) { is StartGamePacket -> { entityMap.clear() playerMap.clear() + chunks.clear() + + is384WorldSupported = try { + // 384 height world was introduced in Minecraft 1.18 + val parts = packet.vanillaVersion.split(".") + parts.size >= 2 && parts[0] == "1" && (parts[1].toIntOrNull() ?: 0) >= 18 + } catch (e: Exception) { + true + } + } + + is LevelChunkPacket -> { + if (!session.isBlockMappingInitialized) { + // shouldn't normally happen (StartGamePacket sets blockMapping before Level + // sees it), but guard anyway since a missing mapping would crash chunk parsing + return + } + + val chunk = Chunk(packet.chunkX, packet.chunkZ, is384WorldSupported, session.blockMapping) + try { + if (packet.isCachingEnabled) { + // No raw data here - record which (chunk, section) each listed hash is + // for, then wait for ClientCacheMissResponsePacket (below) to supply the + // actual bytes for whichever ones the real client didn't already have + // cached. Per protocol, blobIds is subChunksLength subchunk hashes + // (bottom-to-top) followed by exactly one biome-data hash - we only care + // about the former. + packet.blobIds.forEachIndexed { index, blobId -> + val sectionIndex = if (index < packet.subChunksLength) index else -1 + pendingCacheBlobs[blobId] = chunk.hash to sectionIndex + } + } else if (!packet.isRequestSubChunks) { + // duplicate() gives us an independent reader index over the same underlying + // memory (refcount shared with the original packet), so parsing here can + // never disturb packet.data's own reader index / the relay's forwarding of + // the real packet to the client. + val buf = packet.data.duplicate() + chunk.read(buf, packet.subChunksLength) + } + // Either way, register the (possibly still-empty) chunk now: both + // isCachingEnabled and isRequestSubChunks mean the actual block data streams + // in afterwards (via ClientCacheMissResponsePacket or SubChunkPacket + // respectively), which needs a Chunk already sitting in the map to attach its + // sections to. + chunks[chunk.hash] = chunk + } catch (e: Exception) { + // malformed/unexpected chunk data for this protocol version - skip it rather + // than crash the relay + } + } + + is ClientCacheBlobStatusPacket -> { + // Rewrite what the real client reports having cached (acks) into "don't have it" + // (naks) before this goes on to the server. Without this, any blob the real + // client's on-device cache already had from a previous session never gets resent + // at all - the bytes never cross the network, so there's nothing for us to + // passively observe no matter what ClientCacheMissResponsePacket handling we add. + // Forcing every ack into a nak makes the server treat everything as a cache miss + // and resend full data every time, which we can then always track via + // ClientCacheMissResponsePacket above. The real client doesn't notice or care - + // it just receives fresh data instead of using its local cache; functionally + // identical, just slightly less bandwidth-efficient. + if (packet.acks.isNotEmpty()) { + packet.naks.addAll(packet.acks) + packet.acks.clear() + } + } + + is ClientCacheMissResponsePacket -> { + if (!session.isBlockMappingInitialized) return + + packet.blobs.forEach { (blobId, data) -> + val target = pendingCacheBlobs.remove(blobId) ?: return@forEach + val (chunkHash, sectionIndex) = target + if (sectionIndex < 0) return@forEach // biome blob - nothing for us to parse + + try { + val chunk = chunks[chunkHash] ?: return@forEach + chunk.readSubChunk(sectionIndex, data.duplicate()) + } catch (e: Exception) { + // same reasoning as the LevelChunkPacket catch above - skip, don't crash + } + } + } + + is SubChunkPacket -> { + if (!session.isBlockMappingInitialized) return + + val center = packet.centerPosition + packet.subChunks.forEach { subChunkData -> + try { + // Only bother parsing entries that actually carry block data. We don't + // depend on the exact SubChunkRequestResult enum name/value here (its + // constants weren't confirmed) - an empty/absent buffer is a reliable + // enough signal that there's nothing to parse for this one. + val data = subChunkData.data ?: return@forEach + if (data.readableBytes() <= 0) return@forEach + + val offset = subChunkData.position + val chunkX = center.x + offset.x + val chunkZ = center.z + offset.z + // centerPosition.y is the signed index of the reference (usually bottom) + // section; offset.y shifts from there. Our own Chunk.sectionStorage is a + // plain 0-based array, so for a 384-world (24 sections, floor at y=-64) we + // shift by +4 to land the lowest legal signed index (-4) on array index 0. + // For the classic 256-world (16 sections, y starts at 0) signed indices are + // already 0-based, so no shift is needed. + val sectionIndex = (center.y + offset.y) + (if (is384WorldSupported) 4 else 0) + + val chunk = chunks.getOrPut(Chunk.hash(chunkX, chunkZ)) { + Chunk(chunkX, chunkZ, is384WorldSupported, session.blockMapping) + } + + val buf = data.duplicate() + chunk.readSubChunk(sectionIndex, buf) + } catch (e: Exception) { + // same reasoning as the LevelChunkPacket catch above - skip, don't crash + } + } + } + + is UpdateBlockPacket -> { + if (packet.dataLayer == 0) { + setBlockIdAt( + packet.blockPosition.x, + packet.blockPosition.y, + packet.blockPosition.z, + packet.definition.runtimeId + ) + } + } + + is UpdateSubChunkBlocksPacket -> { + // Batch block-change variant of UpdateBlockPacket - the server uses this for + // several simultaneous changes in one subchunk (explosions, redstone, pistons, + // etc.) instead of one UpdateBlockPacket per block. We had no handler for this at + // all before, so any such batch change left our tracked world state stale until + // the whole chunk happened to reload. Each entry already carries an absolute world + // position, same as UpdateBlockPacket.blockPosition. Only standardBlocks (the + // primary layer) is applied, matching how UpdateBlockPacket above only acts on + // dataLayer == 0 - extraBlocks is the secondary/waterlogged layer we don't track. + packet.standardBlocks.forEach { entry -> + val pos = entry.position + setBlockIdAt(pos.x, pos.y, pos.z, entry.definition.runtimeId) + } + } + + is ChunkRadiusUpdatedPacket -> { + viewDistance = packet.radius + } + + is ChangeDimensionPacket -> { + chunks.clear() } is AddEntityPacket -> { @@ -100,4 +302,109 @@ class Level(val session: GameSession) { } } + /** + * Approximates vanilla explosion damage falloff for every tracked entity (and any [extraEntities] + * not currently in [entityMap], e.g. the local player) around [center]. + * + * This does NOT account for block occlusion/exposure (WClient has no local world/chunk state to + * raycast against), so it always assumes full exposure (1.0). Real in-game damage will be lower + * whenever blocks are between the explosion and the target. Treat the result as an upper-bound + * estimate for target/placement selection, not an exact value. + */ + fun simulateExplosionDamage( + center: Vector3f, + size: Float, + extraEntities: List = emptyList(), + damageCallback: (Entity, Float) -> Unit + ) { + val searchRadiusSq = (size * 2).pow(2) + + fun evaluate(entity: Entity) { + val distSq = entity.distanceSq(center) + if (distSq >= searchRadiusSq) return + + val distance = entity.distance(center) / size + if (distance <= 1f) { + val impact = 1f - distance + val damage = ((impact * impact + impact) / 2f) * 8f * size + 1f + damageCallback(entity, damage) + } + } + + entityMap.values.forEach(::evaluate) + extraEntities.forEach(::evaluate) + } + + // --- block query/update helpers ----------------------------------------------------------- + + fun getChunkAt(chunkX: Int, chunkZ: Int): Chunk? = chunks[Chunk.hash(chunkX, chunkZ)] + + fun isChunkLoaded(x: Int, z: Int): Boolean = chunks.containsKey(Chunk.hash(x shr 4, z shr 4)) + + /** + * Runtime id of the block at the given world coordinates, or the air runtime id if the + * containing chunk hasn't been loaded/tracked (see the notes on the LevelChunkPacket handling + * above for when that can happen). + */ + fun getBlockIdAt(x: Int, y: Int, z: Int): Int { + val chunk = getChunkAt(x shr 4, z shr 4) + ?: return if (session.isBlockMappingInitialized) session.blockMapping.airId else 0 + return chunk.getBlockAt(x and 0x0f, y, z and 0x0f) + } + + fun getBlockIdAt(pos: Vector3i): Int = getBlockIdAt(pos.x, pos.y, pos.z) + + fun getBlockAt(x: Int, y: Int, z: Int): BlockDefinition { + if (!session.isBlockMappingInitialized) return UnknownBlockDefinition(0) + return session.blockMapping.getDefinition(getBlockIdAt(x, y, z)) + } + + fun getBlockAt(pos: Vector3i): BlockDefinition = getBlockAt(pos.x, pos.y, pos.z) + + fun setBlockIdAt(x: Int, y: Int, z: Int, runtimeId: Int) { + val chunk = getChunkAt(x shr 4, z shr 4) ?: return + chunk.setBlockAt(x and 0x0f, y, z and 0x0f, runtimeId) + } + + fun isAir(x: Int, y: Int, z: Int): Boolean = getBlockAt(x, y, z).identifier == "minecraft:air" + + fun isAir(pos: Vector3i): Boolean = isAir(pos.x, pos.y, pos.z) + + /** + * Bedrock places a new block adjacent to whatever *existing* block you "click" - not directly + * at the position you name - so block-placing modules (Surround, PistonCrystal, ...) need to + * find a real, currently non-air neighbor of [pos] to click, and which face of that neighbor + * points back at [pos]. Checks straight down first (the common "place on the ground" case), + * then up, then the four horizontal neighbors. + * + * Returns null if [pos] itself isn't currently air (already occupied, or the chunk/subchunk + * simply isn't tracked yet - see the LevelChunkPacket/ClientCacheMissResponsePacket handling + * notes above for the ways that can happen) or if none of its neighbors are known to be + * solid, e.g. floating in open air. + * + * @return (position of the block to click, Bedrock face index of that block to click: + * 0=down,1=up,2=north,3=south,4=west,5=east) or null + */ + fun findPlacementReference(pos: Vector3i): Pair? { + if (!isAir(pos)) return null + + // (offset to the neighboring block, face of THAT block which points back at pos) + val candidates = listOf( + Vector3i.from(0, -1, 0) to 1, // below -> click its UP face + Vector3i.from(0, 1, 0) to 0, // above -> click its DOWN face + Vector3i.from(1, 0, 0) to 4, // east -> click its WEST face + Vector3i.from(-1, 0, 0) to 5, // west -> click its EAST face + Vector3i.from(0, 0, 1) to 2, // south -> click its NORTH face + Vector3i.from(0, 0, -1) to 3 // north -> click its SOUTH face + ) + + for ((offset, face) in candidates) { + val neighborPos = Vector3i.from(pos.x + offset.x, pos.y + offset.y, pos.z + offset.z) + if (!isAir(neighborPos)) { + return neighborPos to face + } + } + return null + } + } \ No newline at end of file diff --git a/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/BlockStorage.kt b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/BlockStorage.kt new file mode 100644 index 00000000..2c16159d --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/BlockStorage.kt @@ -0,0 +1,107 @@ +package com.retrivedmods.wclient.game.world.chunk + +import com.retrivedmods.wclient.game.world.chunk.palette.BitArray +import com.retrivedmods.wclient.game.world.chunk.palette.BitArrayVersion +import io.netty.buffer.ByteBuf +import org.cloudburstmc.protocol.common.util.VarInts + +/** + * Ported from ProtoHax (dev.sora.relay.game.world.chunk.BlockStorage), adapted for WClient: + * - uses a plain MutableList instead of fastutil's IntArrayList (WClient doesn't pull in the + * int-list fastutil artifact, only long/int-object map variants), to avoid an extra dependency. + * - only supports the runtime-id palette format (the "isRuntime" branch). Real Bedrock servers + * always send LevelChunkPacket/SubChunkPacket over the network using runtime ids, never the + * persistent NBT-tag palette (that format is only used in world save files), so this covers + * every case WClient - a live network relay - actually needs to handle. + */ +class BlockStorage { + + var bitArray: BitArray + var palette: MutableList + + constructor(airId: Int, version: BitArrayVersion = BitArrayVersion.V2) { + bitArray = version.createPalette(MAX_BLOCK_IN_SECTION) + palette = mutableListOf(airId) + } + + constructor(buf: ByteBuf, network: Boolean) { + val paletteHeader = buf.readByte().toInt() + val isRuntime = (paletteHeader and 1) == 1 + if (!isRuntime) { + throw UnsupportedOperationException( + "persistent (NBT tag) block palettes are not supported, only runtime-id palettes" + ) + } + + val paletteVersion = paletteHeader or 1 shr 1 + val bitArrayVersion = BitArrayVersion.get(paletteVersion, true) + + bitArray = bitArrayVersion.createPalette(MAX_BLOCK_IN_SECTION) + + for (i in bitArray.words.indices) { + bitArray.words[i] = buf.readIntLE() + } + + fun readInt(): Int = if (network) VarInts.readInt(buf) else buf.readIntLE() + + val paletteSize = readInt() + palette = ArrayList(paletteSize) + for (i in 0 until paletteSize) { + palette.add(readInt()) + } + } + + private fun getIndex(x: Int, y: Int, z: Int): Int { + return x shl 8 or (z shl 4) or y + } + + fun setBlock(x: Int, y: Int, z: Int, runtimeId: Int) { + this.setBlock(getIndex(x, y, z), runtimeId) + } + + fun getByIndex(index: Int): Int { + return palette[bitArray[index]] + } + + fun getBlock(x: Int, y: Int, z: Int): Int { + return getByIndex(getIndex(x, y, z)) + } + + fun setBlock(index: Int, runtimeId: Int) { + try { + val id = idFor(runtimeId) + bitArray[index] = id + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Unable to set block runtime ID: $runtimeId, palette: $palette", e) + } + } + + private fun onResize(version: BitArrayVersion) { + val newBitArray = version.createPalette(MAX_BLOCK_IN_SECTION) + for (i in 0 until MAX_BLOCK_IN_SECTION) { + newBitArray[i] = bitArray[i] + } + bitArray = newBitArray + } + + private fun idFor(runtimeId: Int): Int { + var index = palette.indexOf(runtimeId) + if (index != -1) { + return index + } + index = palette.size + val version = bitArray.version + if (index > version.maxEntryValue) { + val next = version.next() + if (next != null) { + onResize(next) + } + } + palette.add(runtimeId) + return index + } + + companion object { + const val MAX_BLOCK_IN_SECTION = 4096 + } +} diff --git a/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/Chunk.kt b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/Chunk.kt new file mode 100644 index 00000000..5439228e --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/Chunk.kt @@ -0,0 +1,66 @@ +package com.retrivedmods.wclient.game.world.chunk + +import com.retrivedmods.wclient.game.registry.BlockMapping +import io.netty.buffer.ByteBuf +import kotlin.math.abs + +/** + * Ported from ProtoHax (dev.sora.relay.game.world.chunk.Chunk), adapted for WClient's own + * BlockMapping type. + */ +class Chunk( + val x: Int, + val z: Int, + val is384World: Boolean, + private val blockMapping: BlockMapping +) { + + var loadedAt = System.currentTimeMillis() + private set + + val hash: Long + get() = hash(x, z) + + val sectionStorage = Array(if (is384World) 24 else 16) { ChunkSection(blockMapping) } + val maximumHeight = sectionStorage.size * 16 + + fun isInRadius(playerChunkX: Int, playerChunkZ: Int, radius: Int): Boolean { + return abs(x - playerChunkX) <= radius && abs(z - playerChunkZ) <= radius + } + + fun read(buf: ByteBuf, subChunks: Int) { + repeat(subChunks) { + readSubChunk(it, buf) + } + } + + fun readSubChunk(index: Int, buf: ByteBuf) { + loadedAt = System.currentTimeMillis() + if (index !in sectionStorage.indices) return + sectionStorage[index].read(buf) + } + + fun getBlockAt(x: Int, yIn: Int, z: Int): Int { + val y = if (is384World) yIn + 64 else yIn + if (y !in 0 until maximumHeight) { + return blockMapping.airId + } + + return sectionStorage[y shr 4].getBlockAt(x, y and 0x0f, z) + } + + fun setBlockAt(x: Int, yIn: Int, z: Int, runtimeId: Int) { + val y = if (is384World) yIn + 64 else yIn + if (y !in 0 until maximumHeight) { + return + } + + sectionStorage[y shr 4].setBlockAt(x, y and 0x0f, z, runtimeId) + } + + companion object { + fun hash(x: Int, z: Int): Long { + return x.toLong() shl 32 or (z.toLong() and 0xffffffffL) + } + } +} diff --git a/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/ChunkSection.kt b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/ChunkSection.kt new file mode 100644 index 00000000..6cea559e --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/ChunkSection.kt @@ -0,0 +1,56 @@ +package com.retrivedmods.wclient.game.world.chunk + +import com.retrivedmods.wclient.game.registry.BlockMapping +import io.netty.buffer.ByteBuf + +/** + * Ported from ProtoHax (dev.sora.relay.game.world.chunk.ChunkSection), adapted for WClient. + * + * NOTE: the legacy (PocketMine-style, version 0) chunk format is intentionally NOT supported here - + * it needs a full id+meta -> runtime-id legacy mapping table that WClient doesn't have. Every + * Bedrock server in practice (and every currently supported protocol version) sends the modern + * (version 1 or 8-10) format, so this should never come up. + */ +class ChunkSection(private val blockMapping: BlockMapping) { + + var storage = BlockStorage(blockMapping.airId) + private set + + var populated = false + private set + + fun read(buf: ByteBuf) { + populated = true + + val version = buf.readByte().toInt() + if (version == 1 || version in 8..10) { + readModern(buf, version) + } else { + throw UnsupportedOperationException("chunk section version not supported: $version") + } + } + + private fun readModern(buf: ByteBuf, version: Int) { + val layers = if (version == 1) 1 else buf.readByte().toInt() + if (version >= 9) { + buf.readByte() // Y-Index + } + if (layers == 0) return + storage = BlockStorage(buf, true) + + // consume any additional layers (e.g. waterlogging) that we don't track + repeat(layers - 1) { + BlockStorage(buf, true) + } + } + + fun getBlockAt(x: Int, y: Int, z: Int): Int { + require(x in 0..15 && y in 0..15 && z in 0..15) { "query out of range (x=$x, y=$y, z=$z)" } + return storage.getBlock(x, y, z) + } + + fun setBlockAt(x: Int, y: Int, z: Int, runtimeId: Int) { + require(x in 0..15 && y in 0..15 && z in 0..15) { "query out of range (x=$x, y=$y, z=$z)" } + storage.setBlock(x, y, z, runtimeId) + } +} diff --git a/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/BitArray.java b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/BitArray.java new file mode 100644 index 00000000..56635e9d --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/BitArray.java @@ -0,0 +1,24 @@ +package com.retrivedmods.wclient.game.world.chunk.palette; + +/** + * from nukkit https://github.com/CloudburstMC/Nukkit/ + */ +public interface BitArray { + + void set(int index, int value); + + int get(int index); + + int size(); + + int[] getWords(); + + BitArrayVersion getVersion(); + + BitArray copy(); + + static int ceil(float floatNumber) { + int truncated = (int) floatNumber; + return floatNumber > truncated ? truncated + 1 : truncated; + } +} diff --git a/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/BitArrayVersion.java b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/BitArrayVersion.java new file mode 100644 index 00000000..987ab7cc --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/BitArrayVersion.java @@ -0,0 +1,65 @@ +package com.retrivedmods.wclient.game.world.chunk.palette; + +/** + * from nukkit https://github.com/CloudburstMC/Nukkit/ + */ +public enum BitArrayVersion { + V16(16, 2, null), + V8(8, 4, V16), + V6(6, 5, V8), // 2 bit padding + V5(5, 6, V6), // 2 bit padding + V4(4, 8, V5), + V3(3, 10, V4), // 2 bit padding + V2(2, 16, V3), + V1(1, 32, V2); + + final byte bits; + final byte entriesPerWord; + final int maxEntryValue; + final BitArrayVersion next; + + BitArrayVersion(int bits, int entriesPerWord, BitArrayVersion next) { + this.bits = (byte) bits; + this.entriesPerWord = (byte) entriesPerWord; + this.maxEntryValue = (1 << this.bits) - 1; + this.next = next; + } + + public static BitArrayVersion get(int version, boolean read) { + for (BitArrayVersion ver : values()) { + if ((!read && ver.entriesPerWord <= version) || (read && ver.bits == version)) { + return ver; + } + } + throw new IllegalArgumentException("Invalid palette version: " + version); + } + + public BitArray createPalette(int size) { + return this.createPalette(size, new int[this.getWordsForSize(size)]); + } + + public byte getId() { + return bits; + } + + public int getWordsForSize(int size) { + return (size / entriesPerWord) + (size % entriesPerWord == 0 ? 0 : 1); + } + + public int getMaxEntryValue() { + return maxEntryValue; + } + + public BitArrayVersion next() { + return next; + } + + public BitArray createPalette(int size, int[] words) { + if (this == V3 || this == V5 || this == V6) { + // Padded palettes aren't able to use bitwise operations due to their padding. + return new PaddedBitArray(this, size, words); + } else { + return new Pow2BitArray(this, size, words); + } + } +} diff --git a/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/PaddedBitArray.java b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/PaddedBitArray.java new file mode 100644 index 00000000..9a353b93 --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/PaddedBitArray.java @@ -0,0 +1,75 @@ +package com.retrivedmods.wclient.game.world.chunk.palette; + +import org.cloudburstmc.protocol.common.util.Preconditions; + +import java.util.Arrays; + +/** + * from nukkit https://github.com/CloudburstMC/Nukkit/ + */ +public class PaddedBitArray implements BitArray { + + /** + * Array used to store data + */ + private final int[] words; + + /** + * Palette version information + */ + private final BitArrayVersion version; + + /** + * Number of entries in this palette (not the length of the words array that internally backs this palette) + */ + private final int size; + + PaddedBitArray(BitArrayVersion version, int size, int[] words) { + this.size = size; + this.version = version; + this.words = words; + int expectedWordsLength = BitArray.ceil((float) size / version.entriesPerWord); + if (words.length != expectedWordsLength) { + throw new IllegalArgumentException("Invalid length given for storage, got: " + words.length + " but expected: " + expectedWordsLength); + } + } + + @Override + public void set(int index, int value) { + Preconditions.checkElementIndex(index, this.size); + Preconditions.checkArgument(value >= 0 && value <= this.version.maxEntryValue, "Max value: %s. Received value", this.version.maxEntryValue, value); + int arrayIndex = index / this.version.entriesPerWord; + int offset = (index % this.version.entriesPerWord) * this.version.bits; + + this.words[arrayIndex] = this.words[arrayIndex] & ~(this.version.maxEntryValue << offset) | (value & this.version.maxEntryValue) << offset; + } + + @Override + public int get(int index) { + Preconditions.checkElementIndex(index, this.size); + int arrayIndex = index / this.version.entriesPerWord; + int offset = (index % this.version.entriesPerWord) * this.version.bits; + + return (this.words[arrayIndex] >>> offset) & this.version.maxEntryValue; + } + + @Override + public int size() { + return this.size; + } + + @Override + public int[] getWords() { + return this.words; + } + + @Override + public BitArrayVersion getVersion() { + return this.version; + } + + @Override + public BitArray copy() { + return new PaddedBitArray(this.version, this.size, Arrays.copyOf(this.words, this.words.length)); + } +} diff --git a/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/Pow2BitArray.java b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/Pow2BitArray.java new file mode 100644 index 00000000..d34c0a36 --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/Pow2BitArray.java @@ -0,0 +1,87 @@ +package com.retrivedmods.wclient.game.world.chunk.palette; + +import org.cloudburstmc.protocol.common.util.Preconditions; + +import java.util.Arrays; + +/** + * from nukkit https://github.com/CloudburstMC/Nukkit/ + */ +public class Pow2BitArray implements BitArray { + + /** + * Array used to store data + */ + private final int[] words; + + /** + * Palette version information + */ + private final BitArrayVersion version; + + /** + * Number of entries in this palette (not the length of the words array that internally backs this palette) + */ + private final int size; + + Pow2BitArray(BitArrayVersion version, int size, int[] words) { + this.size = size; + this.version = version; + this.words = words; + int expectedWordsLength = BitArray.ceil((float) size / version.entriesPerWord); + if (words.length != expectedWordsLength) { + throw new IllegalArgumentException("Invalid length given for storage, got: " + words.length + + " but expected: " + expectedWordsLength); + } + } + + /** + * Sets the entry at the given location to the given value + */ + public void set(int index, int value) { + Preconditions.checkElementIndex(index, this.size); + Preconditions.checkArgument(value >= 0 && value <= this.version.maxEntryValue, + "Max value: %s. Received value", this.version.maxEntryValue, value); + int bitIndex = index * this.version.bits; + int arrayIndex = bitIndex >> 5; + int offset = bitIndex & 31; + this.words[arrayIndex] = this.words[arrayIndex] & ~(this.version.maxEntryValue << offset) | (value & this.version.maxEntryValue) << offset; + } + + /** + * Gets the entry at the given index + */ + public int get(int index) { + Preconditions.checkElementIndex(index, this.size); + int bitIndex = index * this.version.bits; + int arrayIndex = bitIndex >> 5; + int wordOffset = bitIndex & 31; + return this.words[arrayIndex] >>> wordOffset & this.version.maxEntryValue; + } + + /** + * Gets the long array that is used to store the data in this BitArray. This is useful for sending packet data. + */ + public int size() { + return this.size; + } + + /** + * {@inheritDoc} + * + * @return {@inheritDoc} + */ + @Override + public int[] getWords() { + return this.words; + } + + public BitArrayVersion getVersion() { + return version; + } + + @Override + public BitArray copy() { + return new Pow2BitArray(this.version, this.size, Arrays.copyOf(this.words, this.words.length)); + } +} diff --git a/app/src/main/java/com/retrivedmods/wclient/model/RealmWorld.kt b/app/src/main/java/com/retrivedmods/wclient/model/RealmWorld.kt index 3537d613..eb979006 100644 --- a/app/src/main/java/com/retrivedmods/wclient/model/RealmWorld.kt +++ b/app/src/main/java/com/retrivedmods/wclient/model/RealmWorld.kt @@ -1,7 +1,7 @@ package com.retrivedmods.wclient.model import androidx.compose.runtime.Immutable -import net.raphimc.minecraftauth.service.realms.model.RealmsWorld +import net.raphimc.minecraftauth.extra.realms.model.RealmsServer @Immutable data class RealmWorld( @@ -19,11 +19,11 @@ data class RealmWorld( val connectionDetails: RealmConnectionDetails? = null ) { companion object { - fun fromRealmsWorld(realmsWorld: RealmsWorld): RealmWorld { + fun fromRealmsWorld(realmsWorld: RealmsServer): RealmWorld { return RealmWorld( id = realmsWorld.id, ownerName = realmsWorld.ownerName ?: "Unknown", - ownerUuidOrXuid = realmsWorld.ownerUuidOrXuid ?: "", + ownerUuidOrXuid = realmsWorld.ownerUid ?: "", name = realmsWorld.name ?: "Unnamed Realm", motd = realmsWorld.motd ?: "", state = RealmState.fromString(realmsWorld.state), diff --git a/app/src/main/java/com/retrivedmods/wclient/router/main/AccountPage.kt b/app/src/main/java/com/retrivedmods/wclient/router/main/AccountPage.kt index 1bd2024e..47c47a23 100644 --- a/app/src/main/java/com/retrivedmods/wclient/router/main/AccountPage.kt +++ b/app/src/main/java/com/retrivedmods/wclient/router/main/AccountPage.kt @@ -57,7 +57,7 @@ import com.retrivedmods.wclient.util.getActivityWindow import com.retrivedmods.wclient.util.getDialogWindow import com.retrivedmods.wclient.util.windowFullScreen import kotlinx.coroutines.launch -import net.raphimc.minecraftauth.step.bedrock.session.StepFullBedrockSession.FullBedrockSession +import com.retrivedmods.wclient.game.AccountManager.WAccount @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,7 +66,7 @@ fun AccountPageContent() { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() var showAddAccountDropDownMenu by remember { mutableStateOf(false) } - var selectedAccountAction: FullBedrockSession? by remember { mutableStateOf(null) } + var selectedAccountAction: WAccount? by remember { mutableStateOf(null) } var login: Boolean by remember { mutableStateOf(false) } val snackbarHostState = LocalSnackbarHostState.current @@ -139,7 +139,7 @@ fun AccountPageContent() { containerColor = MaterialTheme.colorScheme.surfaceContainer ), headlineContent = { - Text(account.mcChain.displayName) + Text(account.displayName) }, supportingContent = { Row(Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/retrivedmods/wclient/router/main/RealmsPage.kt b/app/src/main/java/com/retrivedmods/wclient/router/main/RealmsPage.kt index 9aed6689..b6a9b0a2 100644 --- a/app/src/main/java/com/retrivedmods/wclient/router/main/RealmsPage.kt +++ b/app/src/main/java/com/retrivedmods/wclient/router/main/RealmsPage.kt @@ -24,8 +24,7 @@ fun RealmsPageContent() { LaunchedEffect(AccountManager.selectedAccount) { val selectedAccount = AccountManager.selectedAccount - println("RealmsPage: Selected account changed: ${selectedAccount?.mcChain?.displayName}") - println("RealmsPage: Account has Realms support: ${selectedAccount?.realmsXsts != null}") + println("RealmsPage: Selected account changed: ${selectedAccount?.displayName}") RealmsManager.updateSession(selectedAccount) } diff --git a/app/src/main/java/com/retrivedmods/wclient/service/RealmsManager.kt b/app/src/main/java/com/retrivedmods/wclient/service/RealmsManager.kt index 64f9b6f4..5b18561f 100644 --- a/app/src/main/java/com/retrivedmods/wclient/service/RealmsManager.kt +++ b/app/src/main/java/com/retrivedmods/wclient/service/RealmsManager.kt @@ -1,13 +1,15 @@ package com.retrivedmods.wclient.service import android.util.Log +import com.google.gson.JsonObject import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import net.raphimc.minecraftauth.MinecraftAuth -import net.raphimc.minecraftauth.service.realms.BedrockRealmsService -import net.raphimc.minecraftauth.step.bedrock.session.StepFullBedrockSession +import net.raphimc.minecraftauth.extra.realms.model.RealmsServer +import net.raphimc.minecraftauth.extra.realms.service.impl.BedrockRealmsService +import com.retrivedmods.wclient.game.AccountManager import com.retrivedmods.wclient.model.RealmWorld import com.retrivedmods.wclient.model.RealmConnectionDetails import com.retrivedmods.wclient.model.RealmState @@ -17,7 +19,6 @@ import java.util.concurrent.ConcurrentHashMap object RealmsManager { private const val TAG = "RealmsManager" - private const val CLIENT_VERSION = "1.21.120" private val coroutineScope = CoroutineScope(Dispatchers.IO + CoroutineName("RealmsManagerCoroutine")) @@ -26,37 +27,34 @@ object RealmsManager { private val connectionCache = ConcurrentHashMap() - private var currentSession: StepFullBedrockSession.FullBedrockSession? = null private var realmsService: BedrockRealmsService? = null - fun updateSession(session: StepFullBedrockSession.FullBedrockSession?) { - currentSession = session + fun updateSession(account: AccountManager.WAccount?) { + Log.d(TAG, "updateSession called with account: ${account?.displayName}") - Log.d(TAG, "updateSession called with session: ${session?.mcChain?.displayName}") - Log.d(TAG, "Session has realmsXsts: ${session?.realmsXsts != null}") - - if (session?.realmsXsts != null) { - try { - Log.d(TAG, "Initializing Realms service with client version: $CLIENT_VERSION") - val httpClient = MinecraftAuth.createHttpClient() - httpClient.connectTimeout = 10000 - httpClient.readTimeout = 10000 - - realmsService = BedrockRealmsService(httpClient, CLIENT_VERSION, session.realmsXsts) - Log.d(TAG, "Realms service initialized successfully") - refreshRealms() - } catch (e: Exception) { - Log.e(TAG, "Failed to initialize Realms service", e) - _realmsState.value = RealmsLoadingState.Error("Failed to initialize Realms service: ${e.message}") - } - } else { - Log.w(TAG, "No realmsXsts token available - session: ${session != null}, realmsXsts: ${session?.realmsXsts}") + if (account == null) { realmsService = null - _realmsState.value = if (session == null) { - RealmsLoadingState.NoAccount - } else { - RealmsLoadingState.NotAvailable - } + _realmsState.value = RealmsLoadingState.NoAccount + return + } + + try { + Log.d(TAG, "Initializing Realms service with client version: ${AccountManager.GAME_VERSION}") + val httpClient = MinecraftAuth.createHttpClient() + + // realmsXstsToken is a Holder on BedrockAuthManager - it's fetched + // lazily (on first getUpToDate() call from inside BedrockRealmsService), not eagerly + // during sign-in, so accounts without Realms access don't fail login over this. + realmsService = BedrockRealmsService( + httpClient, + AccountManager.GAME_VERSION, + account.authManager.realmsXstsToken + ) + Log.d(TAG, "Realms service initialized successfully") + refreshRealms() + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize Realms service", e) + _realmsState.value = RealmsLoadingState.Error("Failed to initialize Realms service: ${e.message}") } } @@ -68,23 +66,23 @@ object RealmsManager { return } - Log.d(TAG, "Starting Realms refresh with client version: $CLIENT_VERSION") + Log.d(TAG, "Starting Realms refresh") _realmsState.value = RealmsLoadingState.Loading coroutineScope.launch { try { - Log.d(TAG, "Checking if Realms is available...") - val isAvailable = service.isAvailable().get() - Log.d(TAG, "Realms availability check result: $isAvailable") + Log.d(TAG, "Checking Realms compatibility...") + val isCompatible = service.isCompatible() + Log.d(TAG, "Realms compatibility check result: $isCompatible") - if (!isAvailable) { - Log.w(TAG, "Realms not available for client version: $CLIENT_VERSION") + if (!isCompatible) { + Log.w(TAG, "Realms not available for this client version") _realmsState.value = RealmsLoadingState.NotAvailable return@launch } Log.d(TAG, "Fetching Realms worlds...") - val realmsWorlds = service.worlds.get() + val realmsWorlds = service.worlds val realmWorldList = realmsWorlds.map { RealmWorld.fromRealmsWorld(it) } _realmsState.value = RealmsLoadingState.Success(realmWorldList) @@ -144,37 +142,38 @@ object RealmsManager { throw IllegalStateException("Realm is not open (current state: ${realm.state})") } - val realmsWorld = net.raphimc.minecraftauth.service.realms.model.RealmsWorld( + val realmsServer = RealmsServer( realm.id, - realm.ownerName, - realm.ownerUuidOrXuid, realm.name, realm.motd, + realm.ownerName, + realm.ownerUuidOrXuid, realm.state.name, realm.expired, + 0, realm.worldType, realm.maxPlayers, realm.compatible, realm.activeVersion, - null + JsonObject() ) Log.d(TAG, "Requesting connection details for Realm ${realm.name} (ID: $realmId)") - val address = withContext(Dispatchers.IO) { - service.joinWorld(realmsWorld).get() + val joinInfo = withContext(Dispatchers.IO) { + service.joinWorld(realmsServer) } - Log.d(TAG, "Received raw address from Realms service: '$address'") + Log.d(TAG, "Received raw address from Realms service: '${joinInfo.address}'") - if (address.isBlank()) { + if (joinInfo.address.isBlank()) { throw IllegalStateException("Received empty address from Realms service") } val connectionDetails = try { - RealmConnectionDetails.fromAddress(address) + RealmConnectionDetails.fromAddress(joinInfo.address) } catch (e: IllegalArgumentException) { - Log.e(TAG, "Failed to parse address '$address': ${e.message}") - throw IllegalStateException("Invalid address format received: $address") + Log.e(TAG, "Failed to parse address '${joinInfo.address}': ${e.message}") + throw IllegalStateException("Invalid address format received: ${joinInfo.address}") } connectionCache[realmId] = connectionDetails @@ -205,4 +204,4 @@ object RealmsManager { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/retrivedmods/wclient/service/Services.kt b/app/src/main/java/com/retrivedmods/wclient/service/Services.kt index a973a813..1dc4d973 100644 --- a/app/src/main/java/com/retrivedmods/wclient/service/Services.kt +++ b/app/src/main/java/com/retrivedmods/wclient/service/Services.kt @@ -144,7 +144,7 @@ object Services { ).capture(remoteAddress = remoteAddress) { initModules(this) listeners.add(AutoCodecPacketListener(this)) - selectedAccount?.let { OnlineLoginPacketListener(this, it) } + selectedAccount?.authManager?.let { OnlineLoginPacketListener(this, it) } ?.let { listeners.add(it) } listeners.add(GamingPacketHandler(this)) } @@ -155,7 +155,7 @@ object Services { ) { initModules(this) listeners.add(AutoCodecPacketListener(this)) - selectedAccount?.let { OnlineLoginPacketListener(this, it) } + selectedAccount?.authManager?.let { OnlineLoginPacketListener(this, it) } ?.let { listeners.add(it) } listeners.add(GamingPacketHandler(this)) } diff --git a/app/src/main/java/com/retrivedmods/wclient/ui/component/AuthWebView.kt b/app/src/main/java/com/retrivedmods/wclient/ui/component/AuthWebView.kt index 05060465..4e6fa052 100644 --- a/app/src/main/java/com/retrivedmods/wclient/ui/component/AuthWebView.kt +++ b/app/src/main/java/com/retrivedmods/wclient/ui/component/AuthWebView.kt @@ -3,7 +3,6 @@ package com.retrivedmods.wclient.ui.component import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet -import android.util.Base64 import android.webkit.CookieManager import android.webkit.WebResourceRequest import android.webkit.WebView @@ -11,7 +10,9 @@ import android.webkit.WebViewClient import com.retrivedmods.wclient.game.AccountManager import com.retrivedmods.wclient.game.RealmsAuthFlow import net.raphimc.minecraftauth.MinecraftAuth -import net.raphimc.minecraftauth.step.msa.StepMsaDeviceCode +import net.raphimc.minecraftauth.bedrock.BedrockAuthManager +import net.raphimc.minecraftauth.msa.service.impl.DeviceCodeMsaAuthService +import java.util.function.Consumer import kotlin.concurrent.thread val auth = "UCxb4pcHvdYpqv7i5Xt9mOUw" @@ -38,27 +39,38 @@ class AuthWebView @JvmOverloads constructor( thread { runCatching { val httpClient = MinecraftAuth.createHttpClient() - httpClient.connectTimeout = 10000 - httpClient.readTimeout = 10000 - val fullBedrockSession = RealmsAuthFlow.BEDROCK_DEVICE_CODE_LOGIN_WITH_REALMS.getFromInput( - httpClient, - StepMsaDeviceCode.MsaDeviceCodeCallback { - post { - loadUrl(it.directVerificationUri) - } + // Unlike the old 4.x step-chain API, BedrockAuthManager fetches tokens lazily + // and per-purpose (Realms XSTS is only requested later, if/when RealmsManager + // actually needs it) - so there's no separate "Realms-capable chain that can + // fail for accounts without Realms" to fall back from anymore. + // + // NOTE: we build the DeviceCodeMsaAuthService ourselves (instead of passing + // `::DeviceCodeMsaAuthService` into `.login(supplier, callback)`) because + // Kotlin can't reliably resolve which constructor overload a bare `::Class` + // reference should bind to when it has to flow through a generic Java + // functional-interface parameter - it fails with a cryptic + // "Argument type mismatch: actual type is 'Function'" error, even + // though the equivalent `DeviceCodeMsaAuthService::new` compiles fine in Java. + // Calling the constructor directly here sidesteps that inference problem. + val deviceCodeCallback = Consumer { deviceCode -> + post { + loadUrl(deviceCode.directVerificationUri) } - ) - val containedAccount = - AccountManager.accounts.find { it.mcChain.displayName == fullBedrockSession.mcChain.displayName } - if (containedAccount != null) { - AccountManager.removeAccount(containedAccount) } - AccountManager.addAccount(fullBedrockSession) + val authService = DeviceCodeMsaAuthService( + httpClient, + RealmsAuthFlow.BEDROCK_ANDROID_APPLICATION_CONFIG, + deviceCodeCallback + ) + val msaToken = authService.acquireToken() - if (containedAccount == AccountManager.selectedAccount) { - AccountManager.selectAccount(fullBedrockSession) - } + val authManager = BedrockAuthManager + .create(httpClient, AccountManager.GAME_VERSION) + .msaApplicationConfig(RealmsAuthFlow.BEDROCK_ANDROID_APPLICATION_CONFIG) + .login(msaToken) + + AccountManager.addAccount(authManager) callback?.invoke(null) }.exceptionOrNull()?.let { callback?.invoke(it) diff --git a/app/src/main/java/com/retrivedmods/wclient/util/MinecraftUtils.kt b/app/src/main/java/com/retrivedmods/wclient/util/MinecraftUtils.kt index a33f3c43..e354c7cf 100644 --- a/app/src/main/java/com/retrivedmods/wclient/util/MinecraftUtils.kt +++ b/app/src/main/java/com/retrivedmods/wclient/util/MinecraftUtils.kt @@ -1,5 +1,5 @@ package com.retrivedmods.wclient.util object MinecraftUtils { - const val RECOMMENDED_VERSION = "v1.21.132" + const val RECOMMENDED_VERSION = "v1.26.40" } \ No newline at end of file diff --git a/app/src/main/java/com/retrivedmods/wclient/util/PacketDebugLog.kt b/app/src/main/java/com/retrivedmods/wclient/util/PacketDebugLog.kt new file mode 100644 index 00000000..6c29f9ca --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/util/PacketDebugLog.kt @@ -0,0 +1,29 @@ +package com.retrivedmods.wclient.util + +import com.retrivedmods.wclient.game.GameSession +import com.retrivedmods.wclient.util.setPacketField +import org.cloudburstmc.protocol.bedrock.packet.TextPacket + +/** + * Tiny shared hook so code that sends packets directly via session.serverBound(...) (bypassing + * the beforePacketBound intercept pipeline PacketLoggerModule listens on, e.g. + * LocalPlayer.placeBlock) can still get logged to chat when that module is enabled. Toggled by + * PacketLoggerModule.onEnabled/onDisabled - nothing else should touch [enabled]. + */ +object PacketDebugLog { + + @Volatile + var enabled: Boolean = false + + fun log(session: GameSession, tag: String, body: String) { + if (!enabled) return + val textPacket = TextPacket().apply { + type = TextPacket.Type.RAW + setPacketField("needsTranslation", false) + setPacketField("message", "§l§b[$tag]§r\n$body") + xuid = "" + sourceName = "" + } + session.clientBound(textPacket) + } +} diff --git a/app/src/main/java/com/retrivedmods/wclient/util/PacketFieldUtil.kt b/app/src/main/java/com/retrivedmods/wclient/util/PacketFieldUtil.kt new file mode 100644 index 00000000..fa260371 --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/util/PacketFieldUtil.kt @@ -0,0 +1,50 @@ +package com.retrivedmods.wclient.util + +import java.lang.reflect.Field +import java.util.concurrent.ConcurrentHashMap + +/** + * Some packet classes in newer versions of org.cloudburstmc.protocol (e.g. the + * 3.0.0.Beta12-SNAPSHOT build used to support current Bedrock protocol versions) + * no longer expose public setters for certain fields (onGround, selectHotbarSlot, + * needsTranslation, text, etc). The underlying fields still exist, they're just + * no longer part of the public Java Bean API, which breaks Kotlin's synthetic + * property assignment (`packet.onGround = true`). + * + * This helper sets those fields directly via reflection so callers don't have to + * care whether a given field is still publicly settable in the version of the + * protocol library that's currently linked. + */ +object PacketFieldUtil { + + private val fieldCache = ConcurrentHashMap() + + fun setField(target: Any, fieldName: String, value: Any?) { + val field = resolveField(target.javaClass, fieldName) + ?: throw NoSuchFieldException("Field '$fieldName' not found in ${target.javaClass.name} or its superclasses") + field.set(target, value) + } + + private fun resolveField(clazz: Class<*>, fieldName: String): Field? { + val key = "${clazz.name}#$fieldName" + fieldCache[key]?.let { return it } + + var current: Class<*>? = clazz + while (current != null) { + try { + val field = current.getDeclaredField(fieldName) + field.isAccessible = true + fieldCache[key] = field + return field + } catch (_: NoSuchFieldException) { + current = current.superclass + } + } + return null + } +} + +/** Convenience extension so call sites read almost like a normal assignment. */ +fun Any.setPacketField(fieldName: String, value: Any?) { + PacketFieldUtil.setField(this, fieldName, value) +} diff --git a/build.yml b/build.yml new file mode 100644 index 00000000..c3154574 --- /dev/null +++ b/build.yml @@ -0,0 +1,17 @@ +name: Build APK +on: workflow_dispatch +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - run: chmod +x gradlew + - run: ./gradlew assembleRelease + - uses: actions/upload-artifact@v4 + with: + name: apk + path: app/build/outputs/apk/**/*.apk diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a98487cf..210537c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,7 @@ math = "2.0" nbt = "3.0.3.Final" snappy = "2.0.2" jose4j = "0.9.6" -minecraft-auth = "4.1.2" +minecraft-auth = "5.0.0" jackson-databind = "2.20.0" jackson-annotations = "2.20" @@ -61,6 +61,7 @@ kotlinx-serialization-json-jvm = { module = "org.jetbrains.kotlinx:kotlinx-seria androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } adventure-text-serializer-legacy = { group = "net.kyori", name = "adventure-text-serializer-legacy", version.ref = "adventure" } +adventure-api = { group = "net.kyori", name = "adventure-api", version.ref = "adventure" } adventure-text-serializer-json = { group = "net.kyori", name = "adventure-text-serializer-json", version.ref = "adventure" } log4j-bom = { group = "org.apache.logging.log4j", name = "log4j-bom", version.ref = "log4j" } log4j-api = { group = "org.apache.logging.log4j", name = "log4j-api" } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/relay/build.gradle.kts b/relay/build.gradle.kts index 8e2d1185..55cf8374 100644 --- a/relay/build.gradle.kts +++ b/relay/build.gradle.kts @@ -26,10 +26,25 @@ dependencies { // Use api to expose these to the app module api(libs.minecraft.auth) - api(project(":relay:Network:transport-raknet")) - api(project(":relay:Protocol:bedrock-codec")) - api(project(":relay:Protocol:bedrock-connection")) - api(project(":relay:Protocol:common")) + // NOTE: previously also had `api(project(":relay:Network:transport-raknet"))` here, but the + // Beta12 bedrock-connection artifact below already transitively pulls in + // org.cloudburstmc.netty:netty-transport-raknet from Maven. Having both on the classpath at + // once caused R8 to fail with "RakChannel is defined multiple times" during release builds. + // RakChannelFactory/RakChannel usages in relay/src still resolve fine via the Maven artifact. + + // Previously vendored locally under relay/Protocol/* (stuck at an old Beta1 snapshot that + // only understood protocol versions up to ~898 / Bedrock 1.21.130). Switched to the actual + // upstream CloudburstMC/Protocol artifacts published on opencollab's snapshot repo (already + // declared in settings.gradle.kts), which track current Bedrock protocol versions. This is a + // real version jump (Beta1 -> Beta12) so some packet/codec API usage elsewhere in this project + // may need small fixes to compile - check the first build's errors. + // Needed for net.kyori.adventure.text.Component, which the newer Beta12 protocol + // (and relay code referencing it) uses for text. + api(libs.adventure.api) + + api("org.cloudburstmc.protocol:bedrock-codec:3.0.0.Beta13-SNAPSHOT") + api("org.cloudburstmc.protocol:bedrock-connection:3.0.0.Beta13-SNAPSHOT") + api("org.cloudburstmc.protocol:common:3.0.0.Beta13-SNAPSHOT") api(libs.bundles.netty) testImplementation(kotlin("test")) diff --git a/relay/src/main/kotlin/com/retrivedmods/wrelay/WRelay.kt b/relay/src/main/kotlin/com/retrivedmods/wrelay/WRelay.kt index 2c4d03ed..aa5db049 100644 --- a/relay/src/main/kotlin/com/retrivedmods/wrelay/WRelay.kt +++ b/relay/src/main/kotlin/com/retrivedmods/wrelay/WRelay.kt @@ -44,7 +44,13 @@ class WRelay( .motd("§cWelcome To WRelay§c") .playerCount(0) .maximumPlayerCount(20) - .subMotd("WClient") + // Temporary diagnostic: show which codec actually got picked directly in the + // server-list sub-motd, since there's no easy way to read Logcat without a PC/ + // Android Studio. If this still says "v898" after rebuilding with the updated + // CodecRegistry.kt, the v2168 registration silently failed (wrong class name) - + // if it says "v2168", the codec side is fine and the Block error has some other + // cause. Safe to revert to plain "WClient" once this is confirmed. + .subMotd("WClient v${DefaultCodec.protocolVersion}") .nintendoLimited(false) } diff --git a/relay/src/main/kotlin/com/retrivedmods/wrelay/codec/CodecRegistry.kt b/relay/src/main/kotlin/com/retrivedmods/wrelay/codec/CodecRegistry.kt index b53748f5..2ad0dac2 100644 --- a/relay/src/main/kotlin/com/retrivedmods/wrelay/codec/CodecRegistry.kt +++ b/relay/src/main/kotlin/com/retrivedmods/wrelay/codec/CodecRegistry.kt @@ -70,6 +70,23 @@ object CodecRegistry { registerCodec(859, "1.21.120", "org.cloudburstmc.protocol.bedrock.codec.v859.Bedrock_v859") registerCodec(860, "1.21.124", "org.cloudburstmc.protocol.bedrock.codec.v860.Bedrock_v860") registerCodec(898, "1.21.130", "org.cloudburstmc.protocol.bedrock.codec.v898.Bedrock_v898") + // Added after switching relay/build.gradle.kts from the old vendored codec source to the + // upstream org.cloudburstmc.protocol:bedrock-codec:3.0.0.Beta12-SNAPSHOT dependency, which + // (per the compiled classes actually found inside a real, working v35.0 release APK's dex) + // includes codecs at least up through v2168. This file previously never advertised or + // negotiated anything past 898 (1.21.130) even after that dependency swap, because + // CodecRegistry loads codecs by exact class name via reflection and simply had no entries + // for anything newer - so a real 1.26.44 client (protocol 2168) saw WRelay's RakNet + // advertisement claim protocol 898 and refused to connect at the handshake stage + // (Block / InitialConnection-90), before any of our game logic ever ran. + // Class names below follow the same org.cloudburstmc.protocol.bedrock.codec.v{N}.Bedrock_v{N} + // pattern as every entry above; registerCodec() already no-ops with a logged warning if a + // class name turns out to be wrong for this snapshot, so a bad guess here is safe, not fatal. + registerCodec(924, "1.21.140", "org.cloudburstmc.protocol.bedrock.codec.v924.Bedrock_v924") + registerCodec(944, "1.21.150", "org.cloudburstmc.protocol.bedrock.codec.v944.Bedrock_v944") + registerCodec(975, "1.21.160", "org.cloudburstmc.protocol.bedrock.codec.v975.Bedrock_v975") + registerCodec(1001, "1.21.170", "org.cloudburstmc.protocol.bedrock.codec.v1001.Bedrock_v1001") + registerCodec(2168, "1.26.44", "org.cloudburstmc.protocol.bedrock.codec.v2168.Bedrock_v2168") sortedProtocolVersions.sortDescending() @@ -88,7 +105,18 @@ object CodecRegistry { println("Registered codec: $minecraftVersion (protocol $protocolVersion)") } catch (e: Exception) { - println("Failed to register codec $minecraftVersion: ${e.message}") + // NOTE: this used to fail completely silently from the app's perspective (only a + // logcat println, easy to miss). A failure here for the newest entries means + // getLatestCodec()/getClosestCodec() silently fall back to an older protocol than + // the one actually needed - e.g. real 1.26.44 clients seeing WRelay advertise a + // much older protocol and refusing to connect with "InitialConnection-90: Block" + // before any of our game logic even runs. + // (android.util.Log can't be used here: :relay is a plain Kotlin/JVM module with no + // Android SDK dependency - only :app has that. println still shows up in `adb logcat` + // for an Android process, same as every other log line in this file.) + println( + "ERROR: Failed to register codec $minecraftVersion (protocol $protocolVersion, class $className): ${e.javaClass.simpleName}: ${e.message}" + ) } } diff --git a/relay/src/main/kotlin/com/retrivedmods/wrelay/codec/VersionDetector.kt b/relay/src/main/kotlin/com/retrivedmods/wrelay/codec/VersionDetector.kt index 48fbf3f7..25d01a97 100644 --- a/relay/src/main/kotlin/com/retrivedmods/wrelay/codec/VersionDetector.kt +++ b/relay/src/main/kotlin/com/retrivedmods/wrelay/codec/VersionDetector.kt @@ -3,6 +3,9 @@ package com.retrivedmods.wrelay.codec object VersionDetector { private val versionRanges = mapOf( + // 1.26.40 and 1.26.44 share protocol version 2168 (no wire-protocol break between + // them - confirmed via minecraft.wiki/w/Protocol_version and bedrock-v/protocol-docs). + 2168 to listOf("1.26.40", "1.26.44"), 898 to listOf("1.21.130", "1.21.131", "1.21.132"), 860 to listOf("1.21.124"), 859 to listOf("1.21.120"), diff --git a/relay/src/main/kotlin/com/retrivedmods/wrelay/listener/OnlineLoginPacketListener.kt b/relay/src/main/kotlin/com/retrivedmods/wrelay/listener/OnlineLoginPacketListener.kt index e7ee5080..4bbfa3d1 100644 --- a/relay/src/main/kotlin/com/retrivedmods/wrelay/listener/OnlineLoginPacketListener.kt +++ b/relay/src/main/kotlin/com/retrivedmods/wrelay/listener/OnlineLoginPacketListener.kt @@ -2,9 +2,8 @@ package com.retrivedmods.wrelay.listener import com.retrivedmods.wrelay.WRelaySession import com.retrivedmods.wrelay.util.AuthUtils -import com.retrivedmods.wrelay.util.refresh import net.kyori.adventure.text.Component -import net.raphimc.minecraftauth.step.bedrock.session.StepFullBedrockSession +import net.raphimc.minecraftauth.bedrock.BedrockAuthManager import org.cloudburstmc.protocol.bedrock.data.PacketCompressionAlgorithm import org.cloudburstmc.protocol.bedrock.data.auth.AuthType import org.cloudburstmc.protocol.bedrock.data.auth.CertificateChainPayload @@ -22,29 +21,27 @@ import kotlin.io.encoding.ExperimentalEncodingApi @Suppress("MemberVisibilityCanBePrivate") class OnlineLoginPacketListener( val wRelaySession: WRelaySession, - private var fullBedrockSession: StepFullBedrockSession.FullBedrockSession + private val authManager: BedrockAuthManager ) : WRelayPacketListener { private var skinData: JSONObject? = null override fun beforeClientBound(packet: BedrockPacket): Boolean { if (packet is LoginPacket) { - if (fullBedrockSession.isExpired) { - println("Session expired, attempting to refresh tokens...") + println("Processing login packet") - try { - fullBedrockSession = fullBedrockSession.refresh() - println("Successfully refreshed session for: ${fullBedrockSession.mcChain.displayName}") - } catch (e: Exception) { - println("Failed to refresh session: ${e.message}") - e.printStackTrace() - wRelaySession.server.disconnect("Your session has expired and could not be refreshed. Please re-login in the W Client.") - return true - } + try { + // BedrockAuthManager refreshes its own tokens on demand - calling getUpToDate() + // here just makes sure that happens (and surfaces any failure) before we commit + // to the login flow, instead of failing partway through connectServer(). + authManager.minecraftCertificateChain.getUpToDate() + } catch (e: Exception) { + println("Failed to refresh session: ${e.message}") + e.printStackTrace() + wRelaySession.server.disconnect("Your session has expired and could not be refreshed. Please re-login in the W Client.") + return true } - println("Processing login packet") - try { val jws = JsonWebSignature() jws.compactSerialization = packet.clientJwt @@ -75,10 +72,10 @@ class OnlineLoginPacketListener( } try { - val chain = AuthUtils.fetchOnlineChain(fullBedrockSession) + val chain = AuthUtils.fetchOnlineChain(authManager) val skinData = AuthUtils.fetchOnlineSkinData( - fullBedrockSession, + authManager, skinData!!, wRelaySession.wRelay.remoteAddress!! ) @@ -105,25 +102,25 @@ class OnlineLoginPacketListener( if (parts.size != 3) { throw Exception("Invalid JWT format") } - + val headerJson = String(java.util.Base64.getUrlDecoder().decode(parts[0])) val payloadJson = String(java.util.Base64.getUrlDecoder().decode(parts[1])) - + val header = JSONObject(JsonUtil.parseJson(headerJson)) val payload = JSONObject(JsonUtil.parseJson(payloadJson)) - + val x5u = header.get("x5u") as? String ?: throw Exception("Missing x5u in header") val serverKey = EncryptionUtils.parseKey(x5u) - + val saltString = payload.get("salt") as? String ?: throw Exception("Missing salt in payload") val salt = java.util.Base64.getDecoder().decode(saltString) - + val key = EncryptionUtils.getSecretKey( - fullBedrockSession.mcChain.privateKey, + authManager.sessionKeyPair.private, serverKey, salt ) - + wRelaySession.client!!.enableEncryption(key) println("Encryption enabled successfully") @@ -161,4 +158,4 @@ class OnlineLoginPacketListener( } } -} \ No newline at end of file +} diff --git a/relay/src/main/kotlin/com/retrivedmods/wrelay/util/AuthUtils.kt b/relay/src/main/kotlin/com/retrivedmods/wrelay/util/AuthUtils.kt index 34320007..25e7baa5 100644 --- a/relay/src/main/kotlin/com/retrivedmods/wrelay/util/AuthUtils.kt +++ b/relay/src/main/kotlin/com/retrivedmods/wrelay/util/AuthUtils.kt @@ -3,7 +3,7 @@ package com.retrivedmods.wrelay.util import com.google.gson.Gson import com.google.gson.GsonBuilder import com.retrivedmods.wrelay.address.WAddress -import net.raphimc.minecraftauth.step.bedrock.session.StepFullBedrockSession.FullBedrockSession +import net.raphimc.minecraftauth.bedrock.BedrockAuthManager import org.jose4j.json.internal.json_simple.JSONObject import org.jose4j.jws.JsonWebSignature import org.jose4j.jwt.JwtClaims @@ -30,15 +30,22 @@ object AuthUtils { .setPrettyPrinting() .create() + // In MinecraftAuth 4.x, FullBedrockSession.mcChain bundled the mojang/identity JWTs together + // with the ECDSA keypair used to self-sign the third chain link. In 5.x those live in two + // separate places on BedrockAuthManager: the JWTs come from minecraftCertificateChain, and + // the keypair is authManager.sessionKeyPair. @OptIn(ExperimentalEncodingApi::class) - fun fetchOnlineChain(fullBedrockSession: FullBedrockSession): List { - val publicBase64Key = Base64.encode(fullBedrockSession.mcChain.publicKey.encoded) + fun fetchOnlineChain(authManager: BedrockAuthManager): List { + val certChain = authManager.minecraftCertificateChain.getUpToDate() + val sessionKeyPair = authManager.sessionKeyPair + + val publicBase64Key = Base64.encode(sessionKeyPair.public.encoded) val consumer = JwtConsumerBuilder() .setAllowedClockSkewInSeconds(60) .setVerificationKey(mojangPublicKey) .build() - val mojangJws = consumer.process(fullBedrockSession.mcChain.mojangJwt).joseObjects[0] as JsonWebSignature + val mojangJws = consumer.process(certChain.mojangJwt).joseObjects[0] as JsonWebSignature val claimsSet = JwtClaims() claimsSet.setClaim("certificateAuthority", true) @@ -48,28 +55,32 @@ object AuthUtils { val selfSignedJws = JsonWebSignature() selfSignedJws.payload = claimsSet.toJson() - selfSignedJws.key = fullBedrockSession.mcChain.privateKey + selfSignedJws.key = sessionKeyPair.private selfSignedJws.algorithmHeaderValue = "ES384" selfSignedJws.setHeader(HeaderParameterNames.X509_URL, publicBase64Key) val selfSignedJwt = selfSignedJws.compactSerialization - return listOf(selfSignedJwt, fullBedrockSession.mcChain.mojangJwt, fullBedrockSession.mcChain.identityJwt) + return listOf(selfSignedJwt, certChain.mojangJwt, certChain.identityJwt) } @OptIn(ExperimentalEncodingApi::class, ExperimentalUuidApi::class) fun fetchOnlineSkinData( - fullBedrockSession: FullBedrockSession, + authManager: BedrockAuthManager, skinData: JSONObject, remoteAddress: WAddress ): String { - val publicKeyBase64 = Base64.encode(fullBedrockSession.mcChain.publicKey.encoded) + val certChain = authManager.minecraftCertificateChain.getUpToDate() + val sessionKeyPair = authManager.sessionKeyPair + val playFabToken = authManager.playFabToken.getUpToDate() + + val publicKeyBase64 = Base64.encode(sessionKeyPair.public.encoded) val overridedData = HashMap() - overridedData["PlayFabId"] = fullBedrockSession.playFabToken.playFabId.lowercase(Locale.ROOT) + overridedData["PlayFabId"] = playFabToken.playFabId.lowercase(Locale.ROOT) overridedData["DeviceId"] = Uuid.random().toString() overridedData["DeviceOS"] = 1 - overridedData["ThirdPartyName"] = fullBedrockSession.mcChain.displayName + overridedData["ThirdPartyName"] = certChain.identityDisplayName overridedData["ServerAddress"] = "${remoteAddress.hostName}:${remoteAddress.port}" skinData.putAll(overridedData) @@ -78,7 +89,7 @@ object AuthUtils { jws.algorithmHeaderValue = "ES384" jws.setHeader(HeaderParameterNames.X509_URL, publicKeyBase64) jws.payload = skinData.toJSONString() - jws.key = fullBedrockSession.mcChain.privateKey + jws.key = sessionKeyPair.private return jws.compactSerialization } @@ -89,4 +100,4 @@ object AuthUtils { .generatePublic(X509EncodedKeySpec(Base64.decode(MOJANG_PUBLIC_KEY))) as ECPublicKey } -} \ No newline at end of file +} diff --git a/relay/src/main/kotlin/com/retrivedmods/wrelay/util/MinecraftRelays.kt b/relay/src/main/kotlin/com/retrivedmods/wrelay/util/MinecraftRelays.kt index 00973496..1afe4c99 100644 --- a/relay/src/main/kotlin/com/retrivedmods/wrelay/util/MinecraftRelays.kt +++ b/relay/src/main/kotlin/com/retrivedmods/wrelay/util/MinecraftRelays.kt @@ -1,17 +1,10 @@ package com.retrivedmods.wrelay.util -import com.google.gson.JsonParser import com.retrivedmods.wrelay.WRelay import com.retrivedmods.wrelay.WRelaySession import com.retrivedmods.wrelay.address.WAddress import com.retrivedmods.wrelay.codec.CodecRegistry -import net.lenni0451.commons.httpclient.RetryHandler -import net.raphimc.minecraftauth.MinecraftAuth -import net.raphimc.minecraftauth.step.bedrock.session.StepFullBedrockSession -import net.raphimc.minecraftauth.step.msa.StepMsaDeviceCode.MsaDeviceCodeCallback import org.cloudburstmc.protocol.bedrock.BedrockPong -import java.io.File -import java.nio.file.Paths fun captureGamePacket( advertisement: BedrockPong = WRelay.DefaultAdvertisement, @@ -20,7 +13,7 @@ fun captureGamePacket( onSessionCreated: WRelaySession.() -> Unit ): WRelay { CodecRegistry.getLatestCodec() - + return WRelay( localAddress = localAddress, advertisement = advertisement @@ -30,40 +23,9 @@ fun captureGamePacket( ) } -fun authorize( - cache: Boolean = true, - file: File? = Paths.get(".").resolve("bedrockSession.json").toFile(), - msaDeviceCodeCallback: MsaDeviceCodeCallback = MsaDeviceCodeCallback { - println("Go to ${it.directVerificationUri}") - } -): StepFullBedrockSession.FullBedrockSession { - if (cache && file != null && file.exists()) { - val json = JsonParser.parseString(file.readText()).asJsonObject - return MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN.fromJson(json) - } - - val httpClient = MinecraftAuth.createHttpClient() - httpClient.connectTimeout = 30000 - httpClient.readTimeout = 30000 - httpClient.setRetryHandler(RetryHandler(3, Int.MAX_VALUE)) - - val fullBedrockSession = MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN - .getFromInput(httpClient, msaDeviceCodeCallback) - - if (cache && file != null && !file.isDirectory) { - val json = AuthUtils.gson.toJson( - MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN.toJson(fullBedrockSession) - ) - file.writeText(json) - } - - return fullBedrockSession -} - -fun StepFullBedrockSession.FullBedrockSession.refresh(): StepFullBedrockSession.FullBedrockSession { - val httpClient = MinecraftAuth.createHttpClient() - httpClient.connectTimeout = 10000 - httpClient.readTimeout = 15000 - httpClient.setRetryHandler(RetryHandler(2, Int.MAX_VALUE)) - return MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN.refresh(httpClient, this) -} \ No newline at end of file +// NOTE: this file previously also had a standalone authorize() function and a +// StepFullBedrockSession.FullBedrockSession.refresh() extension, both built on the MinecraftAuth +// 4.x step-chain API. Neither was referenced anywhere outside this file (grep-confirmed), and +// BedrockAuthManager (5.x) refreshes its own tokens internally via getUpToDate() - there's no +// longer a separate "session object" that needs a matching refresh() helper - so both were +// removed rather than ported. diff --git a/settings.gradle.kts b/settings.gradle.kts index a04989b4..1420d5e4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,6 +15,11 @@ pluginManagement { maven("https://jitpack.io") } } + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { @@ -31,11 +36,13 @@ rootProject.name = "WClient" include(":app") include(":relay") include( - ":relay:adventure", - ":relay:Protocol:bedrock-codec", - ":relay:Protocol:bedrock-connection", - ":relay:Protocol:common", ":relay:Network:codec-query", ":relay:Network:codec-rcon", ":relay:Network:transport-raknet", ) +// relay:Protocol:bedrock-codec / bedrock-connection / common are no longer built from the local +// vendored source (see relay/build.gradle.kts) - they're pulled from Maven instead so we get +// current Bedrock protocol support. Leaving the source directories in place, just not building them. +// relay:Protocol:adventure was also removed from this list: it pointed at a nonexistent +// "relay/adventure" directory (the real path is "relay/Protocol/adventure") and nothing in the +// project actually depended on it as a project(...), so it was dead/broken either way.