From 9b58c20268c4e2146da986d4f64332d22bd9f99e Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 14:41:09 +0900 Subject: [PATCH 01/82] Add GitHub Actions workflow to build APK --- build.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 build.yml 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 From 423f2de9e1378b80df717805c5d73d05606c915e Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 14:42:51 +0900 Subject: [PATCH 02/82] Add GitHub Actions workflow for building APK --- .github/build.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/build.yml diff --git a/.github/build.yml b/.github/build.yml new file mode 100644 index 00000000..c3154574 --- /dev/null +++ b/.github/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 From a9a8c086c702c4734dd97ecf906bd69697bde6bf Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 15:11:19 +0900 Subject: [PATCH 03/82] Add GitHub Actions workflow to build APK --- .github/workflows/build.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/build.yml 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 From 309e22a0b6bc98b315f0bc58fb993cd62867089a Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 15:11:58 +0900 Subject: [PATCH 04/82] Delete .github/build.yml --- .github/build.yml | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 .github/build.yml diff --git a/.github/build.yml b/.github/build.yml deleted file mode 100644 index c3154574..00000000 --- a/.github/build.yml +++ /dev/null @@ -1,17 +0,0 @@ -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 From 6639010cc9548f728b2de569db71f60201df7acb Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 15:29:21 +0900 Subject: [PATCH 05/82] Add files via upload --- .../game/module/combat/PistonCrystalModule.kt | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 app/src/main/java/com/retrivedmods/wclient/game/module/combat/PistonCrystalModule.kt 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..3d1d3f73 --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/PistonCrystalModule.kt @@ -0,0 +1,344 @@ +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.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 + +/** + * Places an obsidian base + end crystal next to a target, plus an (best-effort) piston + redstone + * setup to try to pop the crystal via a block push instead of hitting it. Ported from the + * PistonCrystal/PistonCrystal2 C++ reference + ideas from ModuleCrystalAura.kt (ProtoHax), now that + * WClient has real block lookups via [com.retrivedmods.wclient.game.world.Level.getBlockAt]. + * + * Known limitations vs the original: + * - Piston orientation in Bedrock is set from the placer's look direction at placement time. This + * fakes that by mutating the outgoing PlayerAuthInputPacket's rotation right before placing, the + * same trick AntiCrystal/Surround use for position - it's not guaranteed as reliable as an actual + * client rotating for real. + * - Block data for chunks loaded via blob caching or the newer per-subchunk-request system isn't + * tracked (see Level.kt), so getBlockAt() may report air for those even if something's really + * there. In that case placements will just be rejected server-side, same as if this weren't + * checked at all. + * - Damage estimation (session.level.simulateExplosionDamage) is a distance-only approximation with + * no block-occlusion/exposure factor, so it's an upper bound, not exact. + * - Direct-attack fallback (autoAttackCrystal) is what actually guarantees detonation; the + * piston/redstone path is a best-effort bonus on top of that, not a replacement for it. + */ +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 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 + } + + private enum class Step { OBSIDIAN, CRYSTAL, PISTON, REDSTONE, DONE } + + private var step = Step.DONE + private var obsidianPos: Vector3i? = null + private var pistonPos: Vector3i? = null + private var redstonePos: Vector3i? = null + private var pushDir: Vector3i? = 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 + obsidianPos = null + pistonPos = null + redstonePos = null + pushDir = null + tickCounter = 0 + oldSlot = -1 + lastCrystalAttack.clear() + } + + override fun beforePacketBound(interceptablePacket: InterceptablePacket) { + if (!isEnabled || !isSessionCreated) return + val packet = interceptablePacket.packet + if (packet !is PlayerAuthInputPacket) return + + // Always try to guarantee detonation of any crystal that's already out there, regardless + // of whether we're mid-placement - this is the part that actually reliably deals damage. + if (autoAttackCrystal) { + attackNearbyCrystals() + } + + if (step == Step.DONE) { + val target = findTarget() ?: return + val base = findPlacementSpot(target) ?: return + beginPlacement(base, target) + } + + if (tickCounter < placeDelayTicks) { + tickCounter++ + return + } + tickCounter = 0 + + advanceStateMachine(packet) + } + + // --- target / spot 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 + } + } + + /** + * Looks for a block directly adjacent to the target (one of the 4 cardinal neighbours at the + * target's feet level) that's solid, with air immediately above it (room for obsidian -> crystal). + * Real getBlockAt() checks now that Level tracks chunk data. + */ + private fun findPlacementSpot(target: Entity): Vector3i? { + val targetPos = target.vec3Position + val baseX = floor(targetPos.x).toInt() + val baseY = floor(targetPos.y).toInt() + val baseZ = floor(targetPos.z).toInt() + + val offsets = listOf(1 to 0, -1 to 0, 0 to 1, 0 to -1) + for ((dx, dz) in offsets) { + val candidate = Vector3i.from(baseX + dx, baseY - 1, baseZ + dz) + val at = session.level.getBlockAt(candidate.x, candidate.y + 1, candidate.z) + // crystal spot itself must be clear; the block below may be air too - in that case + // we'll place our own obsidian there first (see isObsidianNeeded-equivalent check + // in beginPlacement). + if (at.identifier.isAirLike()) { + if (session.localPlayer.distance(Vector3f.from(candidate.x + 0.5f, candidate.y + 1f, candidate.z + 0.5f)) <= range + 1f) { + return candidate + } + } + } + return null + } + + private fun String.isAirLike(): Boolean { + return this == "minecraft:air" + } + + // --- placement sequencing -------------------------------------------------------------- + + private fun beginPlacement(base: Vector3i, target: Entity) { + val localPlayer = session.localPlayer + + val crystalCenter = Vector3f.from(base.x + 0.5f, base.y + 2f, base.z + 0.5f) + var estimatedTargetDamage = 0f + var estimatedSelfDamage = 0f + session.level.simulateExplosionDamage( + crystalCenter, + EXPLOSION_SIZE, + extraEntities = listOf(localPlayer) + ) { entity, damage -> + if (entity.runtimeEntityId == target.runtimeEntityId) estimatedTargetDamage = damage + if (entity is LocalPlayer) estimatedSelfDamage = damage + } + + if (estimatedTargetDamage < targetDamageMin) return + if (estimatedSelfDamage > selfDamageLimit) return + + obsidianPos = base + + // matches PistonCrystal2's isObsidianNeeded(): only place obsidian if the spot doesn't + // already have a solid block there (e.g. the target is already standing on real ground). + val obsidianAlreadyPresent = !session.level.getBlockAt(base).identifier.isAirLike() + + // push direction: away from the target, so a piston push (if it lands) knocks the + // obsidian - and whatever's resting on it - out from under the crystal. + val targetPos = target.vec3Position + val dx = base.x + 0.5f - targetPos.x + val dz = base.z + 0.5f - targetPos.z + pushDir = if (kotlin.math.abs(dx) > kotlin.math.abs(dz)) { + Vector3i.from(if (dx > 0) 1 else -1, 0, 0) + } else { + Vector3i.from(0, 0, if (dz > 0) 1 else -1) + } + pistonPos = base.add(pushDir) + redstonePos = pistonPos!!.add(pushDir) + + step = if (obsidianAlreadyPresent) Step.CRYSTAL else Step.OBSIDIAN + tickCounter = 0 + } + + private fun advanceStateMachine(packet: PlayerAuthInputPacket) { + when (step) { + Step.OBSIDIAN -> { + val pos = obsidianPos ?: return resetState() + if (place(OBSIDIAN, pos, packet, faceUp = true)) { + step = Step.CRYSTAL + } + } + + Step.CRYSTAL -> { + val pos = obsidianPos ?: return resetState() + val crystalPos = pos.add(0, 1, 0) + if (place(CRYSTAL_ITEM, crystalPos, packet, faceUp = true)) { + step = if (usePiston) Step.PISTON else Step.DONE + if (!usePiston) resetState() + } + } + + Step.PISTON -> { + val pos = pistonPos ?: return resetState() + val dir = pushDir ?: return resetState() + if (place(PISTON, pos, packet, faceUp = false, lookTowards = obsidianPos, pushDir = dir)) { + step = Step.REDSTONE + } + } + + Step.REDSTONE -> { + val pos = redstonePos ?: return resetState() + if (place(REDSTONE_BLOCK, pos, packet, faceUp = false)) { + resetState() + } + } + + Step.DONE -> {} + } + } + + /** + * Sends one block placement attempt. Returns true whether or not the item was found/placed so + * the state machine keeps moving forward - we can't confirm server-side success without real + * world feedback, so we don't retry indefinitely. + */ + private fun place( + identifier: String, + pos: Vector3i, + authInput: PlayerAuthInputPacket, + faceUp: Boolean, + lookTowards: Vector3i? = null, + pushDir: Vector3i? = null + ): Boolean { + val localPlayer = session.localPlayer + val slot = localPlayer.inventory.searchForItemInHotbar { + it.definition?.identifier == identifier + } ?: return true // nothing to place with, don't get stuck retrying forever + + 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) + } + + val transaction = InventoryTransactionPacket().apply { + transactionType = InventoryTransactionType.ITEM_USE + actionType = 0 // click block / place + blockPosition = pos + blockFace = if (faceUp) 1 else (pushDir?.let { faceForDirection(it) } ?: 1) + hotbarSlot = slot + itemInHand = localPlayer.inventory.hand + playerPosition = localPlayer.vec3Position + clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) + } + + session.serverBound(transaction) + 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) { + // matches the existing (working) HotbarSwitcherModule pattern exactly - only clientBound. + val packet = PlayerHotbarPacket().apply { + selectedHotbarSlot = slot + containerId = 0 + selectHotbarSlot = true + } + session.clientBound(packet) + } + + // --- 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) + } + } +} From feac11933c4ab1e502f9ccb306c7321386915ea8 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 15:30:30 +0900 Subject: [PATCH 06/82] Add files via upload --- .../game/module/combat/SurroundModule.kt | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 app/src/main/java/com/retrivedmods/wclient/game/module/combat/SurroundModule.kt 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..aee3fa73 --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/combat/SurroundModule.kt @@ -0,0 +1,183 @@ +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.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 + +/** + * Places blocks (obsidian by default) around the player to reduce end crystal / explosion damage. + * + * NOTE: unlike the original C++ (Mod-Menu-style) implementation this was ported from, WClient is a + * relay/proxy client and does not keep a local copy of world block state (no BlockSource / chunk data). + * Because of that this version can NOT check whether a target position is actually air, replaceable, + * or already occupied before placing - it just fires placement packets at the 8 positions around the + * player each tick and lets the server accept or silently reject them, the same way a real client's + * packet would be validated server-side. "Dynamic" hitbox expansion is also approximated using a fixed + * player-sized radius per nearby entity (since Entity here has no AABB/hitbox size), not true AABB + * collision like the original. + */ +class SurroundModule : Module("surround", ModuleCategory.Combat) { + + private var blockIdentifier by stringValue("block", "minecraft:obsidian", null) + + private var placeDelayTicks by intValue("place_delay", 1, 0..20) + private var blocksPerTick by intValue("blocks_per_tick", 4, 1..8) + private var placeBelowFeet by boolValue("place_below", true) + private var center by boolValue("center", true) + private var dynamic by boolValue("dynamic", true) + private var dynamicRadius by floatValue("dynamic_radius", 3f, 1f..6f) + + private var placeQueue: MutableList = mutableListOf() + private var tickCounter = 0 + private var oldSlot = -1 + private var hasCentered = false + + override fun onEnabled() { + super.onEnabled() + placeQueue = mutableListOf() + tickCounter = 0 + oldSlot = -1 + hasCentered = false + } + + override fun onDisabled() { + super.onDisabled() + placeQueue.clear() + if (oldSlot != -1 && isSessionCreated) { + restoreSlot() + } + 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 + + val obsidianSlot = localPlayer.inventory.searchForItemInHotbar { + it.definition?.identifier == blockIdentifier + } + + if (obsidianSlot == null) { + // nothing to place with, don't spam the server + return + } + + if (oldSlot == -1) { + oldSlot = localPlayer.inventory.heldItemSlot + } + + if (center && !hasCentered) { + val pos = localPlayer.vec3Position + val centeredPos = Vector3f.from(floor(pos.x) + 0.5f, pos.y, floor(pos.z) + 0.5f) + packet.position = centeredPos + hasCentered = true + } + + if (placeQueue.isEmpty()) { + placeQueue = computePlacements(localPlayer.vec3Position).toMutableList() + } + + if (tickCounter < placeDelayTicks) { + tickCounter++ + return + } + tickCounter = 0 + + if (localPlayer.inventory.heldItemSlot != obsidianSlot) { + switchToSlot(obsidianSlot) + } + + var placed = 0 + val iterator = placeQueue.iterator() + while (iterator.hasNext() && placed < blocksPerTick) { + val target = iterator.next() + placeBlock(target, obsidianSlot) + iterator.remove() + placed++ + } + } + + private fun computePlacements(playerPos: Vector3f): List { + val baseX = floor(playerPos.x).toInt() + val baseY = floor(playerPos.y).toInt() - if (placeBelowFeet) 1 else 0 + val baseZ = floor(playerPos.z).toInt() + + var minX = -1 + var maxX = 1 + var minZ = -1 + var maxZ = 1 + + if (dynamic) { + val level = session.level + level.entityMap.values.forEach { entity -> + if (entity.distance(playerPos) > dynamicRadius) return@forEach + val dx = entity.posX - playerPos.x + val dz = entity.posZ - playerPos.z + if (dx > 1.2f) maxX = maxOf(maxX, 2) + if (dx < -1.2f) minX = minOf(minX, -2) + if (dz > 1.2f) maxZ = maxOf(maxZ, 2) + if (dz < -1.2f) minZ = minOf(minZ, -2) + } + } + + val positions = mutableListOf() + for (x in minX..maxX) { + for (z in minZ..maxZ) { + if (x == 0 && z == 0) continue + // only the ring around the player, skip interior tiles when the box grew beyond 3x3 + if (x !in -1..1 && z !in -1..1) continue + positions.add(Vector3i.from(baseX + x, baseY, baseZ + z)) + } + } + return positions + } + + private fun switchToSlot(slot: Int) { + val packet = PlayerHotbarPacket().apply { + selectedHotbarSlot = slot + containerId = 0 + selectHotbarSlot = true + } + session.serverBound(packet) + session.clientBound(packet) + } + + private fun restoreSlot() { + switchToSlot(oldSlot) + } + + private fun placeBlock(pos: Vector3i, slot: Int) { + val localPlayer = session.localPlayer + val runtimeId = session.blockMapping.getRuntimeIdByIdentifier(blockIdentifier) ?: return + + val packet = InventoryTransactionPacket().apply { + transactionType = InventoryTransactionType.ITEM_USE + actionType = 0 // click block / place + blockPosition = pos + blockFace = 1 // top face, doesn't matter much for a fresh air target + hotbarSlot = slot + itemInHand = localPlayer.inventory.hand + playerPosition = localPlayer.vec3Position + clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) + blockRuntimeId = runtimeId + } + + session.serverBound(packet) + } +} From b7b34f11de905cbc3595fd1b442d46d34a742896 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 15:31:26 +0900 Subject: [PATCH 07/82] Add files via upload --- .../game/module/combat/AntiCrystalModule.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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..1df067b8 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,31 @@ 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 } + 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) + } } } From c47b4ff1dccc7ac5dba061073ebb0b3e34e067e6 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 15:36:45 +0900 Subject: [PATCH 08/82] Add files via upload --- .../retrivedmods/wclient/game/world/Level.kt | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) 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..67cdbd87 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,26 @@ 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.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.TakeItemEntityPacket +import org.cloudburstmc.protocol.bedrock.packet.UpdateBlockPacket +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 +33,25 @@ class Level(val session: GameSession) { val playerMap = ConcurrentHashMap() + // --- world/chunk block tracking ----------------------------------------------------------- + // Ported (with real changes, see Chunk/ChunkSection/BlockStorage) from ProtoHax. Only the + // "normal" full LevelChunkPacket path is handled - servers using blob (disk) caching or the + // newer per-subchunk request system (SubChunkPacket) won't have their blocks tracked here, + // since that needs a blob cache we don't implement. UpdateBlockPacket (individual block + // changes) IS handled, so blocks placed/broken after a chunk loads stay accurate. + + val chunks = ConcurrentHashMap() + + var is384WorldSupported = false + private set + + var viewDistance = -1 + private set + fun onDisconnect() { entityMap.clear() playerMap.clear() + chunks.clear() } fun onPacketBound(packet: BedrockPacket) { @@ -33,6 +59,61 @@ 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::blockMapping.isInitialized) { + // shouldn't normally happen (StartGamePacket sets blockMapping before Level + // sees it), but guard anyway since a missing mapping would crash chunk parsing + return + } + + if (packet.isCachingEnabled || packet.isRequestSubChunks) { + // blob-cache / newer subchunk-request chunk loading isn't supported, see note above + return + } + + val chunk = Chunk(packet.chunkX, packet.chunkZ, is384WorldSupported, session.blockMapping) + try { + // 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) + chunks[chunk.hash] = chunk + } catch (e: Exception) { + // malformed/unexpected chunk data for this protocol version - skip it rather + // than crash the relay + } + } + + is UpdateBlockPacket -> { + if (packet.dataLayer == 0) { + setBlockIdAt( + packet.blockPosition.x, + packet.blockPosition.y, + packet.blockPosition.z, + packet.definition.runtimeId + ) + } + } + + is ChunkRadiusUpdatedPacket -> { + viewDistance = packet.radius + } + + is ChangeDimensionPacket -> { + chunks.clear() } is AddEntityPacket -> { @@ -100,4 +181,68 @@ 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::blockMapping.isInitialized) 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::blockMapping.isInitialized) 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) + } + } \ No newline at end of file From 313ae0d3e8324795d9f1978fe0fb098951e41bbf Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 15:42:42 +0900 Subject: [PATCH 09/82] Implement BlockStorage class for chunk management This class manages block storage using a runtime-id palette format, supporting dynamic block management. --- .../wclient/game/world/chunk/BlockStorage.kt | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 app/src/main/java/com/retrivedmods/wclient/game/world/chunk/BlockStorage.kt 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 + } +} From 9342ea0c251b1d759b880a48771a5e91ae04b738 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 15:43:10 +0900 Subject: [PATCH 10/82] Add files via upload --- .../wclient/game/world/chunk/Chunk.kt | 66 +++++++++++++++++++ .../wclient/game/world/chunk/ChunkSection.kt | 56 ++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 app/src/main/java/com/retrivedmods/wclient/game/world/chunk/Chunk.kt create mode 100644 app/src/main/java/com/retrivedmods/wclient/game/world/chunk/ChunkSection.kt 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) + } +} From 39a4ee85201f72f6dff9deb47d8631b2a47b79c2 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 15:45:30 +0900 Subject: [PATCH 11/82] Add BitArray interface for bit manipulation This interface defines methods for manipulating bit arrays, including setting and getting values, retrieving size and words, and copying the array. --- .../game/world/chunk/palette/BitArray.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/BitArray.java 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; + } +} From 6f9748647461e85c726d006c48b675f705b993a6 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 15:46:05 +0900 Subject: [PATCH 12/82] Add files via upload --- .../world/chunk/palette/BitArrayVersion.java | 65 ++++++++++++++ .../world/chunk/palette/PaddedBitArray.java | 75 ++++++++++++++++ .../world/chunk/palette/Pow2BitArray.java | 87 +++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/BitArrayVersion.java create mode 100644 app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/PaddedBitArray.java create mode 100644 app/src/main/java/com/retrivedmods/wclient/game/world/chunk/palette/Pow2BitArray.java 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)); + } +} From e66a1a814c8b71d3b0f7c4e605206db85aa5ce75 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 16:17:26 +0900 Subject: [PATCH 13/82] Add files via upload --- .../retrivedmods/wclient/game/world/Level.kt | 180 +++++------------- 1 file changed, 50 insertions(+), 130 deletions(-) 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 67cdbd87..faf9d602 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,23 +5,16 @@ 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.math.vector.Vector3f 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.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.TakeItemEntityPacket -import org.cloudburstmc.protocol.bedrock.packet.UpdateBlockPacket -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 @@ -33,25 +26,13 @@ class Level(val session: GameSession) { val playerMap = ConcurrentHashMap() - // --- world/chunk block tracking ----------------------------------------------------------- - // Ported (with real changes, see Chunk/ChunkSection/BlockStorage) from ProtoHax. Only the - // "normal" full LevelChunkPacket path is handled - servers using blob (disk) caching or the - // newer per-subchunk request system (SubChunkPacket) won't have their blocks tracked here, - // since that needs a blob cache we don't implement. UpdateBlockPacket (individual block - // changes) IS handled, so blocks placed/broken after a chunk loads stay accurate. - - val chunks = ConcurrentHashMap() - - var is384WorldSupported = false - private set - - var viewDistance = -1 + // Kept for compatibility with ProtoHax-style world/dimension handling. + var dimension: Int = 0 private set fun onDisconnect() { entityMap.clear() playerMap.clear() - chunks.clear() } fun onPacketBound(packet: BedrockPacket) { @@ -59,61 +40,7 @@ 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::blockMapping.isInitialized) { - // shouldn't normally happen (StartGamePacket sets blockMapping before Level - // sees it), but guard anyway since a missing mapping would crash chunk parsing - return - } - - if (packet.isCachingEnabled || packet.isRequestSubChunks) { - // blob-cache / newer subchunk-request chunk loading isn't supported, see note above - return - } - - val chunk = Chunk(packet.chunkX, packet.chunkZ, is384WorldSupported, session.blockMapping) - try { - // 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) - chunks[chunk.hash] = chunk - } catch (e: Exception) { - // malformed/unexpected chunk data for this protocol version - skip it rather - // than crash the relay - } - } - - is UpdateBlockPacket -> { - if (packet.dataLayer == 0) { - setBlockIdAt( - packet.blockPosition.x, - packet.blockPosition.y, - packet.blockPosition.z, - packet.definition.runtimeId - ) - } - } - - is ChunkRadiusUpdatedPacket -> { - viewDistance = packet.radius - } - - is ChangeDimensionPacket -> { - chunks.clear() + dimension = packet.dimensionId } is AddEntityPacket -> { @@ -131,7 +58,10 @@ class Level(val session: GameSession) { } is AddItemEntityPacket -> { - val entity = Item(packet.runtimeEntityId, packet.uniqueEntityId).apply { + val entity = Item( + packet.runtimeEntityId, + packet.uniqueEntityId + ).apply { move(packet.position) handleSetData(packet.metadata) } @@ -173,6 +103,10 @@ class Level(val session: GameSession) { } } + is ChangeDimensionPacket -> { + dimension = packet.dimension + } + else -> { entityMap.values.forEach { entity -> entity.onPacketBound(packet) @@ -182,67 +116,53 @@ 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. + * Removes an entity locally and sends the corresponding RemoveEntityPacket + * to the server, matching ProtoHax's Level.removeEntity behavior. + */ + fun removeEntity(entity: Entity) { + if (entityMap.remove(entity.runtimeEntityId) == null) return + + session.serverBound(RemoveEntityPacket().apply { + uniqueEntityId = entity.uniqueEntityId + }) + } + + /** + * Simulates vanilla-style explosion damage against tracked entities and + * additional entities supplied by the caller. */ fun simulateExplosionDamage( center: Vector3f, size: Float, - extraEntities: List = emptyList(), + extraEntities: List, 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) - } + if (size <= 0f) return - // --- block query/update helpers ----------------------------------------------------------- + val explosionSearchSizeSq = (size * 2).pow(2) - fun getChunkAt(chunkX: Int, chunkZ: Int): Chunk? = chunks[Chunk.hash(chunkX, chunkZ)] + entityMap.values + .filter { it.distanceSq(center) < explosionSearchSizeSq } + .forEach { entity -> + val distance = entity.distance(center) / size - 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::blockMapping.isInitialized) 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::blockMapping.isInitialized) return UnknownBlockDefinition(0) - return session.blockMapping.getDefinition(getBlockIdAt(x, y, z)) - } + if (distance <= 1f) { + val impact = 1f - distance + val damage = ((impact * impact + impact) / 2f) * 8f * size + 1f + damageCallback(entity, damage) + } + } - fun getBlockAt(pos: Vector3i): BlockDefinition = getBlockAt(pos.x, pos.y, pos.z) + extraEntities + .filter { it.distanceSq(center) < explosionSearchSizeSq } + .forEach { entity -> + val distance = entity.distance(center) / size - 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) + if (distance <= 1f) { + val impact = 1f - distance + val damage = ((impact * impact + impact) / 2f) * 8f * size + 1f + damageCallback(entity, damage) + } + } } - -} \ No newline at end of file +} From 2191a0cc7f3155958f913a83747332f88f3a3eca Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 16:21:13 +0900 Subject: [PATCH 14/82] Add files via upload --- .../retrivedmods/wclient/game/world/Level.kt | 180 +++++++++++++----- 1 file changed, 130 insertions(+), 50 deletions(-) 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 faf9d602..67cdbd87 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,23 @@ 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 org.cloudburstmc.math.vector.Vector3f +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.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.TakeItemEntityPacket +import org.cloudburstmc.protocol.bedrock.packet.UpdateBlockPacket +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 @@ -26,13 +33,25 @@ class Level(val session: GameSession) { val playerMap = ConcurrentHashMap() - // Kept for compatibility with ProtoHax-style world/dimension handling. - var dimension: Int = 0 + // --- world/chunk block tracking ----------------------------------------------------------- + // Ported (with real changes, see Chunk/ChunkSection/BlockStorage) from ProtoHax. Only the + // "normal" full LevelChunkPacket path is handled - servers using blob (disk) caching or the + // newer per-subchunk request system (SubChunkPacket) won't have their blocks tracked here, + // since that needs a blob cache we don't implement. UpdateBlockPacket (individual block + // changes) IS handled, so blocks placed/broken after a chunk loads stay accurate. + + val chunks = ConcurrentHashMap() + + var is384WorldSupported = false + private set + + var viewDistance = -1 private set fun onDisconnect() { entityMap.clear() playerMap.clear() + chunks.clear() } fun onPacketBound(packet: BedrockPacket) { @@ -40,7 +59,61 @@ class Level(val session: GameSession) { is StartGamePacket -> { entityMap.clear() playerMap.clear() - dimension = packet.dimensionId + 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::blockMapping.isInitialized) { + // shouldn't normally happen (StartGamePacket sets blockMapping before Level + // sees it), but guard anyway since a missing mapping would crash chunk parsing + return + } + + if (packet.isCachingEnabled || packet.isRequestSubChunks) { + // blob-cache / newer subchunk-request chunk loading isn't supported, see note above + return + } + + val chunk = Chunk(packet.chunkX, packet.chunkZ, is384WorldSupported, session.blockMapping) + try { + // 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) + chunks[chunk.hash] = chunk + } catch (e: Exception) { + // malformed/unexpected chunk data for this protocol version - skip it rather + // than crash the relay + } + } + + is UpdateBlockPacket -> { + if (packet.dataLayer == 0) { + setBlockIdAt( + packet.blockPosition.x, + packet.blockPosition.y, + packet.blockPosition.z, + packet.definition.runtimeId + ) + } + } + + is ChunkRadiusUpdatedPacket -> { + viewDistance = packet.radius + } + + is ChangeDimensionPacket -> { + chunks.clear() } is AddEntityPacket -> { @@ -58,10 +131,7 @@ class Level(val session: GameSession) { } is AddItemEntityPacket -> { - val entity = Item( - packet.runtimeEntityId, - packet.uniqueEntityId - ).apply { + val entity = Item(packet.runtimeEntityId, packet.uniqueEntityId).apply { move(packet.position) handleSetData(packet.metadata) } @@ -103,10 +173,6 @@ class Level(val session: GameSession) { } } - is ChangeDimensionPacket -> { - dimension = packet.dimension - } - else -> { entityMap.values.forEach { entity -> entity.onPacketBound(packet) @@ -116,53 +182,67 @@ class Level(val session: GameSession) { } /** - * Removes an entity locally and sends the corresponding RemoveEntityPacket - * to the server, matching ProtoHax's Level.removeEntity behavior. - */ - fun removeEntity(entity: Entity) { - if (entityMap.remove(entity.runtimeEntityId) == null) return - - session.serverBound(RemoveEntityPacket().apply { - uniqueEntityId = entity.uniqueEntityId - }) - } - - /** - * Simulates vanilla-style explosion damage against tracked entities and - * additional entities supplied by the caller. + * 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, + extraEntities: List = emptyList(), damageCallback: (Entity, Float) -> Unit ) { - if (size <= 0f) return + val searchRadiusSq = (size * 2).pow(2) - val explosionSearchSizeSq = (size * 2).pow(2) + fun evaluate(entity: Entity) { + val distSq = entity.distanceSq(center) + if (distSq >= searchRadiusSq) return - entityMap.values - .filter { it.distanceSq(center) < explosionSearchSizeSq } - .forEach { entity -> - 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) - } + 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) } + } - extraEntities - .filter { it.distanceSq(center) < explosionSearchSizeSq } - .forEach { entity -> - val distance = entity.distance(center) / size + entityMap.values.forEach(::evaluate) + extraEntities.forEach(::evaluate) + } - if (distance <= 1f) { - val impact = 1f - distance - val damage = ((impact * impact + impact) / 2f) * 8f * size + 1f - damageCallback(entity, damage) - } - } + // --- 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::blockMapping.isInitialized) 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::blockMapping.isInitialized) 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) } -} + +} \ No newline at end of file From d50eb3061f4e9f12e36ee4d2a3f5aec5bb846c3c Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 16:37:39 +0900 Subject: [PATCH 15/82] Add files via upload --- .../main/java/com/retrivedmods/wclient/game/world/Level.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 67cdbd87..140f94ce 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 @@ -71,7 +71,7 @@ class Level(val session: GameSession) { } is LevelChunkPacket -> { - if (!session::blockMapping.isInitialized) { + 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 @@ -227,14 +227,14 @@ class Level(val session: GameSession) { */ fun getBlockIdAt(x: Int, y: Int, z: Int): Int { val chunk = getChunkAt(x shr 4, z shr 4) - ?: return if (session::blockMapping.isInitialized) session.blockMapping.airId else 0 + ?: 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::blockMapping.isInitialized) return UnknownBlockDefinition(0) + if (!session.isBlockMappingInitialized) return UnknownBlockDefinition(0) return session.blockMapping.getDefinition(getBlockIdAt(x, y, z)) } From bb26142582e85e7d09374cdbcf91e51efd923b5b Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 16:38:05 +0900 Subject: [PATCH 16/82] Add files via upload --- .../main/java/com/retrivedmods/wclient/game/GameSession.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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..2f7b54f7 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt @@ -33,6 +33,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) { From ada0ac8c050a3367887c3f75d239b552bde6e566 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 16:46:32 +0900 Subject: [PATCH 17/82] Add files via upload --- .../wclient/game/registry/BlockMapping.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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..cd93fefa 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,6 +26,21 @@ 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 { fun read(context: Context, version: Short): BlockMapping { val path = "mcpedata/blocks/runtime_block_states_$version.dat" From b89421040494558e2d06518c492bfb1e7f5be7ec Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Tue, 18 Aug 2026 16:56:16 +0900 Subject: [PATCH 18/82] Add files via upload --- .../retrivedmods/wclient/game/module/combat/SurroundModule.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index aee3fa73..c693207b 100644 --- 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 @@ -165,6 +165,7 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { private fun placeBlock(pos: Vector3i, slot: Int) { val localPlayer = session.localPlayer val runtimeId = session.blockMapping.getRuntimeIdByIdentifier(blockIdentifier) ?: return + val definitionToPlace = session.blockMapping.getDefinition(runtimeId) val packet = InventoryTransactionPacket().apply { transactionType = InventoryTransactionType.ITEM_USE @@ -175,7 +176,7 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { itemInHand = localPlayer.inventory.hand playerPosition = localPlayer.vec3Position clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) - blockRuntimeId = runtimeId + blockDefinition = definitionToPlace } session.serverBound(packet) From 10368da264c7be07e24c1dcdad1bf4da93b9d3e1 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Wed, 19 Aug 2026 00:57:00 +0900 Subject: [PATCH 19/82] Add files via upload --- .../main/java/com/retrivedmods/wclient/game/ModuleManager.kt | 4 ++++ 1 file changed, 4 insertions(+) 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..9b709fde 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 @@ -99,6 +101,8 @@ object ModuleManager { add(AntiKnockbackModule()) add(AntiCrystalModule()) + add(SurroundModule()) + add(PistonCrystalModule()) add(HitAndRunModule()) add(HitboxModule()) add(CrystalSmashModule()) From b138e3660617ac717ef513e1230f9dea4e9b94a3 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Wed, 19 Aug 2026 01:01:41 +0900 Subject: [PATCH 20/82] Add files via upload --- .../wclient/activity/MainActivity.kt | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) 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) { From 9c29bea3f0cebab4f5496ac85f4555ed9e2f81d0 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Wed, 19 Aug 2026 12:11:58 +0900 Subject: [PATCH 21/82] Add files via upload --- .../wclient/ui/component/AuthWebView.kt | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) 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..dfc6f974 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 @@ -41,14 +41,30 @@ class AuthWebView @JvmOverloads constructor( httpClient.connectTimeout = 10000 httpClient.readTimeout = 10000 - val fullBedrockSession = RealmsAuthFlow.BEDROCK_DEVICE_CODE_LOGIN_WITH_REALMS.getFromInput( - httpClient, - StepMsaDeviceCode.MsaDeviceCodeCallback { - post { - loadUrl(it.directVerificationUri) - } + val deviceCodeCallback = StepMsaDeviceCode.MsaDeviceCodeCallback { + post { + loadUrl(it.directVerificationUri) } - ) + } + + // Try the Realms-capable auth chain first, but fall back to the plain Bedrock + // chain if it fails - accounts without a Realms subscription/entitlement can + // fail the extra Realms XSTS step even though normal sign-in would have worked + // fine. (AccountManager already does this same fallback for save/load/refresh; + // this was the one place it was missing, and the likely cause of "can't sign in" + // for anyone without Realms.) + val fullBedrockSession = try { + RealmsAuthFlow.BEDROCK_DEVICE_CODE_LOGIN_WITH_REALMS.getFromInput( + httpClient, + deviceCodeCallback + ) + } catch (e: Exception) { + println("Realms-capable sign-in failed, falling back to regular sign-in: ${e.message}") + MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN.getFromInput( + httpClient, + deviceCodeCallback + ) + } val containedAccount = AccountManager.accounts.find { it.mcChain.displayName == fullBedrockSession.mcChain.displayName } if (containedAccount != null) { From 12df4f604a01ee6eef14aa1cd3c0a5551f7b7552 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Wed, 19 Aug 2026 12:12:48 +0900 Subject: [PATCH 22/82] Add files via upload --- build.gradle.kts | 62 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index bc96b873..5736fed6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,21 +1,51 @@ plugins { - alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.kotlin.compose) apply false - alias(libs.plugins.lombok) apply false - kotlin("jvm") version "2.2.20" apply false - kotlin("plugin.serialization") version "2.2.20" apply false - id("com.github.johnrengelman.shadow") version "8.1.1" apply false + id("java-library") + kotlin("jvm") } -tasks.register("buildAll") { - group = "build" - description = "Builds both Android APK and Desktop JAR" - dependsOn(":app:assembleRelease", ":desktop:shadowJar") +group = "com.retrivedmods.wrelay" +version = "1.0-SNAPSHOT" + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } } -tasks.register("buildDebugAll") { - group = "build" - description = "Builds both Android Debug APK and Desktop JAR" - dependsOn(":app:assembleDebug", ":desktop:shadowJar") -} \ No newline at end of file +dependencies { + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + + implementation(platform(libs.log4j.bom)) + implementation(libs.log4j.api) + implementation(libs.log4j.core) + implementation(libs.jose4j) + implementation(libs.jackson.databind) + implementation(libs.kotlinx.coroutines.core) + + // Use api to expose these to the app module + api(libs.minecraft.auth) + api(project(":relay:Network:transport-raknet")) + + // 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. + api("org.cloudburstmc.protocol:bedrock-codec:3.0.0.Beta12-SNAPSHOT") + api("org.cloudburstmc.protocol:bedrock-connection:3.0.0.Beta12-SNAPSHOT") + api("org.cloudburstmc.protocol:common:3.0.0.Beta12-SNAPSHOT") + api(libs.bundles.netty) + + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} + +kotlin { + jvmToolchain(17) +} From f076d8223f22ec9e6acf2565f95d1d1c16dced1a Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Wed, 19 Aug 2026 12:13:58 +0900 Subject: [PATCH 23/82] Add files via upload --- relay/build.gradle.kts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/relay/build.gradle.kts b/relay/build.gradle.kts index 8e2d1185..5736fed6 100644 --- a/relay/build.gradle.kts +++ b/relay/build.gradle.kts @@ -27,9 +27,16 @@ 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")) + + // 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. + api("org.cloudburstmc.protocol:bedrock-codec:3.0.0.Beta12-SNAPSHOT") + api("org.cloudburstmc.protocol:bedrock-connection:3.0.0.Beta12-SNAPSHOT") + api("org.cloudburstmc.protocol:common:3.0.0.Beta12-SNAPSHOT") api(libs.bundles.netty) testImplementation(kotlin("test")) From e6c65be172dd968f43a3e5c3463a9589446b3a09 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Wed, 19 Aug 2026 12:16:21 +0900 Subject: [PATCH 24/82] Add files via upload --- settings.gradle.kts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index a04989b4..1b8f98ee 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,11 +31,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. From 99912fae42a96383aaf29d25e474af5c30fb5529 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Wed, 19 Aug 2026 12:17:14 +0900 Subject: [PATCH 25/82] Add files via upload --- build.gradle.kts | 62 +++++++++++++----------------------------------- 1 file changed, 16 insertions(+), 46 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 5736fed6..bc96b873 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,51 +1,21 @@ plugins { - id("java-library") - kotlin("jvm") + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.lombok) apply false + kotlin("jvm") version "2.2.20" apply false + kotlin("plugin.serialization") version "2.2.20" apply false + id("com.github.johnrengelman.shadow") version "8.1.1" apply false } -group = "com.retrivedmods.wrelay" -version = "1.0-SNAPSHOT" - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) - } +tasks.register("buildAll") { + group = "build" + description = "Builds both Android APK and Desktop JAR" + dependsOn(":app:assembleRelease", ":desktop:shadowJar") } -dependencies { - - testImplementation(libs.junit.jupiter.api) - testRuntimeOnly(libs.junit.jupiter.engine) - - implementation(platform(libs.log4j.bom)) - implementation(libs.log4j.api) - implementation(libs.log4j.core) - implementation(libs.jose4j) - implementation(libs.jackson.databind) - implementation(libs.kotlinx.coroutines.core) - - // Use api to expose these to the app module - api(libs.minecraft.auth) - api(project(":relay:Network:transport-raknet")) - - // 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. - api("org.cloudburstmc.protocol:bedrock-codec:3.0.0.Beta12-SNAPSHOT") - api("org.cloudburstmc.protocol:bedrock-connection:3.0.0.Beta12-SNAPSHOT") - api("org.cloudburstmc.protocol:common:3.0.0.Beta12-SNAPSHOT") - api(libs.bundles.netty) - - testImplementation(kotlin("test")) -} - -tasks.test { - useJUnitPlatform() -} - -kotlin { - jvmToolchain(17) -} +tasks.register("buildDebugAll") { + group = "build" + description = "Builds both Android Debug APK and Desktop JAR" + dependsOn(":app:assembleDebug", ":desktop:shadowJar") +} \ No newline at end of file From aa311f82eacf2843beceff8c6e9aaa7e8f1d3904 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Thu, 20 Aug 2026 00:54:59 +0900 Subject: [PATCH 26/82] Add files via upload --- relay/build.gradle.kts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/relay/build.gradle.kts b/relay/build.gradle.kts index 5736fed6..63f3c6c6 100644 --- a/relay/build.gradle.kts +++ b/relay/build.gradle.kts @@ -34,6 +34,10 @@ dependencies { // 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.Beta12-SNAPSHOT") api("org.cloudburstmc.protocol:bedrock-connection:3.0.0.Beta12-SNAPSHOT") api("org.cloudburstmc.protocol:common:3.0.0.Beta12-SNAPSHOT") From ef2204ebf2647b8c62b9251e5078c509c4ea802a Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Thu, 20 Aug 2026 00:56:09 +0900 Subject: [PATCH 27/82] Add files via upload --- gradle/libs.versions.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a98487cf..e787828b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } From aa71020381ec5e2b0521f34d16d58791aadfbd0e Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Thu, 20 Aug 2026 12:04:23 +0900 Subject: [PATCH 28/82] Fix build errors for bedrock-codec Beta12 (immutable packet fields) + JDK8 toolchain auto-download --- .../wclient/game/ActionBarManager.kt | 3 +- .../retrivedmods/wclient/game/GameSession.kt | 5 +- .../wclient/game/module/combat/ACAModule.kt | 4 +- .../game/module/combat/AutoHvHModule.kt | 4 +- .../game/module/combat/EnemyHunterModule.kt | 4 +- .../module/combat/HotbarSwitcherModule.kt | 3 +- .../game/module/combat/KillauraModule.kt | 6 ++- .../game/module/combat/PistonCrystalModule.kt | 4 +- .../game/module/combat/SurroundModule.kt | 4 +- .../game/module/misc/AutoDisconnectModule.kt | 4 +- .../game/module/misc/PositionLoggerModule.kt | 6 ++- .../wclient/game/module/misc/SpammerModule.kt | 6 ++- .../game/module/motion/PlayerTPModule.kt | 4 +- .../game/module/world/FreeCameraModule.kt | 4 +- .../wclient/util/PacketFieldUtil.kt | 50 +++++++++++++++++++ gradlew | 0 settings.gradle.kts | 4 ++ 17 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/retrivedmods/wclient/util/PacketFieldUtil.kt mode change 100644 => 100755 gradlew 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/GameSession.kt b/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt index 2f7b54f7..e57e7c99 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 @@ -136,10 +137,10 @@ 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) } 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/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 index 3d1d3f73..e7623f37 100644 --- 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 @@ -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 @@ -319,7 +321,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { val packet = PlayerHotbarPacket().apply { selectedHotbarSlot = slot containerId = 0 - selectHotbarSlot = true + setPacketField("selectHotbarSlot", true) } session.clientBound(packet) } 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 index c693207b..1e3e9bb9 100644 --- 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 @@ -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 @@ -152,7 +154,7 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { val packet = PlayerHotbarPacket().apply { selectedHotbarSlot = slot containerId = 0 - selectHotbarSlot = true + setPacketField("selectHotbarSlot", true) } session.serverBound(packet) session.clientBound(packet) 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/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/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/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/settings.gradle.kts b/settings.gradle.kts index 1b8f98ee..7a6f681a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,7 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + @file:Suppress("UnstableApiUsage") pluginManagement { From 5dad400b0910ccc04003ab4e66996a9c8080e2f2 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Thu, 20 Aug 2026 13:01:37 +0900 Subject: [PATCH 29/82] Add files via upload --- settings.gradle.kts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 7a6f681a..1420d5e4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,3 @@ -plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" -} - @file:Suppress("UnstableApiUsage") pluginManagement { @@ -19,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 { From e2e6ca2c45da2d41fc66fd3640723a35ae183507 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Thu, 20 Aug 2026 13:09:58 +0900 Subject: [PATCH 30/82] Add files via upload --- relay/build.gradle-1.kts | 59 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 relay/build.gradle-1.kts diff --git a/relay/build.gradle-1.kts b/relay/build.gradle-1.kts new file mode 100644 index 00000000..924fcf6c --- /dev/null +++ b/relay/build.gradle-1.kts @@ -0,0 +1,59 @@ +plugins { + id("java-library") + kotlin("jvm") +} + +group = "com.retrivedmods.wrelay" +version = "1.0-SNAPSHOT" + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + +dependencies { + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + + implementation(platform(libs.log4j.bom)) + implementation(libs.log4j.api) + implementation(libs.log4j.core) + implementation(libs.jose4j) + implementation(libs.jackson.databind) + implementation(libs.kotlinx.coroutines.core) + + // Use api to expose these to the app module + api(libs.minecraft.auth) + // 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.Beta12-SNAPSHOT") + api("org.cloudburstmc.protocol:bedrock-connection:3.0.0.Beta12-SNAPSHOT") + api("org.cloudburstmc.protocol:common:3.0.0.Beta12-SNAPSHOT") + api(libs.bundles.netty) + + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} + +kotlin { + jvmToolchain(17) +} From ba10851874b5466b61d817dca0766052671d7ce8 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Thu, 20 Aug 2026 13:17:48 +0900 Subject: [PATCH 31/82] Delete relay/build.gradle-1.kts --- relay/build.gradle-1.kts | 59 ---------------------------------------- 1 file changed, 59 deletions(-) delete mode 100644 relay/build.gradle-1.kts diff --git a/relay/build.gradle-1.kts b/relay/build.gradle-1.kts deleted file mode 100644 index 924fcf6c..00000000 --- a/relay/build.gradle-1.kts +++ /dev/null @@ -1,59 +0,0 @@ -plugins { - id("java-library") - kotlin("jvm") -} - -group = "com.retrivedmods.wrelay" -version = "1.0-SNAPSHOT" - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) - } -} - -dependencies { - - testImplementation(libs.junit.jupiter.api) - testRuntimeOnly(libs.junit.jupiter.engine) - - implementation(platform(libs.log4j.bom)) - implementation(libs.log4j.api) - implementation(libs.log4j.core) - implementation(libs.jose4j) - implementation(libs.jackson.databind) - implementation(libs.kotlinx.coroutines.core) - - // Use api to expose these to the app module - api(libs.minecraft.auth) - // 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.Beta12-SNAPSHOT") - api("org.cloudburstmc.protocol:bedrock-connection:3.0.0.Beta12-SNAPSHOT") - api("org.cloudburstmc.protocol:common:3.0.0.Beta12-SNAPSHOT") - api(libs.bundles.netty) - - testImplementation(kotlin("test")) -} - -tasks.test { - useJUnitPlatform() -} - -kotlin { - jvmToolchain(17) -} From 2f179fb579f84cc354bf2250eaddbe3c1bc678b4 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Thu, 20 Aug 2026 13:18:30 +0900 Subject: [PATCH 32/82] Add files via upload --- relay/build.gradle.kts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/relay/build.gradle.kts b/relay/build.gradle.kts index 63f3c6c6..924fcf6c 100644 --- a/relay/build.gradle.kts +++ b/relay/build.gradle.kts @@ -26,7 +26,11 @@ dependencies { // Use api to expose these to the app module api(libs.minecraft.auth) - api(project(":relay:Network:transport-raknet")) + // 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 From 95f6522a894a0d8c299463f115d788c5c7cc2cb0 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Thu, 20 Aug 2026 23:23:43 +0900 Subject: [PATCH 33/82] Apply patch file via Termux --- .../wclient/game/AccountManager.kt | 214 ++++++------------ .../wclient/game/RealmsAuthFlow.kt | 22 +- .../retrivedmods/wclient/model/RealmWorld.kt | 6 +- .../wclient/router/main/AccountPage.kt | 6 +- .../wclient/router/main/RealmsPage.kt | 3 +- .../wclient/service/RealmsManager.kt | 97 ++++---- .../retrivedmods/wclient/service/Services.kt | 4 +- .../wclient/ui/component/AuthWebView.kt | 50 ++-- gradle/libs.versions.toml | 2 +- .../listener/OnlineLoginPacketListener.kt | 49 ++-- .../com/retrivedmods/wrelay/util/AuthUtils.kt | 35 ++- .../wrelay/util/MinecraftRelays.kt | 52 +---- 12 files changed, 204 insertions(+), 336 deletions(-) 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..7e8fa930 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.21.132" + + // 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/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/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 dfc6f974..e72be22f 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,8 @@ 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 kotlin.concurrent.thread val auth = "UCxb4pcHvdYpqv7i5Xt9mOUw" @@ -38,43 +38,21 @@ class AuthWebView @JvmOverloads constructor( thread { runCatching { val httpClient = MinecraftAuth.createHttpClient() - httpClient.connectTimeout = 10000 - httpClient.readTimeout = 10000 - val deviceCodeCallback = 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. + val authManager = BedrockAuthManager + .create(httpClient, AccountManager.GAME_VERSION) + .msaApplicationConfig(RealmsAuthFlow.BEDROCK_ANDROID_APPLICATION_CONFIG) + .login(::DeviceCodeMsaAuthService) { deviceCode -> + post { + loadUrl(deviceCode.directVerificationUri) + } } - } - // Try the Realms-capable auth chain first, but fall back to the plain Bedrock - // chain if it fails - accounts without a Realms subscription/entitlement can - // fail the extra Realms XSTS step even though normal sign-in would have worked - // fine. (AccountManager already does this same fallback for save/load/refresh; - // this was the one place it was missing, and the likely cause of "can't sign in" - // for anyone without Realms.) - val fullBedrockSession = try { - RealmsAuthFlow.BEDROCK_DEVICE_CODE_LOGIN_WITH_REALMS.getFromInput( - httpClient, - deviceCodeCallback - ) - } catch (e: Exception) { - println("Realms-capable sign-in failed, falling back to regular sign-in: ${e.message}") - MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN.getFromInput( - httpClient, - deviceCodeCallback - ) - } - val containedAccount = - AccountManager.accounts.find { it.mcChain.displayName == fullBedrockSession.mcChain.displayName } - if (containedAccount != null) { - AccountManager.removeAccount(containedAccount) - } - AccountManager.addAccount(fullBedrockSession) - - if (containedAccount == AccountManager.selectedAccount) { - AccountManager.selectAccount(fullBedrockSession) - } + AccountManager.addAccount(authManager) callback?.invoke(null) }.exceptionOrNull()?.let { callback?.invoke(it) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e787828b..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" 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. From 2464c11c511ac1c44de47b602051a016a3d6aa4c Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 10:51:34 +0900 Subject: [PATCH 34/82] Add files via upload --- .../com/retrivedmods/wclient/ui/component/AuthWebView.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 e72be22f..8503db18 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 @@ -12,6 +12,7 @@ import com.retrivedmods.wclient.game.RealmsAuthFlow import net.raphimc.minecraftauth.MinecraftAuth 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" @@ -46,11 +47,11 @@ class AuthWebView @JvmOverloads constructor( val authManager = BedrockAuthManager .create(httpClient, AccountManager.GAME_VERSION) .msaApplicationConfig(RealmsAuthFlow.BEDROCK_ANDROID_APPLICATION_CONFIG) - .login(::DeviceCodeMsaAuthService) { deviceCode -> + .login(::DeviceCodeMsaAuthService, Consumer { deviceCode -> post { loadUrl(deviceCode.directVerificationUri) } - } + }) AccountManager.addAccount(authManager) callback?.invoke(null) From 514cba0acc7cfc955fb08f6e57411233e8a89c4b Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 11:13:40 +0900 Subject: [PATCH 35/82] Add files via upload --- .../retrivedmods/wrelay/codec/CodecRegistry.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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..25505ac1 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() From 99f1a73fcc1f886522c37819cd6f82a5b9a9948f Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 11:31:50 +0900 Subject: [PATCH 36/82] Add files via upload --- relay/src/main/kotlin/com/retrivedmods/wrelay/WRelay.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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) } From 4399126d2ac0380cb1966d6a7606ed516ca8c38e Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 12:15:40 +0900 Subject: [PATCH 37/82] Fix AuthWebView Kotlin type inference + bump client version to 1.26.40 --- .../wclient/game/AccountManager.kt | 214 ++++++------------ .../wclient/ui/component/AuthWebView.kt | 62 +++-- .../wclient/util/MinecraftUtils.kt | 2 +- .../wrelay/codec/VersionDetector.kt | 3 + 4 files changed, 103 insertions(+), 178 deletions(-) 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/ui/component/AuthWebView.kt b/app/src/main/java/com/retrivedmods/wclient/ui/component/AuthWebView.kt index dfc6f974..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,43 +39,38 @@ class AuthWebView @JvmOverloads constructor( thread { runCatching { val httpClient = MinecraftAuth.createHttpClient() - httpClient.connectTimeout = 10000 - httpClient.readTimeout = 10000 - val deviceCodeCallback = StepMsaDeviceCode.MsaDeviceCodeCallback { + // 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(it.directVerificationUri) + loadUrl(deviceCode.directVerificationUri) } } + val authService = DeviceCodeMsaAuthService( + httpClient, + RealmsAuthFlow.BEDROCK_ANDROID_APPLICATION_CONFIG, + deviceCodeCallback + ) + val msaToken = authService.acquireToken() - // Try the Realms-capable auth chain first, but fall back to the plain Bedrock - // chain if it fails - accounts without a Realms subscription/entitlement can - // fail the extra Realms XSTS step even though normal sign-in would have worked - // fine. (AccountManager already does this same fallback for save/load/refresh; - // this was the one place it was missing, and the likely cause of "can't sign in" - // for anyone without Realms.) - val fullBedrockSession = try { - RealmsAuthFlow.BEDROCK_DEVICE_CODE_LOGIN_WITH_REALMS.getFromInput( - httpClient, - deviceCodeCallback - ) - } catch (e: Exception) { - println("Realms-capable sign-in failed, falling back to regular sign-in: ${e.message}") - MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN.getFromInput( - httpClient, - deviceCodeCallback - ) - } - val containedAccount = - AccountManager.accounts.find { it.mcChain.displayName == fullBedrockSession.mcChain.displayName } - if (containedAccount != null) { - AccountManager.removeAccount(containedAccount) - } - AccountManager.addAccount(fullBedrockSession) + val authManager = BedrockAuthManager + .create(httpClient, AccountManager.GAME_VERSION) + .msaApplicationConfig(RealmsAuthFlow.BEDROCK_ANDROID_APPLICATION_CONFIG) + .login(msaToken) - if (containedAccount == AccountManager.selectedAccount) { - AccountManager.selectAccount(fullBedrockSession) - } + 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/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"), From 550e153c05b38bc4a543e1fc5f2dc8e3235af6e4 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 12:36:32 +0900 Subject: [PATCH 38/82] Bump bedrock-codec to Beta13-SNAPSHOT to actually get the v2168 (1.26.40/1.26.44) codec --- relay/build.gradle.kts | 12 +++++--- .../wrelay/codec/CodecRegistry.kt | 30 ++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/relay/build.gradle.kts b/relay/build.gradle.kts index 63f3c6c6..55cf8374 100644 --- a/relay/build.gradle.kts +++ b/relay/build.gradle.kts @@ -26,7 +26,11 @@ dependencies { // Use api to expose these to the app module api(libs.minecraft.auth) - api(project(":relay:Network:transport-raknet")) + // 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 @@ -38,9 +42,9 @@ dependencies { // (and relay code referencing it) uses for text. api(libs.adventure.api) - api("org.cloudburstmc.protocol:bedrock-codec:3.0.0.Beta12-SNAPSHOT") - api("org.cloudburstmc.protocol:bedrock-connection:3.0.0.Beta12-SNAPSHOT") - api("org.cloudburstmc.protocol:common:3.0.0.Beta12-SNAPSHOT") + 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/codec/CodecRegistry.kt b/relay/src/main/kotlin/com/retrivedmods/wrelay/codec/CodecRegistry.kt index b53748f5..d8b6c9b7 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. Logging with Log.e (and the class name) + // makes that failure visible in `adb logcat` / Android Studio's Logcat instead of + // requiring println output that's easy to lose. + android.util.Log.e( + "CodecRegistry", + "Failed to register codec $minecraftVersion (protocol $protocolVersion, class $className): ${e.javaClass.simpleName}: ${e.message}" + ) } } From 413e2ee99e4c1413fca65a1f2e330e81088cd577 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 13:02:39 +0900 Subject: [PATCH 39/82] Add files via upload --- .../com/retrivedmods/wrelay/codec/CodecRegistry.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 d8b6c9b7..2ad0dac2 100644 --- a/relay/src/main/kotlin/com/retrivedmods/wrelay/codec/CodecRegistry.kt +++ b/relay/src/main/kotlin/com/retrivedmods/wrelay/codec/CodecRegistry.kt @@ -110,12 +110,12 @@ object CodecRegistry { // 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. Logging with Log.e (and the class name) - // makes that failure visible in `adb logcat` / Android Studio's Logcat instead of - // requiring println output that's easy to lose. - android.util.Log.e( - "CodecRegistry", - "Failed to register codec $minecraftVersion (protocol $protocolVersion, class $className): ${e.javaClass.simpleName}: ${e.message}" + // 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}" ) } } From b8570dc9c3cfdf8400b6aaeb4f7d3b2d32223189 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 13:18:20 +0900 Subject: [PATCH 40/82] Add files via upload --- app/proguard-rules.pro | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 07bb9ae9..24898685 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 ; From 6d0465e096d17a89cfbd3af22c01e15ffeeccb59 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 13:27:13 +0900 Subject: [PATCH 41/82] Add files via upload --- app/proguard-rules.pro | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 24898685..03b83522 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -3,15 +3,15 @@ -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. +# 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 * From 92062f908e9d888bc824fff163780959296cdb7f Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 14:08:53 +0900 Subject: [PATCH 42/82] Add files via upload --- .../com/retrivedmods/wclient/game/InterceptablePacket.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 From 7c59a08bdf6d9bb822f91387248885aa24c550ee Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 14:09:51 +0900 Subject: [PATCH 43/82] Add files via upload --- .../game/module/combat/PistonCrystalModule.kt | 19 +++++++++++++++++-- .../game/module/combat/SurroundModule.kt | 13 ++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) 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 index e7623f37..194ebbce 100644 --- 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 @@ -290,11 +290,26 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { authInput.rotation = Vector3f.from(0f, yaw, yaw) } + // Same underlying issue as SurroundModule had: Bedrock's block-place transaction places + // the new block adjacent to whatever existing block you "click", not at blockPosition + // itself. Clicking the empty target cell directly gets silently dropped server-side. + // Figure out which existing block we're clicking and which face of it, such that the + // resulting placement lands at `pos`: + // - faceUp: click the block below `pos` from its top (UP) face. + // - pushDir: click the block on the opposite side of `pos` from pushDir, on the face + // that points towards `pos` (i.e. towards pushDir). + // - neither: fall back to the same below/UP behavior as faceUp. + val face = if (faceUp) 1 else (pushDir?.let { faceForDirection(it) } ?: 1) + val referencePos = when { + faceUp || pushDir == null -> Vector3i.from(pos.x, pos.y - 1, pos.z) + else -> Vector3i.from(pos.x - pushDir.x, pos.y - pushDir.y, pos.z - pushDir.z) + } + val transaction = InventoryTransactionPacket().apply { transactionType = InventoryTransactionType.ITEM_USE actionType = 0 // click block / place - blockPosition = pos - blockFace = if (faceUp) 1 else (pushDir?.let { faceForDirection(it) } ?: 1) + blockPosition = referencePos + blockFace = face hotbarSlot = slot itemInHand = localPlayer.inventory.hand playerPosition = localPlayer.vec3Position 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 index 1e3e9bb9..d48d6260 100644 --- 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 @@ -169,15 +169,22 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { val runtimeId = session.blockMapping.getRuntimeIdByIdentifier(blockIdentifier) ?: return val definitionToPlace = session.blockMapping.getDefinition(runtimeId) + // Bedrock's block-place transaction works by "clicking" an existing block from a given + // face; the new block lands adjacent to that face, not at blockPosition itself. Clicking + // the target position directly (as if it already had a block there) means the server sees + // us clicking air and silently drops the placement. Click the block below the target from + // its top (UP) face instead, so the new block ends up at `pos`. + val referencePos = Vector3i.from(pos.x, pos.y - 1, pos.z) + val packet = InventoryTransactionPacket().apply { transactionType = InventoryTransactionType.ITEM_USE actionType = 0 // click block / place - blockPosition = pos - blockFace = 1 // top face, doesn't matter much for a fresh air target + blockPosition = referencePos + blockFace = 1 // up hotbarSlot = slot itemInHand = localPlayer.inventory.hand playerPosition = localPlayer.vec3Position - clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) + clickPosition = Vector3f.from(0.5f, 1.0f, 0.5f) blockDefinition = definitionToPlace } From 4b4d845cee59d96b8f89e68e2d4d9a7e7021cb54 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 14:10:28 +0900 Subject: [PATCH 44/82] Add files via upload --- .../wclient/game/module/combat/AntiCrystalModule.kt | 9 +++++++++ 1 file changed, 9 insertions(+) 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 1df067b8..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 @@ -17,6 +17,15 @@ class AntiCrystalModule : Module("anti_crystal", ModuleCategory.Combat) { 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 From d1cb200180deef1b676831b7e081733aec22ee04 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 14:10:50 +0900 Subject: [PATCH 45/82] Add files via upload --- .../retrivedmods/wclient/game/GameSession.kt | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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 e57e7c99..001a8080 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt @@ -51,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 { @@ -101,7 +120,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 @@ -144,4 +163,4 @@ class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { clientBound(textPacket) } -} \ No newline at end of file +} From 1a6e3dd0f08823fb71c14cab33f1cf971eeab496 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 15:42:30 +0900 Subject: [PATCH 46/82] Add files via upload --- .../game/module/combat/PistonCrystalModule.kt | 228 +++++++--------- .../game/module/combat/SurroundModule.kt | 245 ++++++++++-------- 2 files changed, 231 insertions(+), 242 deletions(-) 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 index 194ebbce..a42f35df 100644 --- 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 @@ -1,7 +1,5 @@ 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 @@ -19,24 +17,20 @@ import org.cloudburstmc.protocol.bedrock.packet.PlayerHotbarPacket import kotlin.math.floor /** - * Places an obsidian base + end crystal next to a target, plus an (best-effort) piston + redstone - * setup to try to pop the crystal via a block push instead of hitting it. Ported from the - * PistonCrystal/PistonCrystal2 C++ reference + ideas from ModuleCrystalAura.kt (ProtoHax), now that - * WClient has real block lookups via [com.retrivedmods.wclient.game.world.Level.getBlockAt]. + * 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 * - * Known limitations vs the original: - * - Piston orientation in Bedrock is set from the placer's look direction at placement time. This - * fakes that by mutating the outgoing PlayerAuthInputPacket's rotation right before placing, the - * same trick AntiCrystal/Surround use for position - it's not guaranteed as reliable as an actual - * client rotating for real. - * - Block data for chunks loaded via blob caching or the newer per-subchunk-request system isn't - * tracked (see Level.kt), so getBlockAt() may report air for those even if something's really - * there. In that case placements will just be rejected server-side, same as if this weren't - * checked at all. - * - Damage estimation (session.level.simulateExplosionDamage) is a distance-only approximation with - * no block-occlusion/exposure factor, so it's an upper bound, not exact. - * - Direct-attack fallback (autoAttackCrystal) is what actually guarantees detonation; the - * piston/redstone path is a best-effort bonus on top of that, not a replacement for it. + * 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) { @@ -52,20 +46,23 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { 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)) } - private enum class Step { OBSIDIAN, CRYSTAL, PISTON, REDSTONE, DONE } + private data class Placement(val crystalPos: Vector3i, val pistonPos: Vector3i, val redstonePos: Vector3i, val dir: Vector3i) + + private enum class Step { CRYSTAL, PISTON, REDSTONE, DONE } private var step = Step.DONE - private var obsidianPos: Vector3i? = null - private var pistonPos: Vector3i? = null - private var redstonePos: Vector3i? = null - private var pushDir: Vector3i? = null + private var placement: Placement? = null private var tickCounter = 0 private var oldSlot = -1 private val lastCrystalAttack = HashMap() @@ -85,10 +82,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { private fun resetState() { step = Step.DONE - obsidianPos = null - pistonPos = null - redstonePos = null - pushDir = null + placement = null tickCounter = 0 oldSlot = -1 lastCrystalAttack.clear() @@ -99,16 +93,21 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { val packet = interceptablePacket.packet if (packet !is PlayerAuthInputPacket) return - // Always try to guarantee detonation of any crystal that's already out there, regardless - // of whether we're mid-placement - this is the part that actually reliably deals damage. + // Guaranteed detonation path - runs every tick regardless of placement state. if (autoAttackCrystal) { attackNearbyCrystals() } if (step == Step.DONE) { - val target = findTarget() ?: return - val base = findPlacementSpot(target) ?: return - beginPlacement(base, target) + val target = findTarget() + if (target != null) { + val found = findValidPlacement(target) + if (found != null) { + placement = found + step = Step.CRYSTAL + tickCounter = 0 + } + } } if (tickCounter < placeDelayTicks) { @@ -120,7 +119,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { advanceStateMachine(packet) } - // --- target / spot selection ----------------------------------------------------------- + // --- target selection ------------------------------------------------------------------- private fun findTarget(): Entity? { val localPlayer = session.localPlayer @@ -146,109 +145,99 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { } } - /** - * Looks for a block directly adjacent to the target (one of the 4 cardinal neighbours at the - * target's feet level) that's solid, with air immediately above it (room for obsidian -> crystal). - * Real getBlockAt() checks now that Level tracks chunk data. - */ - private fun findPlacementSpot(target: Entity): Vector3i? { + // --- placement search (PistonCrystal.cpp: findValidPlacement/calculatePlacement) -------- + + private fun findValidPlacement(target: Entity): Placement? { val targetPos = target.vec3Position - val baseX = floor(targetPos.x).toInt() - val baseY = floor(targetPos.y).toInt() - val baseZ = floor(targetPos.z).toInt() - - val offsets = listOf(1 to 0, -1 to 0, 0 to 1, 0 to -1) - for ((dx, dz) in offsets) { - val candidate = Vector3i.from(baseX + dx, baseY - 1, baseZ + dz) - val at = session.level.getBlockAt(candidate.x, candidate.y + 1, candidate.z) - // crystal spot itself must be clear; the block below may be air too - in that case - // we'll place our own obsidian there first (see isObsidianNeeded-equivalent check - // in beginPlacement). - if (at.identifier.isAirLike()) { - if (session.localPlayer.distance(Vector3f.from(candidate.x + 0.5f, candidate.y + 1f, candidate.z + 0.5f)) <= range + 1f) { - return candidate + 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) + } + + for (dir in orderedDirs) { + for (yLevel in 0..1) { + val config = calculatePlacement(targetBlockPos, dir, yLevel) + if (isValidPlacement(config, target)) { + return config } } } return null } - private fun String.isAirLike(): Boolean { - return this == "minecraft:air" + 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) } - // --- placement sequencing -------------------------------------------------------------- + private fun isAir(pos: Vector3i) = session.level.getBlockAt(pos).identifier == "minecraft:air" - private fun beginPlacement(base: Vector3i, target: Entity) { - val localPlayer = session.localPlayer + 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 { + if (!canPlaceCrystal(config.crystalPos)) return false + if (!canPlaceBlock(config.pistonPos)) return false + if (!canPlaceBlock(config.redstonePos)) return false + + 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 false - val crystalCenter = Vector3f.from(base.x + 0.5f, base.y + 2f, base.z + 0.5f) var estimatedTargetDamage = 0f var estimatedSelfDamage = 0f session.level.simulateExplosionDamage( - crystalCenter, + Vector3f.from(config.crystalPos.x + 0.5f, config.crystalPos.y + 0.5f, config.crystalPos.z + 0.5f), EXPLOSION_SIZE, - extraEntities = listOf(localPlayer) + extraEntities = listOf(session.localPlayer) ) { entity, damage -> if (entity.runtimeEntityId == target.runtimeEntityId) estimatedTargetDamage = damage if (entity is LocalPlayer) estimatedSelfDamage = damage } + if (estimatedTargetDamage < targetDamageMin) return false + if (estimatedSelfDamage > selfDamageLimit) return false - if (estimatedTargetDamage < targetDamageMin) return - if (estimatedSelfDamage > selfDamageLimit) return - - obsidianPos = base - - // matches PistonCrystal2's isObsidianNeeded(): only place obsidian if the spot doesn't - // already have a solid block there (e.g. the target is already standing on real ground). - val obsidianAlreadyPresent = !session.level.getBlockAt(base).identifier.isAirLike() - - // push direction: away from the target, so a piston push (if it lands) knocks the - // obsidian - and whatever's resting on it - out from under the crystal. - val targetPos = target.vec3Position - val dx = base.x + 0.5f - targetPos.x - val dz = base.z + 0.5f - targetPos.z - pushDir = if (kotlin.math.abs(dx) > kotlin.math.abs(dz)) { - Vector3i.from(if (dx > 0) 1 else -1, 0, 0) - } else { - Vector3i.from(0, 0, if (dz > 0) 1 else -1) - } - pistonPos = base.add(pushDir) - redstonePos = pistonPos!!.add(pushDir) - - step = if (obsidianAlreadyPresent) Step.CRYSTAL else Step.OBSIDIAN - tickCounter = 0 + return true } + // --- placement sequencing ---------------------------------------------------------------- + private fun advanceStateMachine(packet: PlayerAuthInputPacket) { - when (step) { - Step.OBSIDIAN -> { - val pos = obsidianPos ?: return resetState() - if (place(OBSIDIAN, pos, packet, faceUp = true)) { - step = Step.CRYSTAL - } - } + val config = placement ?: return resetState() + when (step) { Step.CRYSTAL -> { - val pos = obsidianPos ?: return resetState() - val crystalPos = pos.add(0, 1, 0) - if (place(CRYSTAL_ITEM, crystalPos, packet, faceUp = true)) { + if (place(CRYSTAL_ITEM, config.crystalPos, packet, lookTowards = config.crystalPos)) { step = if (usePiston) Step.PISTON else Step.DONE if (!usePiston) resetState() } } Step.PISTON -> { - val pos = pistonPos ?: return resetState() - val dir = pushDir ?: return resetState() - if (place(PISTON, pos, packet, faceUp = false, lookTowards = obsidianPos, pushDir = dir)) { + if (place(PISTON, config.pistonPos, packet, lookTowards = config.crystalPos, pushDir = Vector3i.from(-config.dir.x, 0, -config.dir.z))) { step = Step.REDSTONE } } Step.REDSTONE -> { - val pos = redstonePos ?: return resetState() - if (place(REDSTONE_BLOCK, pos, packet, faceUp = false)) { + if (place(REDSTONE_BLOCK, config.redstonePos, packet, lookTowards = null)) { resetState() } } @@ -257,23 +246,17 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { } } - /** - * Sends one block placement attempt. Returns true whether or not the item was found/placed so - * the state machine keeps moving forward - we can't confirm server-side success without real - * world feedback, so we don't retry indefinitely. - */ private fun place( identifier: String, pos: Vector3i, authInput: PlayerAuthInputPacket, - faceUp: Boolean, - lookTowards: Vector3i? = null, + lookTowards: Vector3i?, pushDir: Vector3i? = null ): Boolean { val localPlayer = session.localPlayer val slot = localPlayer.inventory.searchForItemInHotbar { it.definition?.identifier == identifier - } ?: return true // nothing to place with, don't get stuck retrying forever + } ?: return true // don't get stuck retrying forever if we're out of the item if (oldSlot == -1) { oldSlot = localPlayer.inventory.heldItemSlot @@ -290,32 +273,16 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { authInput.rotation = Vector3f.from(0f, yaw, yaw) } - // Same underlying issue as SurroundModule had: Bedrock's block-place transaction places - // the new block adjacent to whatever existing block you "click", not at blockPosition - // itself. Clicking the empty target cell directly gets silently dropped server-side. - // Figure out which existing block we're clicking and which face of it, such that the - // resulting placement lands at `pos`: - // - faceUp: click the block below `pos` from its top (UP) face. - // - pushDir: click the block on the opposite side of `pos` from pushDir, on the face - // that points towards `pos` (i.e. towards pushDir). - // - neither: fall back to the same below/UP behavior as faceUp. - val face = if (faceUp) 1 else (pushDir?.let { faceForDirection(it) } ?: 1) - val referencePos = when { - faceUp || pushDir == null -> Vector3i.from(pos.x, pos.y - 1, pos.z) - else -> Vector3i.from(pos.x - pushDir.x, pos.y - pushDir.y, pos.z - pushDir.z) - } - val transaction = InventoryTransactionPacket().apply { transactionType = InventoryTransactionType.ITEM_USE - actionType = 0 // click block / place - blockPosition = referencePos - blockFace = face + actionType = 0 + blockPosition = pos + blockFace = pushDir?.let { faceForDirection(it) } ?: 1 hotbarSlot = slot itemInHand = localPlayer.inventory.hand playerPosition = localPlayer.vec3Position clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) } - session.serverBound(transaction) return true } @@ -332,16 +299,15 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { } private fun switchToSlot(slot: Int) { - // matches the existing (working) HotbarSwitcherModule pattern exactly - only clientBound. val packet = PlayerHotbarPacket().apply { selectedHotbarSlot = slot containerId = 0 - setPacketField("selectHotbarSlot", true) + selectHotbarSlot = true } session.clientBound(packet) } - // --- crystal detonation fallback ------------------------------------------------------- + // --- crystal detonation fallback ---------------------------------------------------------- private fun attackNearbyCrystals() { val localPlayer = session.localPlayer 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 index d48d6260..77b18490 100644 --- 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 @@ -1,7 +1,5 @@ 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 @@ -14,36 +12,36 @@ import org.cloudburstmc.protocol.bedrock.packet.PlayerHotbarPacket import kotlin.math.floor /** - * Places blocks (obsidian by default) around the player to reduce end crystal / explosion damage. - * - * NOTE: unlike the original C++ (Mod-Menu-style) implementation this was ported from, WClient is a - * relay/proxy client and does not keep a local copy of world block state (no BlockSource / chunk data). - * Because of that this version can NOT check whether a target position is actually air, replaceable, - * or already occupied before placing - it just fires placement packets at the 8 positions around the - * player each tick and lets the server accept or silently reject them, the same way a real client's - * packet would be validated server-side. "Dynamic" hitbox expansion is also approximated using a fixed - * player-sized radius per nearby entity (since Entity here has no AABB/hitbox size), not true AABB - * collision like the original. + * 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 blockIdentifier by stringValue("block", "minecraft:obsidian", null) - private var placeDelayTicks by intValue("place_delay", 1, 0..20) - private var blocksPerTick by intValue("blocks_per_tick", 4, 1..8) - private var placeBelowFeet by boolValue("place_below", true) + 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 dynamicRadius by floatValue("dynamic_radius", 3f, 1f..6f) + 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 placeQueue: MutableList = mutableListOf() + private var placeList: MutableList = mutableListOf() private var tickCounter = 0 private var oldSlot = -1 private var hasCentered = false override fun onEnabled() { super.onEnabled() - placeQueue = mutableListOf() + placeList = mutableListOf() tickCounter = 0 oldSlot = -1 hasCentered = false @@ -51,143 +49,168 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { override fun onDisabled() { super.onDisabled() - placeQueue.clear() + placeList.clear() if (oldSlot != -1 && isSessionCreated) { - restoreSlot() + switchToSlot(oldSlot) } oldSlot = -1 hasCentered = false } override fun beforePacketBound(interceptablePacket: InterceptablePacket) { - if (!isEnabled || !isSessionCreated) { - return - } - + if (!isEnabled || !isSessionCreated) return val packet = interceptablePacket.packet - if (packet !is PlayerAuthInputPacket) { - return - } + if (packet !is PlayerAuthInputPacket) return val localPlayer = session.localPlayer - val obsidianSlot = localPlayer.inventory.searchForItemInHotbar { - it.definition?.identifier == blockIdentifier - } - - if (obsidianSlot == null) { - // nothing to place with, don't spam the server - return - } - - if (oldSlot == -1) { - oldSlot = localPlayer.inventory.heldItemSlot - } - if (center && !hasCentered) { val pos = localPlayer.vec3Position - val centeredPos = Vector3f.from(floor(pos.x) + 0.5f, pos.y, floor(pos.z) + 0.5f) - packet.position = centeredPos + packet.position = Vector3f.from(floor(pos.x) + 0.5f, pos.y, floor(pos.z) + 0.5f) hasCentered = true } - if (placeQueue.isEmpty()) { - placeQueue = computePlacements(localPlayer.vec3Position).toMutableList() + val obsidianSlot = localPlayer.inventory.searchForItemInHotbar { + it.definition?.identifier == OBSIDIAN } - if (tickCounter < placeDelayTicks) { - tickCounter++ - return + placeList = computePlaceList() + + 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) } - tickCounter = 0 - if (localPlayer.inventory.heldItemSlot != obsidianSlot) { - switchToSlot(obsidianSlot) + 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++ + } } - var placed = 0 - val iterator = placeQueue.iterator() - while (iterator.hasNext() && placed < blocksPerTick) { - val target = iterator.next() - placeBlock(target, obsidianSlot) - iterator.remove() - placed++ + if (placeButton) { + val buttonSlot = localPlayer.inventory.searchForItemInHotbar { + it.definition?.identifier == BUTTON + } + if (buttonSlot != null) { + 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 fun computePlacements(playerPos: Vector3f): List { - val baseX = floor(playerPos.x).toInt() - val baseY = floor(playerPos.y).toInt() - if (placeBelowFeet) 1 else 0 - val baseZ = floor(playerPos.z).toInt() + /** [Surround.cpp]'s canPlaceBlock(): the target itself must be air (or, with airPlace, anything). */ + private fun canPlaceAt(pos: Vector3i): Boolean { + if (airPlace) return true + return session.level.getBlockAt(pos).identifier == "minecraft:air" + } + + private fun computePlaceList(): MutableList { + val localPlayer = session.localPlayer + val pos = localPlayer.vec3Position + val currentPos = Vector3i.from(floor(pos.x).toInt(), floor(pos.y + 0.5f).toInt(), floor(pos.z).toInt()) - var minX = -1 - var maxX = 1 - var minZ = -1 - var maxZ = 1 + var xStart = -1 + var zStart = -1 + var xEnd = 1 + var zEnd = 1 if (dynamic) { - val level = session.level - level.entityMap.values.forEach { entity -> - if (entity.distance(playerPos) > dynamicRadius) return@forEach - val dx = entity.posX - playerPos.x - val dz = entity.posZ - playerPos.z - if (dx > 1.2f) maxX = maxOf(maxX, 2) - if (dx < -1.2f) minX = minOf(minX, -2) - if (dz > 1.2f) maxZ = maxOf(maxZ, 2) - if (dz < -1.2f) minZ = minOf(minZ, -2) + 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 positions = mutableListOf() - for (x in minX..maxX) { - for (z in minZ..maxZ) { - if (x == 0 && z == 0) continue - // only the ring around the player, skip interior tiles when the box grew beyond 3x3 - if (x !in -1..1 && z !in -1..1) continue - positions.add(Vector3i.from(baseX + x, baseY, baseZ + z)) + 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) + } } } - return positions - } - private fun switchToSlot(slot: Int) { - val packet = PlayerHotbarPacket().apply { - selectedHotbarSlot = slot - containerId = 0 - setPacketField("selectHotbarSlot", true) - } - session.serverBound(packet) - session.clientBound(packet) + result.sortBy { it.distanceSq(currentPos) } + return result } - private fun restoreSlot() { - switchToSlot(oldSlot) + 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 placeBlock(pos: Vector3i, slot: Int) { + private fun place(identifier: String, pos: Vector3i, slot: Int) { val localPlayer = session.localPlayer - val runtimeId = session.blockMapping.getRuntimeIdByIdentifier(blockIdentifier) ?: return - val definitionToPlace = session.blockMapping.getDefinition(runtimeId) - - // Bedrock's block-place transaction works by "clicking" an existing block from a given - // face; the new block lands adjacent to that face, not at blockPosition itself. Clicking - // the target position directly (as if it already had a block there) means the server sees - // us clicking air and silently drops the placement. Click the block below the target from - // its top (UP) face instead, so the new block ends up at `pos`. - val referencePos = Vector3i.from(pos.x, pos.y - 1, pos.z) - val packet = InventoryTransactionPacket().apply { transactionType = InventoryTransactionType.ITEM_USE - actionType = 0 // click block / place - blockPosition = referencePos - blockFace = 1 // up + actionType = 0 + blockPosition = pos + blockFace = 1 hotbarSlot = slot itemInHand = localPlayer.inventory.hand playerPosition = localPlayer.vec3Position - clickPosition = Vector3f.from(0.5f, 1.0f, 0.5f) - blockDefinition = definitionToPlace + clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) } - session.serverBound(packet) } + + private fun switchToSlot(slot: Int) { + val packet = PlayerHotbarPacket().apply { + selectedHotbarSlot = slot + containerId = 0 + selectHotbarSlot = true + } + session.clientBound(packet) + } } From 8b9b13b3c4f5faf458ba149d9ef7a4e3ddf6a9c5 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 15:51:19 +0900 Subject: [PATCH 47/82] Add files via upload --- .../wclient/game/module/combat/PistonCrystalModule.kt | 2 +- .../retrivedmods/wclient/game/module/combat/SurroundModule.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index a42f35df..c68df288 100644 --- 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 @@ -302,7 +302,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { val packet = PlayerHotbarPacket().apply { selectedHotbarSlot = slot containerId = 0 - selectHotbarSlot = true + isSelectHotbarSlot = true } session.clientBound(packet) } 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 index 77b18490..575e61b2 100644 --- 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 @@ -209,7 +209,7 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { val packet = PlayerHotbarPacket().apply { selectedHotbarSlot = slot containerId = 0 - selectHotbarSlot = true + isSelectHotbarSlot = true } session.clientBound(packet) } From c6d393791d53fe5f6a0d4b50210a8783524b8934 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 17:51:47 +0900 Subject: [PATCH 48/82] Parse SubChunkPacket so PistonCrystal/Surround actually see real block data; fix piston-before-crystal placement order --- .../game/module/combat/PistonCrystalModule.kt | 224 +++++++++--------- .../retrivedmods/wclient/game/world/Level.kt | 46 +++- 2 files changed, 152 insertions(+), 118 deletions(-) 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 index e7623f37..dbdb41dd 100644 --- 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 @@ -1,7 +1,5 @@ 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 @@ -19,24 +17,20 @@ import org.cloudburstmc.protocol.bedrock.packet.PlayerHotbarPacket import kotlin.math.floor /** - * Places an obsidian base + end crystal next to a target, plus an (best-effort) piston + redstone - * setup to try to pop the crystal via a block push instead of hitting it. Ported from the - * PistonCrystal/PistonCrystal2 C++ reference + ideas from ModuleCrystalAura.kt (ProtoHax), now that - * WClient has real block lookups via [com.retrivedmods.wclient.game.world.Level.getBlockAt]. + * 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 * - * Known limitations vs the original: - * - Piston orientation in Bedrock is set from the placer's look direction at placement time. This - * fakes that by mutating the outgoing PlayerAuthInputPacket's rotation right before placing, the - * same trick AntiCrystal/Surround use for position - it's not guaranteed as reliable as an actual - * client rotating for real. - * - Block data for chunks loaded via blob caching or the newer per-subchunk-request system isn't - * tracked (see Level.kt), so getBlockAt() may report air for those even if something's really - * there. In that case placements will just be rejected server-side, same as if this weren't - * checked at all. - * - Damage estimation (session.level.simulateExplosionDamage) is a distance-only approximation with - * no block-occlusion/exposure factor, so it's an upper bound, not exact. - * - Direct-attack fallback (autoAttackCrystal) is what actually guarantees detonation; the - * piston/redstone path is a best-effort bonus on top of that, not a replacement for it. + * 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) { @@ -52,20 +46,23 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { 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)) } - private enum class Step { OBSIDIAN, CRYSTAL, PISTON, REDSTONE, DONE } + 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 - private var obsidianPos: Vector3i? = null - private var pistonPos: Vector3i? = null - private var redstonePos: Vector3i? = null - private var pushDir: Vector3i? = null + private var placement: Placement? = null private var tickCounter = 0 private var oldSlot = -1 private val lastCrystalAttack = HashMap() @@ -85,10 +82,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { private fun resetState() { step = Step.DONE - obsidianPos = null - pistonPos = null - redstonePos = null - pushDir = null + placement = null tickCounter = 0 oldSlot = -1 lastCrystalAttack.clear() @@ -99,16 +93,21 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { val packet = interceptablePacket.packet if (packet !is PlayerAuthInputPacket) return - // Always try to guarantee detonation of any crystal that's already out there, regardless - // of whether we're mid-placement - this is the part that actually reliably deals damage. + // Guaranteed detonation path - runs every tick regardless of placement state. if (autoAttackCrystal) { attackNearbyCrystals() } if (step == Step.DONE) { - val target = findTarget() ?: return - val base = findPlacementSpot(target) ?: return - beginPlacement(base, target) + 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 + } + } } if (tickCounter < placeDelayTicks) { @@ -120,7 +119,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { advanceStateMachine(packet) } - // --- target / spot selection ----------------------------------------------------------- + // --- target selection ------------------------------------------------------------------- private fun findTarget(): Entity? { val localPlayer = session.localPlayer @@ -146,109 +145,108 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { } } - /** - * Looks for a block directly adjacent to the target (one of the 4 cardinal neighbours at the - * target's feet level) that's solid, with air immediately above it (room for obsidian -> crystal). - * Real getBlockAt() checks now that Level tracks chunk data. - */ - private fun findPlacementSpot(target: Entity): Vector3i? { + // --- placement search (PistonCrystal.cpp: findValidPlacement/calculatePlacement) -------- + + private fun findValidPlacement(target: Entity): Placement? { val targetPos = target.vec3Position - val baseX = floor(targetPos.x).toInt() - val baseY = floor(targetPos.y).toInt() - val baseZ = floor(targetPos.z).toInt() - - val offsets = listOf(1 to 0, -1 to 0, 0 to 1, 0 to -1) - for ((dx, dz) in offsets) { - val candidate = Vector3i.from(baseX + dx, baseY - 1, baseZ + dz) - val at = session.level.getBlockAt(candidate.x, candidate.y + 1, candidate.z) - // crystal spot itself must be clear; the block below may be air too - in that case - // we'll place our own obsidian there first (see isObsidianNeeded-equivalent check - // in beginPlacement). - if (at.identifier.isAirLike()) { - if (session.localPlayer.distance(Vector3f.from(candidate.x + 0.5f, candidate.y + 1f, candidate.z + 0.5f)) <= range + 1f) { - return candidate + 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) + } + + for (dir in orderedDirs) { + for (yLevel in 0..1) { + val config = calculatePlacement(targetBlockPos, dir, yLevel) + if (isValidPlacement(config, target)) { + return config } } } return null } - private fun String.isAirLike(): Boolean { - return this == "minecraft:air" + 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) } - // --- placement sequencing -------------------------------------------------------------- + private fun isAir(pos: Vector3i) = session.level.getBlockAt(pos).identifier == "minecraft:air" - private fun beginPlacement(base: Vector3i, target: Entity) { - val localPlayer = session.localPlayer + 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 { + if (!canPlaceCrystal(config.crystalPos)) return false + if (!canPlaceBlock(config.pistonPos)) return false + if (!canPlaceBlock(config.redstonePos)) return false + + 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 false - val crystalCenter = Vector3f.from(base.x + 0.5f, base.y + 2f, base.z + 0.5f) var estimatedTargetDamage = 0f var estimatedSelfDamage = 0f session.level.simulateExplosionDamage( - crystalCenter, + Vector3f.from(config.crystalPos.x + 0.5f, config.crystalPos.y + 0.5f, config.crystalPos.z + 0.5f), EXPLOSION_SIZE, - extraEntities = listOf(localPlayer) + extraEntities = listOf(session.localPlayer) ) { entity, damage -> if (entity.runtimeEntityId == target.runtimeEntityId) estimatedTargetDamage = damage if (entity is LocalPlayer) estimatedSelfDamage = damage } + if (estimatedTargetDamage < targetDamageMin) return false + if (estimatedSelfDamage > selfDamageLimit) return false - if (estimatedTargetDamage < targetDamageMin) return - if (estimatedSelfDamage > selfDamageLimit) return - - obsidianPos = base - - // matches PistonCrystal2's isObsidianNeeded(): only place obsidian if the spot doesn't - // already have a solid block there (e.g. the target is already standing on real ground). - val obsidianAlreadyPresent = !session.level.getBlockAt(base).identifier.isAirLike() - - // push direction: away from the target, so a piston push (if it lands) knocks the - // obsidian - and whatever's resting on it - out from under the crystal. - val targetPos = target.vec3Position - val dx = base.x + 0.5f - targetPos.x - val dz = base.z + 0.5f - targetPos.z - pushDir = if (kotlin.math.abs(dx) > kotlin.math.abs(dz)) { - Vector3i.from(if (dx > 0) 1 else -1, 0, 0) - } else { - Vector3i.from(0, 0, if (dz > 0) 1 else -1) - } - pistonPos = base.add(pushDir) - redstonePos = pistonPos!!.add(pushDir) - - step = if (obsidianAlreadyPresent) Step.CRYSTAL else Step.OBSIDIAN - tickCounter = 0 + return true } + // --- 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.OBSIDIAN -> { - val pos = obsidianPos ?: return resetState() - if (place(OBSIDIAN, pos, packet, faceUp = true)) { + 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 -> { - val pos = obsidianPos ?: return resetState() - val crystalPos = pos.add(0, 1, 0) - if (place(CRYSTAL_ITEM, crystalPos, packet, faceUp = true)) { - step = if (usePiston) Step.PISTON else Step.DONE + if (place(CRYSTAL_ITEM, config.crystalPos, packet, lookTowards = config.crystalPos)) { + step = if (usePiston) Step.REDSTONE else Step.DONE if (!usePiston) resetState() } } - Step.PISTON -> { - val pos = pistonPos ?: return resetState() - val dir = pushDir ?: return resetState() - if (place(PISTON, pos, packet, faceUp = false, lookTowards = obsidianPos, pushDir = dir)) { - step = Step.REDSTONE - } - } - Step.REDSTONE -> { - val pos = redstonePos ?: return resetState() - if (place(REDSTONE_BLOCK, pos, packet, faceUp = false)) { + if (place(REDSTONE_BLOCK, config.redstonePos, packet, lookTowards = null)) { resetState() } } @@ -257,23 +255,17 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { } } - /** - * Sends one block placement attempt. Returns true whether or not the item was found/placed so - * the state machine keeps moving forward - we can't confirm server-side success without real - * world feedback, so we don't retry indefinitely. - */ private fun place( identifier: String, pos: Vector3i, authInput: PlayerAuthInputPacket, - faceUp: Boolean, - lookTowards: Vector3i? = null, + lookTowards: Vector3i?, pushDir: Vector3i? = null ): Boolean { val localPlayer = session.localPlayer val slot = localPlayer.inventory.searchForItemInHotbar { it.definition?.identifier == identifier - } ?: return true // nothing to place with, don't get stuck retrying forever + } ?: return true // don't get stuck retrying forever if we're out of the item if (oldSlot == -1) { oldSlot = localPlayer.inventory.heldItemSlot @@ -292,15 +284,14 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { val transaction = InventoryTransactionPacket().apply { transactionType = InventoryTransactionType.ITEM_USE - actionType = 0 // click block / place + actionType = 0 blockPosition = pos - blockFace = if (faceUp) 1 else (pushDir?.let { faceForDirection(it) } ?: 1) + blockFace = pushDir?.let { faceForDirection(it) } ?: 1 hotbarSlot = slot itemInHand = localPlayer.inventory.hand playerPosition = localPlayer.vec3Position clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) } - session.serverBound(transaction) return true } @@ -317,16 +308,15 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { } private fun switchToSlot(slot: Int) { - // matches the existing (working) HotbarSwitcherModule pattern exactly - only clientBound. val packet = PlayerHotbarPacket().apply { selectedHotbarSlot = slot containerId = 0 - setPacketField("selectHotbarSlot", true) + isSelectHotbarSlot = true } session.clientBound(packet) } - // --- crystal detonation fallback ------------------------------------------------------- + // --- crystal detonation fallback ---------------------------------------------------------- private fun attackNearbyCrystals() { val localPlayer = session.localPlayer 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 140f94ce..a5995da3 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 @@ -18,6 +18,7 @@ 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.math.vector.Vector3f @@ -78,7 +79,13 @@ class Level(val session: GameSession) { } if (packet.isCachingEnabled || packet.isRequestSubChunks) { - // blob-cache / newer subchunk-request chunk loading isn't supported, see note above + // blob-cache chunk loading (isCachingEnabled) still isn't supported - servers + // using that will still leave us blind. But isRequestSubChunks (the modern, + // now near-universal loading path where LevelChunkPacket only carries heightmap/ + // biome data and actual block data arrives later via individual SubChunkPacket + // responses) IS now handled below, in the SubChunkPacket branch - so don't + // bail out here for that case, just skip the (block-data-less) LevelChunkPacket + // itself. return } @@ -97,6 +104,43 @@ class Level(val session: GameSession) { } } + is SubChunkPacket -> { + if (!session.isBlockMappingInitialized) return + + // This is the response to the (real, physical) client's own SubChunkRequestPacket, + // which we don't send ourselves - we're just observing it pass through the relay. + // Protocol semantics (best effort, not verified against a live packet capture): + // `centerPosition` is the request's base (chunkX, sectionY, chunkZ) - sectionY as + // a *section* index (worldBlockY shr 4), not a block Y - and each SubChunkData's + // own `position` is a small relative offset from that base. In the overwhelmingly + // common case (a single chunk column, vertical prefetch only) x/z offsets are 0 + // and only the y offset varies, but we honor x/z too in case a server ever sends + // neighboring columns this way. + for (subChunkData in packet.subChunks) { + val data = subChunkData.data ?: continue + if (data.readableBytes() <= 0) continue // no data = not found/failed, nothing to store + + val offset = subChunkData.position + val chunkX = packet.centerPosition.x + offset.x + val chunkZ = packet.centerPosition.z + offset.z + val worldSectionY = packet.centerPosition.y + offset.y + + val chunk = chunks.getOrPut(Chunk.hash(chunkX, chunkZ)) { + Chunk(chunkX, chunkZ, is384WorldSupported, session.blockMapping) + } + // Chunk.readSubChunk()/getBlockAt() index their sectionStorage array from 0, + // with 384-worlds offset by +4 sections (-64 world Y -> array index 0) - see + // Chunk.getBlockAt()'s `(yIn + 64) shr 4` for the equivalent block-Y-based math. + val sectionIndex = if (is384WorldSupported) worldSectionY + 4 else worldSectionY + try { + chunk.readSubChunk(sectionIndex, data.duplicate()) + } catch (e: Exception) { + // malformed/unexpected subchunk data for this protocol version - skip it + // rather than crash the relay + } + } + } + is UpdateBlockPacket -> { if (packet.dataLayer == 0) { setBlockIdAt( From 61172a0b7948d6d369870dc812bb810713cba9c1 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 18:24:12 +0900 Subject: [PATCH 49/82] Add files via upload --- .../retrivedmods/wclient/game/world/Level.kt | 86 +++++++++---------- 1 file changed, 41 insertions(+), 45 deletions(-) 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 a5995da3..0537dbce 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 @@ -18,7 +18,6 @@ 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.math.vector.Vector3f @@ -79,13 +78,7 @@ class Level(val session: GameSession) { } if (packet.isCachingEnabled || packet.isRequestSubChunks) { - // blob-cache chunk loading (isCachingEnabled) still isn't supported - servers - // using that will still leave us blind. But isRequestSubChunks (the modern, - // now near-universal loading path where LevelChunkPacket only carries heightmap/ - // biome data and actual block data arrives later via individual SubChunkPacket - // responses) IS now handled below, in the SubChunkPacket branch - so don't - // bail out here for that case, just skip the (block-data-less) LevelChunkPacket - // itself. + // blob-cache / newer subchunk-request chunk loading isn't supported, see note above return } @@ -104,43 +97,6 @@ class Level(val session: GameSession) { } } - is SubChunkPacket -> { - if (!session.isBlockMappingInitialized) return - - // This is the response to the (real, physical) client's own SubChunkRequestPacket, - // which we don't send ourselves - we're just observing it pass through the relay. - // Protocol semantics (best effort, not verified against a live packet capture): - // `centerPosition` is the request's base (chunkX, sectionY, chunkZ) - sectionY as - // a *section* index (worldBlockY shr 4), not a block Y - and each SubChunkData's - // own `position` is a small relative offset from that base. In the overwhelmingly - // common case (a single chunk column, vertical prefetch only) x/z offsets are 0 - // and only the y offset varies, but we honor x/z too in case a server ever sends - // neighboring columns this way. - for (subChunkData in packet.subChunks) { - val data = subChunkData.data ?: continue - if (data.readableBytes() <= 0) continue // no data = not found/failed, nothing to store - - val offset = subChunkData.position - val chunkX = packet.centerPosition.x + offset.x - val chunkZ = packet.centerPosition.z + offset.z - val worldSectionY = packet.centerPosition.y + offset.y - - val chunk = chunks.getOrPut(Chunk.hash(chunkX, chunkZ)) { - Chunk(chunkX, chunkZ, is384WorldSupported, session.blockMapping) - } - // Chunk.readSubChunk()/getBlockAt() index their sectionStorage array from 0, - // with 384-worlds offset by +4 sections (-64 world Y -> array index 0) - see - // Chunk.getBlockAt()'s `(yIn + 64) shr 4` for the equivalent block-Y-based math. - val sectionIndex = if (is384WorldSupported) worldSectionY + 4 else worldSectionY - try { - chunk.readSubChunk(sectionIndex, data.duplicate()) - } catch (e: Exception) { - // malformed/unexpected subchunk data for this protocol version - skip it - // rather than crash the relay - } - } - } - is UpdateBlockPacket -> { if (packet.dataLayer == 0) { setBlockIdAt( @@ -289,4 +245,44 @@ class Level(val session: GameSession) { 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 simply + * isn't tracked/loaded yet - see the LevelChunkPacket handling notes above for when that + * happens) 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 From 7e4c5ec2f48f1351b25d838337d64b2d42e61bc8 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 18:26:53 +0900 Subject: [PATCH 50/82] Add files via upload --- .../wclient/game/entity/LocalPlayer.kt | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) 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..aba501ad 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,14 @@ 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 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 +91,57 @@ 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 + clickPosition = Vector3f.from( + Math.random().toFloat(), + Math.random().toFloat(), + Math.random().toFloat() + ) + blockDefinition = definition + + if (inventoriesServerAuthoritative) { + 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) + } + fun swing() { val animatePacket = AnimatePacket() animatePacket.action = AnimatePacket.Action.SWING_ARM From 7a16edd1320d4dcc661b3a7e55345db6011309eb Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 18:27:49 +0900 Subject: [PATCH 51/82] Add files via upload --- .../game/module/combat/PistonCrystalModule.kt | 255 ++++++++++-------- .../game/module/combat/SurroundModule.kt | 242 +++++++---------- 2 files changed, 243 insertions(+), 254 deletions(-) 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 index dbdb41dd..5319b027 100644 --- 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 @@ -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 @@ -17,20 +19,24 @@ 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 + * Places an obsidian base + end crystal next to a target, plus an (best-effort) piston + redstone + * setup to try to pop the crystal via a block push instead of hitting it. Ported from the + * PistonCrystal/PistonCrystal2 C++ reference + ideas from ModuleCrystalAura.kt (ProtoHax), now that + * WClient has real block lookups via [com.retrivedmods.wclient.game.world.Level.getBlockAt]. * - * 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. + * Known limitations vs the original: + * - Piston orientation in Bedrock is set from the placer's look direction at placement time. This + * fakes that by mutating the outgoing PlayerAuthInputPacket's rotation right before placing, the + * same trick AntiCrystal/Surround use for position - it's not guaranteed as reliable as an actual + * client rotating for real. + * - Block data for chunks loaded via blob caching or the newer per-subchunk-request system isn't + * tracked (see Level.kt), so getBlockAt() may report air for those even if something's really + * there. In that case placements will just be rejected server-side, same as if this weren't + * checked at all. + * - Damage estimation (session.level.simulateExplosionDamage) is a distance-only approximation with + * no block-occlusion/exposure factor, so it's an upper bound, not exact. + * - Direct-attack fallback (autoAttackCrystal) is what actually guarantees detonation; the + * piston/redstone path is a best-effort bonus on top of that, not a replacement for it. */ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { @@ -46,23 +52,20 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { 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)) } - 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 enum class Step { OBSIDIAN, CRYSTAL, PISTON, REDSTONE, DONE } private var step = Step.DONE - private var placement: Placement? = null + private var obsidianPos: Vector3i? = null + private var pistonPos: Vector3i? = null + private var redstonePos: Vector3i? = null + private var pushDir: Vector3i? = null private var tickCounter = 0 private var oldSlot = -1 private val lastCrystalAttack = HashMap() @@ -82,7 +85,10 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { private fun resetState() { step = Step.DONE - placement = null + obsidianPos = null + pistonPos = null + redstonePos = null + pushDir = null tickCounter = 0 oldSlot = -1 lastCrystalAttack.clear() @@ -93,21 +99,16 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { val packet = interceptablePacket.packet if (packet !is PlayerAuthInputPacket) return - // Guaranteed detonation path - runs every tick regardless of placement state. + // Always try to guarantee detonation of any crystal that's already out there, regardless + // of whether we're mid-placement - this is the part that actually reliably deals damage. if (autoAttackCrystal) { attackNearbyCrystals() } 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 - } - } + val target = findTarget() ?: return + val base = findPlacementSpot(target) ?: return + beginPlacement(base, target) } if (tickCounter < placeDelayTicks) { @@ -119,7 +120,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { advanceStateMachine(packet) } - // --- target selection ------------------------------------------------------------------- + // --- target / spot selection ----------------------------------------------------------- private fun findTarget(): Entity? { val localPlayer = session.localPlayer @@ -145,108 +146,109 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { } } - // --- placement search (PistonCrystal.cpp: findValidPlacement/calculatePlacement) -------- - - private fun findValidPlacement(target: Entity): Placement? { + /** + * Looks for a block directly adjacent to the target (one of the 4 cardinal neighbours at the + * target's feet level) that's solid, with air immediately above it (room for obsidian -> crystal). + * Real getBlockAt() checks now that Level tracks chunk data. + */ + private fun findPlacementSpot(target: Entity): Vector3i? { 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) - } - - for (dir in orderedDirs) { - for (yLevel in 0..1) { - val config = calculatePlacement(targetBlockPos, dir, yLevel) - if (isValidPlacement(config, target)) { - return config + val baseX = floor(targetPos.x).toInt() + val baseY = floor(targetPos.y).toInt() + val baseZ = floor(targetPos.z).toInt() + + val offsets = listOf(1 to 0, -1 to 0, 0 to 1, 0 to -1) + for ((dx, dz) in offsets) { + val candidate = Vector3i.from(baseX + dx, baseY - 1, baseZ + dz) + val at = session.level.getBlockAt(candidate.x, candidate.y + 1, candidate.z) + // crystal spot itself must be clear; the block below may be air too - in that case + // we'll place our own obsidian there first (see isObsidianNeeded-equivalent check + // in beginPlacement). + if (at.identifier.isAirLike()) { + if (session.localPlayer.distance(Vector3f.from(candidate.x + 0.5f, candidate.y + 1f, candidate.z + 0.5f)) <= range + 1f) { + return candidate } } } return 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 String.isAirLike(): Boolean { + return this == "minecraft:air" } - private fun isAir(pos: Vector3i) = session.level.getBlockAt(pos).identifier == "minecraft:air" + // --- placement sequencing -------------------------------------------------------------- - 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 { - if (!canPlaceCrystal(config.crystalPos)) return false - if (!canPlaceBlock(config.pistonPos)) return false - if (!canPlaceBlock(config.redstonePos)) return false - - 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 false + private fun beginPlacement(base: Vector3i, target: Entity) { + val localPlayer = session.localPlayer + val crystalCenter = Vector3f.from(base.x + 0.5f, base.y + 2f, base.z + 0.5f) 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), + crystalCenter, EXPLOSION_SIZE, - extraEntities = listOf(session.localPlayer) + extraEntities = listOf(localPlayer) ) { entity, damage -> if (entity.runtimeEntityId == target.runtimeEntityId) estimatedTargetDamage = damage if (entity is LocalPlayer) estimatedSelfDamage = damage } - if (estimatedTargetDamage < targetDamageMin) return false - if (estimatedSelfDamage > selfDamageLimit) return false - return true - } + if (estimatedTargetDamage < targetDamageMin) return + if (estimatedSelfDamage > selfDamageLimit) return - // --- 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. + obsidianPos = base - private fun advanceStateMachine(packet: PlayerAuthInputPacket) { - val config = placement ?: return resetState() + // matches PistonCrystal2's isObsidianNeeded(): only place obsidian if the spot doesn't + // already have a solid block there (e.g. the target is already standing on real ground). + val obsidianAlreadyPresent = !session.level.getBlockAt(base).identifier.isAirLike() + + // push direction: away from the target, so a piston push (if it lands) knocks the + // obsidian - and whatever's resting on it - out from under the crystal. + val targetPos = target.vec3Position + val dx = base.x + 0.5f - targetPos.x + val dz = base.z + 0.5f - targetPos.z + pushDir = if (kotlin.math.abs(dx) > kotlin.math.abs(dz)) { + Vector3i.from(if (dx > 0) 1 else -1, 0, 0) + } else { + Vector3i.from(0, 0, if (dz > 0) 1 else -1) + } + pistonPos = base.add(pushDir) + redstonePos = pistonPos!!.add(pushDir) + + step = if (obsidianAlreadyPresent) Step.CRYSTAL else Step.OBSIDIAN + tickCounter = 0 + } + private fun advanceStateMachine(packet: PlayerAuthInputPacket) { 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.OBSIDIAN -> { + val pos = obsidianPos ?: return resetState() + if (place(OBSIDIAN, pos, packet, faceUp = true)) { step = Step.CRYSTAL } } Step.CRYSTAL -> { - if (place(CRYSTAL_ITEM, config.crystalPos, packet, lookTowards = config.crystalPos)) { - step = if (usePiston) Step.REDSTONE else Step.DONE + val pos = obsidianPos ?: return resetState() + val crystalPos = pos.add(0, 1, 0) + if (place(CRYSTAL_ITEM, crystalPos, packet, faceUp = true)) { + step = if (usePiston) Step.PISTON else Step.DONE if (!usePiston) resetState() } } + Step.PISTON -> { + val pos = pistonPos ?: return resetState() + val dir = pushDir ?: return resetState() + if (place(PISTON, pos, packet, faceUp = false, lookTowards = obsidianPos, pushDir = dir)) { + step = Step.REDSTONE + } + } + Step.REDSTONE -> { - if (place(REDSTONE_BLOCK, config.redstonePos, packet, lookTowards = null)) { + val pos = redstonePos ?: return resetState() + if (place(REDSTONE_BLOCK, pos, packet, faceUp = false)) { resetState() } } @@ -255,17 +257,23 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { } } + /** + * Sends one block placement attempt. Returns true whether or not the item was found/placed so + * the state machine keeps moving forward - we can't confirm server-side success without real + * world feedback, so we don't retry indefinitely. + */ private fun place( identifier: String, pos: Vector3i, authInput: PlayerAuthInputPacket, - lookTowards: Vector3i?, + faceUp: Boolean, + lookTowards: Vector3i? = null, pushDir: Vector3i? = null ): Boolean { val localPlayer = session.localPlayer val slot = localPlayer.inventory.searchForItemInHotbar { it.definition?.identifier == identifier - } ?: return true // don't get stuck retrying forever if we're out of the item + } ?: return true // nothing to place with, don't get stuck retrying forever if (oldSlot == -1) { oldSlot = localPlayer.inventory.heldItemSlot @@ -282,17 +290,33 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { authInput.rotation = Vector3f.from(0f, yaw, yaw) } - val transaction = InventoryTransactionPacket().apply { - transactionType = InventoryTransactionType.ITEM_USE - actionType = 0 - blockPosition = pos - blockFace = pushDir?.let { faceForDirection(it) } ?: 1 - hotbarSlot = slot - itemInHand = localPlayer.inventory.hand - playerPosition = localPlayer.vec3Position - clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) + // Same underlying issue as SurroundModule had: Bedrock's block-place transaction places + // the new block adjacent to whatever existing block you "click", not at blockPosition + // itself. Clicking the empty target cell directly gets silently dropped server-side. + // Figure out which existing block we're clicking and which face of it, such that the + // resulting placement lands at `pos`: + // - faceUp: click the block below `pos` from its top (UP) face. + // - pushDir: click the block on the opposite side of `pos` from pushDir, on the face + // that points towards `pos` (i.e. towards pushDir). + // - neither: fall back to the same below/UP behavior as faceUp. + val face = if (faceUp) 1 else (pushDir?.let { faceForDirection(it) } ?: 1) + val referencePos = when { + faceUp || pushDir == null -> Vector3i.from(pos.x, pos.y - 1, pos.z) + else -> Vector3i.from(pos.x - pushDir.x, pos.y - pushDir.y, pos.z - pushDir.z) } - session.serverBound(transaction) + + // The reference block has to actually exist (be non-air) or the server just drops the + // transaction - same issue Surround had. Unlike Surround's simple ring placement, this + // module picks the reference position itself based on faceUp/pushDir, so we can't fall + // back to a different neighbor here - just bail out for this attempt. + if (session.level.isAir(referencePos)) { + return false + } + + val runtimeId = session.blockMapping.getRuntimeIdByIdentifier(identifier) ?: return true + val definition = session.blockMapping.getDefinition(runtimeId) + + localPlayer.placeBlock(pos, referencePos, face, definition) return true } @@ -308,15 +332,16 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { } private fun switchToSlot(slot: Int) { + // matches the existing (working) HotbarSwitcherModule pattern exactly - only clientBound. val packet = PlayerHotbarPacket().apply { selectedHotbarSlot = slot containerId = 0 - isSelectHotbarSlot = true + setPacketField("selectHotbarSlot", true) } session.clientBound(packet) } - // --- crystal detonation fallback ---------------------------------------------------------- + // --- crystal detonation fallback ------------------------------------------------------- private fun attackNearbyCrystals() { val localPlayer = session.localPlayer 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 index 575e61b2..7a21f1ca 100644 --- 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 @@ -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 @@ -12,36 +14,36 @@ 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. + * Places blocks (obsidian by default) around the player to reduce end crystal / explosion damage. + * + * NOTE: unlike the original C++ (Mod-Menu-style) implementation this was ported from, WClient is a + * relay/proxy client and does not keep a local copy of world block state (no BlockSource / chunk data). + * Because of that this version can NOT check whether a target position is actually air, replaceable, + * or already occupied before placing - it just fires placement packets at the 8 positions around the + * player each tick and lets the server accept or silently reject them, the same way a real client's + * packet would be validated server-side. "Dynamic" hitbox expansion is also approximated using a fixed + * player-sized radius per nearby entity (since Entity here has no AABB/hitbox size), not true AABB + * collision like the original. */ class SurroundModule : Module("surround", ModuleCategory.Combat) { + private var blockIdentifier by stringValue("block", "minecraft:obsidian", null) + 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 blocksPerTick by intValue("blocks_per_tick", 4, 1..8) + private var placeBelowFeet by boolValue("place_below", true) 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 dynamicRadius by floatValue("dynamic_radius", 3f, 1f..6f) - private var placeList: MutableList = mutableListOf() + private var placeQueue: MutableList = mutableListOf() private var tickCounter = 0 private var oldSlot = -1 private var hasCentered = false override fun onEnabled() { super.onEnabled() - placeList = mutableListOf() + placeQueue = mutableListOf() tickCounter = 0 oldSlot = -1 hasCentered = false @@ -49,168 +51,130 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { override fun onDisabled() { super.onDisabled() - placeList.clear() + placeQueue.clear() if (oldSlot != -1 && isSessionCreated) { - switchToSlot(oldSlot) + restoreSlot() } oldSlot = -1 hasCentered = false } override fun beforePacketBound(interceptablePacket: InterceptablePacket) { - if (!isEnabled || !isSessionCreated) return + if (!isEnabled || !isSessionCreated) { + return + } + val packet = interceptablePacket.packet - if (packet !is PlayerAuthInputPacket) return + 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 == blockIdentifier } - val obsidianSlot = localPlayer.inventory.searchForItemInHotbar { - it.definition?.identifier == OBSIDIAN + if (obsidianSlot == null) { + // nothing to place with, don't spam the server + return } - placeList = computePlaceList() + if (oldSlot == -1) { + oldSlot = localPlayer.inventory.heldItemSlot + } - 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 (center && !hasCentered) { + val pos = localPlayer.vec3Position + val centeredPos = Vector3f.from(floor(pos.x) + 0.5f, pos.y, floor(pos.z) + 0.5f) + packet.position = centeredPos + hasCentered = true } - if (obsidianSlot != null && placeList.isNotEmpty()) { - if (oldSlot == -1) { - oldSlot = localPlayer.inventory.heldItemSlot - } + if (placeQueue.isEmpty()) { + placeQueue = computePlacements(localPlayer.vec3Position).toMutableList() + } - 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 (tickCounter < placeDelayTicks) { + tickCounter++ + return } + tickCounter = 0 - if (placeButton) { - val buttonSlot = localPlayer.inventory.searchForItemInHotbar { - it.definition?.identifier == BUTTON - } - if (buttonSlot != null) { - val buttonPos = Vector3i.from( - floor(localPlayer.vec3Position.x).toInt(), - floor(localPlayer.vec3Position.y).toInt() - 1, - floor(localPlayer.vec3Position.z).toInt() - ) - place(BUTTON, buttonPos, buttonSlot) - } + if (localPlayer.inventory.heldItemSlot != obsidianSlot) { + switchToSlot(obsidianSlot) } - } - /** [Surround.cpp]'s canPlaceBlock(): the target itself must be air (or, with airPlace, anything). */ - private fun canPlaceAt(pos: Vector3i): Boolean { - if (airPlace) return true - return session.level.getBlockAt(pos).identifier == "minecraft:air" + var placed = 0 + val iterator = placeQueue.iterator() + while (iterator.hasNext() && placed < blocksPerTick) { + val target = iterator.next() + placeBlock(target) + iterator.remove() + placed++ + } } - private fun computePlaceList(): MutableList { - val localPlayer = session.localPlayer - val pos = localPlayer.vec3Position - val currentPos = Vector3i.from(floor(pos.x).toInt(), floor(pos.y + 0.5f).toInt(), floor(pos.z).toInt()) + private fun computePlacements(playerPos: Vector3f): List { + val baseX = floor(playerPos.x).toInt() + val baseY = floor(playerPos.y).toInt() - if (placeBelowFeet) 1 else 0 + val baseZ = floor(playerPos.z).toInt() - var xStart = -1 - var zStart = -1 - var xEnd = 1 - var zEnd = 1 + var minX = -1 + var maxX = 1 + var minZ = -1 + var maxZ = 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 level = session.level + level.entityMap.values.forEach { entity -> + if (entity.distance(playerPos) > dynamicRadius) return@forEach + val dx = entity.posX - playerPos.x + val dz = entity.posZ - playerPos.z + if (dx > 1.2f) maxX = maxOf(maxX, 2) + if (dx < -1.2f) minX = minOf(minX, -2) + if (dz > 1.2f) maxZ = maxOf(maxZ, 2) + if (dz < -1.2f) minZ = minOf(minZ, -2) } } - 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) - } + val positions = mutableListOf() + for (x in minX..maxX) { + for (z in minZ..maxZ) { + if (x == 0 && z == 0) continue + // only the ring around the player, skip interior tiles when the box grew beyond 3x3 + if (x !in -1..1 && z !in -1..1) continue + positions.add(Vector3i.from(baseX + x, baseY, baseZ + z)) } } - - 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 packet = InventoryTransactionPacket().apply { - transactionType = InventoryTransactionType.ITEM_USE - actionType = 0 - blockPosition = pos - blockFace = 1 - hotbarSlot = slot - itemInHand = localPlayer.inventory.hand - playerPosition = localPlayer.vec3Position - clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) - } - session.serverBound(packet) + return positions } private fun switchToSlot(slot: Int) { val packet = PlayerHotbarPacket().apply { selectedHotbarSlot = slot containerId = 0 - isSelectHotbarSlot = true + setPacketField("selectHotbarSlot", true) } + session.serverBound(packet) session.clientBound(packet) } + + private fun restoreSlot() { + switchToSlot(oldSlot) + } + + private fun placeBlock(pos: Vector3i) { + val localPlayer = session.localPlayer + val runtimeId = session.blockMapping.getRuntimeIdByIdentifier(blockIdentifier) ?: return + val definitionToPlace = session.blockMapping.getDefinition(runtimeId) + + // Find a real, currently non-air neighbor to click so the new block actually lands at + // `pos` - see Level.findPlacementReference for why. If nothing usable is found (target + // isn't air, chunk not loaded, or genuinely floating in open air), skip this position + // rather than fire a packet the server will just drop. + val (referencePos, face) = session.level.findPlacementReference(pos) ?: return + + localPlayer.placeBlock(pos, referencePos, face, definitionToPlace) + } } From 86e4d8b4419b9889681ab13c6ba1ddd18c2535e3 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Fri, 21 Aug 2026 20:52:14 +0900 Subject: [PATCH 52/82] Add files via upload --- .../game/module/combat/PistonCrystalModule.kt | 276 +++++++++--------- .../game/module/combat/SurroundModule.kt | 269 ++++++++++------- 2 files changed, 304 insertions(+), 241 deletions(-) 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 index 5319b027..e9c4d8c4 100644 --- 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 @@ -1,7 +1,5 @@ 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 @@ -19,24 +17,20 @@ import org.cloudburstmc.protocol.bedrock.packet.PlayerHotbarPacket import kotlin.math.floor /** - * Places an obsidian base + end crystal next to a target, plus an (best-effort) piston + redstone - * setup to try to pop the crystal via a block push instead of hitting it. Ported from the - * PistonCrystal/PistonCrystal2 C++ reference + ideas from ModuleCrystalAura.kt (ProtoHax), now that - * WClient has real block lookups via [com.retrivedmods.wclient.game.world.Level.getBlockAt]. + * 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 * - * Known limitations vs the original: - * - Piston orientation in Bedrock is set from the placer's look direction at placement time. This - * fakes that by mutating the outgoing PlayerAuthInputPacket's rotation right before placing, the - * same trick AntiCrystal/Surround use for position - it's not guaranteed as reliable as an actual - * client rotating for real. - * - Block data for chunks loaded via blob caching or the newer per-subchunk-request system isn't - * tracked (see Level.kt), so getBlockAt() may report air for those even if something's really - * there. In that case placements will just be rejected server-side, same as if this weren't - * checked at all. - * - Damage estimation (session.level.simulateExplosionDamage) is a distance-only approximation with - * no block-occlusion/exposure factor, so it's an upper bound, not exact. - * - Direct-attack fallback (autoAttackCrystal) is what actually guarantees detonation; the - * piston/redstone path is a best-effort bonus on top of that, not a replacement for it. + * 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) { @@ -52,20 +46,23 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { 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)) } - private enum class Step { OBSIDIAN, CRYSTAL, PISTON, REDSTONE, DONE } + private data class Placement(val crystalPos: Vector3i, val pistonPos: Vector3i, val redstonePos: Vector3i, val dir: Vector3i) + + private enum class Step { CRYSTAL, PISTON, REDSTONE, DONE } private var step = Step.DONE - private var obsidianPos: Vector3i? = null - private var pistonPos: Vector3i? = null - private var redstonePos: Vector3i? = null - private var pushDir: Vector3i? = null + private var placement: Placement? = null private var tickCounter = 0 private var oldSlot = -1 private val lastCrystalAttack = HashMap() @@ -85,10 +82,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { private fun resetState() { step = Step.DONE - obsidianPos = null - pistonPos = null - redstonePos = null - pushDir = null + placement = null tickCounter = 0 oldSlot = -1 lastCrystalAttack.clear() @@ -99,16 +93,21 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { val packet = interceptablePacket.packet if (packet !is PlayerAuthInputPacket) return - // Always try to guarantee detonation of any crystal that's already out there, regardless - // of whether we're mid-placement - this is the part that actually reliably deals damage. + // Guaranteed detonation path - runs every tick regardless of placement state. if (autoAttackCrystal) { attackNearbyCrystals() } if (step == Step.DONE) { - val target = findTarget() ?: return - val base = findPlacementSpot(target) ?: return - beginPlacement(base, target) + val target = findTarget() + if (target != null) { + val found = findValidPlacement(target) + if (found != null) { + placement = found + step = Step.CRYSTAL + tickCounter = 0 + } + } } if (tickCounter < placeDelayTicks) { @@ -120,7 +119,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { advanceStateMachine(packet) } - // --- target / spot selection ----------------------------------------------------------- + // --- target selection ------------------------------------------------------------------- private fun findTarget(): Entity? { val localPlayer = session.localPlayer @@ -146,109 +145,99 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { } } - /** - * Looks for a block directly adjacent to the target (one of the 4 cardinal neighbours at the - * target's feet level) that's solid, with air immediately above it (room for obsidian -> crystal). - * Real getBlockAt() checks now that Level tracks chunk data. - */ - private fun findPlacementSpot(target: Entity): Vector3i? { + // --- placement search (PistonCrystal.cpp: findValidPlacement/calculatePlacement) -------- + + private fun findValidPlacement(target: Entity): Placement? { val targetPos = target.vec3Position - val baseX = floor(targetPos.x).toInt() - val baseY = floor(targetPos.y).toInt() - val baseZ = floor(targetPos.z).toInt() - - val offsets = listOf(1 to 0, -1 to 0, 0 to 1, 0 to -1) - for ((dx, dz) in offsets) { - val candidate = Vector3i.from(baseX + dx, baseY - 1, baseZ + dz) - val at = session.level.getBlockAt(candidate.x, candidate.y + 1, candidate.z) - // crystal spot itself must be clear; the block below may be air too - in that case - // we'll place our own obsidian there first (see isObsidianNeeded-equivalent check - // in beginPlacement). - if (at.identifier.isAirLike()) { - if (session.localPlayer.distance(Vector3f.from(candidate.x + 0.5f, candidate.y + 1f, candidate.z + 0.5f)) <= range + 1f) { - return candidate + 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) + } + + for (dir in orderedDirs) { + for (yLevel in 0..1) { + val config = calculatePlacement(targetBlockPos, dir, yLevel) + if (isValidPlacement(config, target)) { + return config } } } return null } - private fun String.isAirLike(): Boolean { - return this == "minecraft:air" + 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) } - // --- placement sequencing -------------------------------------------------------------- + private fun isAir(pos: Vector3i) = session.level.getBlockAt(pos).identifier == "minecraft:air" - private fun beginPlacement(base: Vector3i, target: Entity) { - val localPlayer = session.localPlayer + 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 { + if (!canPlaceCrystal(config.crystalPos)) return false + if (!canPlaceBlock(config.pistonPos)) return false + if (!canPlaceBlock(config.redstonePos)) return false + + 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 false - val crystalCenter = Vector3f.from(base.x + 0.5f, base.y + 2f, base.z + 0.5f) var estimatedTargetDamage = 0f var estimatedSelfDamage = 0f session.level.simulateExplosionDamage( - crystalCenter, + Vector3f.from(config.crystalPos.x + 0.5f, config.crystalPos.y + 0.5f, config.crystalPos.z + 0.5f), EXPLOSION_SIZE, - extraEntities = listOf(localPlayer) + extraEntities = listOf(session.localPlayer) ) { entity, damage -> if (entity.runtimeEntityId == target.runtimeEntityId) estimatedTargetDamage = damage if (entity is LocalPlayer) estimatedSelfDamage = damage } + if (estimatedTargetDamage < targetDamageMin) return false + if (estimatedSelfDamage > selfDamageLimit) return false - if (estimatedTargetDamage < targetDamageMin) return - if (estimatedSelfDamage > selfDamageLimit) return - - obsidianPos = base - - // matches PistonCrystal2's isObsidianNeeded(): only place obsidian if the spot doesn't - // already have a solid block there (e.g. the target is already standing on real ground). - val obsidianAlreadyPresent = !session.level.getBlockAt(base).identifier.isAirLike() - - // push direction: away from the target, so a piston push (if it lands) knocks the - // obsidian - and whatever's resting on it - out from under the crystal. - val targetPos = target.vec3Position - val dx = base.x + 0.5f - targetPos.x - val dz = base.z + 0.5f - targetPos.z - pushDir = if (kotlin.math.abs(dx) > kotlin.math.abs(dz)) { - Vector3i.from(if (dx > 0) 1 else -1, 0, 0) - } else { - Vector3i.from(0, 0, if (dz > 0) 1 else -1) - } - pistonPos = base.add(pushDir) - redstonePos = pistonPos!!.add(pushDir) - - step = if (obsidianAlreadyPresent) Step.CRYSTAL else Step.OBSIDIAN - tickCounter = 0 + return true } + // --- placement sequencing ---------------------------------------------------------------- + private fun advanceStateMachine(packet: PlayerAuthInputPacket) { - when (step) { - Step.OBSIDIAN -> { - val pos = obsidianPos ?: return resetState() - if (place(OBSIDIAN, pos, packet, faceUp = true)) { - step = Step.CRYSTAL - } - } + val config = placement ?: return resetState() + when (step) { Step.CRYSTAL -> { - val pos = obsidianPos ?: return resetState() - val crystalPos = pos.add(0, 1, 0) - if (place(CRYSTAL_ITEM, crystalPos, packet, faceUp = true)) { + if (place(CRYSTAL_ITEM, config.crystalPos, packet, lookTowards = config.crystalPos)) { step = if (usePiston) Step.PISTON else Step.DONE if (!usePiston) resetState() } } Step.PISTON -> { - val pos = pistonPos ?: return resetState() - val dir = pushDir ?: return resetState() - if (place(PISTON, pos, packet, faceUp = false, lookTowards = obsidianPos, pushDir = dir)) { + if (place(PISTON, config.pistonPos, packet, lookTowards = config.crystalPos, preferredFace = faceForDirection(Vector3i.from(-config.dir.x, 0, -config.dir.z)))) { step = Step.REDSTONE } } Step.REDSTONE -> { - val pos = redstonePos ?: return resetState() - if (place(REDSTONE_BLOCK, pos, packet, faceUp = false)) { + if (place(REDSTONE_BLOCK, config.redstonePos, packet, lookTowards = null)) { resetState() } } @@ -257,23 +246,17 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { } } - /** - * Sends one block placement attempt. Returns true whether or not the item was found/placed so - * the state machine keeps moving forward - we can't confirm server-side success without real - * world feedback, so we don't retry indefinitely. - */ private fun place( identifier: String, pos: Vector3i, authInput: PlayerAuthInputPacket, - faceUp: Boolean, - lookTowards: Vector3i? = null, - pushDir: Vector3i? = null + lookTowards: Vector3i?, + preferredFace: Int? = null ): Boolean { val localPlayer = session.localPlayer val slot = localPlayer.inventory.searchForItemInHotbar { it.definition?.identifier == identifier - } ?: return true // nothing to place with, don't get stuck retrying forever + } ?: return true // don't get stuck retrying forever if we're out of the item if (oldSlot == -1) { oldSlot = localPlayer.inventory.heldItemSlot @@ -290,34 +273,52 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { authInput.rotation = Vector3f.from(0f, yaw, yaw) } - // Same underlying issue as SurroundModule had: Bedrock's block-place transaction places - // the new block adjacent to whatever existing block you "click", not at blockPosition - // itself. Clicking the empty target cell directly gets silently dropped server-side. - // Figure out which existing block we're clicking and which face of it, such that the - // resulting placement lands at `pos`: - // - faceUp: click the block below `pos` from its top (UP) face. - // - pushDir: click the block on the opposite side of `pos` from pushDir, on the face - // that points towards `pos` (i.e. towards pushDir). - // - neither: fall back to the same below/UP behavior as faceUp. - val face = if (faceUp) 1 else (pushDir?.let { faceForDirection(it) } ?: 1) - val referencePos = when { - faceUp || pushDir == null -> Vector3i.from(pos.x, pos.y - 1, pos.z) - else -> Vector3i.from(pos.x - pushDir.x, pos.y - pushDir.y, pos.z - pushDir.z) + // Real Bedrock block placement clicks an existing SOLID block's face; the server computes + // the new block's position as (clickedBlockPos + faceNormal). Sending blockPosition = pos + // (the empty cell we want to fill) was wrong - resolve a real solid neighbour to click + // instead, preferring one that gives the orientation we actually want (e.g. for the + // piston's push direction) when possible. + val (refPos, face) = resolvePlacementReference(pos, preferredFace) ?: return true + + val transaction = InventoryTransactionPacket().apply { + transactionType = InventoryTransactionType.ITEM_USE + actionType = 0 + blockPosition = refPos + blockFace = face + hotbarSlot = slot + itemInHand = localPlayer.inventory.hand + playerPosition = localPlayer.vec3Position + clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) + blockDefinition = session.level.getBlockAt(refPos) } + session.serverBound(transaction) + return true + } - // The reference block has to actually exist (be non-air) or the server just drops the - // transaction - same issue Surround had. Unlike Surround's simple ring placement, this - // module picks the reference position itself based on faceUp/pushDir, so we can't fall - // back to a different neighbor here - just bail out for this attempt. - if (session.level.isAir(referencePos)) { - return false + /** + * Finds a real, currently-solid block adjacent to [target] and the face of it that faces + * [target], so a placement packet can click that face like a real client would (see place()). + * If [preferredFace] is given (e.g. to aim the piston in a specific push direction) and that + * particular neighbour happens to be solid, it's used first; otherwise falls back to any + * solid neighbour, preferring straight down. + * Bedrock face indices: 0=down,1=up,2=north(-z),3=south(+z),4=west(-x),5=east(+x). + */ + private fun resolvePlacementReference(target: Vector3i, preferredFace: Int? = null): Pair? { + val candidates = listOf( + target.add(0, -1, 0) to 1, + target.add(0, 0, -1) to 3, + target.add(0, 0, 1) to 2, + target.add(-1, 0, 0) to 5, + target.add(1, 0, 0) to 4, + target.add(0, 1, 0) to 0 + ) + if (preferredFace != null) { + val preferred = candidates.firstOrNull { it.second == preferredFace } + if (preferred != null && session.level.getBlockAt(preferred.first).identifier != "minecraft:air") { + return preferred + } } - - val runtimeId = session.blockMapping.getRuntimeIdByIdentifier(identifier) ?: return true - val definition = session.blockMapping.getDefinition(runtimeId) - - localPlayer.placeBlock(pos, referencePos, face, definition) - return true + return candidates.firstOrNull { (pos, _) -> session.level.getBlockAt(pos).identifier != "minecraft:air" } } /** Bedrock block face indices: 0=down,1=up,2=north,3=south,4=west,5=east */ @@ -332,16 +333,15 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { } private fun switchToSlot(slot: Int) { - // matches the existing (working) HotbarSwitcherModule pattern exactly - only clientBound. val packet = PlayerHotbarPacket().apply { selectedHotbarSlot = slot containerId = 0 - setPacketField("selectHotbarSlot", true) + isSelectHotbarSlot = true } session.clientBound(packet) } - // --- crystal detonation fallback ------------------------------------------------------- + // --- crystal detonation fallback ---------------------------------------------------------- private fun attackNearbyCrystals() { val localPlayer = session.localPlayer 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 index 7a21f1ca..3464a17c 100644 --- 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 @@ -1,7 +1,5 @@ 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 @@ -14,36 +12,36 @@ import org.cloudburstmc.protocol.bedrock.packet.PlayerHotbarPacket import kotlin.math.floor /** - * Places blocks (obsidian by default) around the player to reduce end crystal / explosion damage. - * - * NOTE: unlike the original C++ (Mod-Menu-style) implementation this was ported from, WClient is a - * relay/proxy client and does not keep a local copy of world block state (no BlockSource / chunk data). - * Because of that this version can NOT check whether a target position is actually air, replaceable, - * or already occupied before placing - it just fires placement packets at the 8 positions around the - * player each tick and lets the server accept or silently reject them, the same way a real client's - * packet would be validated server-side. "Dynamic" hitbox expansion is also approximated using a fixed - * player-sized radius per nearby entity (since Entity here has no AABB/hitbox size), not true AABB - * collision like the original. + * 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 blockIdentifier by stringValue("block", "minecraft:obsidian", null) - private var placeDelayTicks by intValue("place_delay", 1, 0..20) - private var blocksPerTick by intValue("blocks_per_tick", 4, 1..8) - private var placeBelowFeet by boolValue("place_below", true) + 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 dynamicRadius by floatValue("dynamic_radius", 3f, 1f..6f) + 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 placeQueue: MutableList = mutableListOf() + private var placeList: MutableList = mutableListOf() private var tickCounter = 0 private var oldSlot = -1 private var hasCentered = false override fun onEnabled() { super.onEnabled() - placeQueue = mutableListOf() + placeList = mutableListOf() tickCounter = 0 oldSlot = -1 hasCentered = false @@ -51,130 +49,195 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { override fun onDisabled() { super.onDisabled() - placeQueue.clear() + placeList.clear() if (oldSlot != -1 && isSessionCreated) { - restoreSlot() + switchToSlot(oldSlot) } oldSlot = -1 hasCentered = false } override fun beforePacketBound(interceptablePacket: InterceptablePacket) { - if (!isEnabled || !isSessionCreated) { - return - } - + if (!isEnabled || !isSessionCreated) return val packet = interceptablePacket.packet - if (packet !is PlayerAuthInputPacket) { - return - } + if (packet !is PlayerAuthInputPacket) return val localPlayer = session.localPlayer - val obsidianSlot = localPlayer.inventory.searchForItemInHotbar { - it.definition?.identifier == blockIdentifier - } - - if (obsidianSlot == null) { - // nothing to place with, don't spam the server - return - } - - if (oldSlot == -1) { - oldSlot = localPlayer.inventory.heldItemSlot - } - if (center && !hasCentered) { val pos = localPlayer.vec3Position - val centeredPos = Vector3f.from(floor(pos.x) + 0.5f, pos.y, floor(pos.z) + 0.5f) - packet.position = centeredPos + packet.position = Vector3f.from(floor(pos.x) + 0.5f, pos.y, floor(pos.z) + 0.5f) hasCentered = true } - if (placeQueue.isEmpty()) { - placeQueue = computePlacements(localPlayer.vec3Position).toMutableList() + val obsidianSlot = localPlayer.inventory.searchForItemInHotbar { + it.definition?.identifier == OBSIDIAN } - if (tickCounter < placeDelayTicks) { - tickCounter++ - return + placeList = computePlaceList() + + 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) } - tickCounter = 0 - if (localPlayer.inventory.heldItemSlot != obsidianSlot) { - switchToSlot(obsidianSlot) + 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++ + } } - var placed = 0 - val iterator = placeQueue.iterator() - while (iterator.hasNext() && placed < blocksPerTick) { - val target = iterator.next() - placeBlock(target) - iterator.remove() - placed++ + if (placeButton) { + val buttonSlot = localPlayer.inventory.searchForItemInHotbar { + it.definition?.identifier == BUTTON + } + if (buttonSlot != null) { + 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 fun computePlacements(playerPos: Vector3f): List { - val baseX = floor(playerPos.x).toInt() - val baseY = floor(playerPos.y).toInt() - if (placeBelowFeet) 1 else 0 - val baseZ = floor(playerPos.z).toInt() + /** [Surround.cpp]'s canPlaceBlock(): the target itself must be air (or, with airPlace, anything). */ + private fun canPlaceAt(pos: Vector3i): Boolean { + if (airPlace) return true + return session.level.getBlockAt(pos).identifier == "minecraft:air" + } + + private fun computePlaceList(): MutableList { + val localPlayer = session.localPlayer + val pos = localPlayer.vec3Position + val currentPos = Vector3i.from(floor(pos.x).toInt(), floor(pos.y + 0.5f).toInt(), floor(pos.z).toInt()) - var minX = -1 - var maxX = 1 - var minZ = -1 - var maxZ = 1 + var xStart = -1 + var zStart = -1 + var xEnd = 1 + var zEnd = 1 if (dynamic) { - val level = session.level - level.entityMap.values.forEach { entity -> - if (entity.distance(playerPos) > dynamicRadius) return@forEach - val dx = entity.posX - playerPos.x - val dz = entity.posZ - playerPos.z - if (dx > 1.2f) maxX = maxOf(maxX, 2) - if (dx < -1.2f) minX = minOf(minX, -2) - if (dz > 1.2f) maxZ = maxOf(maxZ, 2) - if (dz < -1.2f) minZ = minOf(minZ, -2) + 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 positions = mutableListOf() - for (x in minX..maxX) { - for (z in minZ..maxZ) { - if (x == 0 && z == 0) continue - // only the ring around the player, skip interior tiles when the box grew beyond 3x3 - if (x !in -1..1 && z !in -1..1) continue - positions.add(Vector3i.from(baseX + x, baseY, baseZ + z)) + 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) + } } } - return positions - } - private fun switchToSlot(slot: Int) { - val packet = PlayerHotbarPacket().apply { - selectedHotbarSlot = slot - containerId = 0 - setPacketField("selectHotbarSlot", true) - } - session.serverBound(packet) - session.clientBound(packet) + result.sortBy { it.distanceSq(currentPos) } + return result } - private fun restoreSlot() { - switchToSlot(oldSlot) + 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 placeBlock(pos: Vector3i) { + private fun place(identifier: String, pos: Vector3i, slot: Int) { val localPlayer = session.localPlayer - val runtimeId = session.blockMapping.getRuntimeIdByIdentifier(blockIdentifier) ?: return - val definitionToPlace = session.blockMapping.getDefinition(runtimeId) - // Find a real, currently non-air neighbor to click so the new block actually lands at - // `pos` - see Level.findPlacementReference for why. If nothing usable is found (target - // isn't air, chunk not loaded, or genuinely floating in open air), skip this position - // rather than fire a packet the server will just drop. - val (referencePos, face) = session.level.findPlacementReference(pos) ?: return + // Real Bedrock block placement clicks an existing SOLID block's face; the server computes + // the new block's position as (clickedBlockPos + faceNormal) itself. Sending blockPosition + // = pos (the empty air cell we want to fill) was wrong - there's nothing solid there to + // click, so the server had nothing valid to place against. Resolve a real solid neighbour + // to click instead. + val (refPos, face) = resolvePlacementReference(pos) ?: return + + val packet = InventoryTransactionPacket().apply { + transactionType = InventoryTransactionType.ITEM_USE + actionType = 0 + blockPosition = refPos + blockFace = face + hotbarSlot = slot + itemInHand = localPlayer.inventory.hand + playerPosition = localPlayer.vec3Position + clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) + blockDefinition = session.level.getBlockAt(refPos) + } + session.serverBound(packet) + } - localPlayer.placeBlock(pos, referencePos, face, definitionToPlace) + /** + * Finds a real, currently-solid block adjacent to [target] and the face of it that faces + * [target], so a placement packet can click that face like a real client would (see place()). + * Prefers straight down, since that's the most common/expected case for these two modules. + * Bedrock face indices: 0=down,1=up,2=north(-z),3=south(+z),4=west(-x),5=east(+x). + */ + private fun resolvePlacementReference(target: Vector3i): Pair? { + val candidates = listOf( + target.add(0, -1, 0) to 1, + target.add(0, 0, -1) to 3, + target.add(0, 0, 1) to 2, + target.add(-1, 0, 0) to 5, + target.add(1, 0, 0) to 4, + target.add(0, 1, 0) to 0 + ) + return candidates.firstOrNull { (pos, _) -> session.level.getBlockAt(pos).identifier != "minecraft:air" } + } + + private fun switchToSlot(slot: Int) { + val packet = PlayerHotbarPacket().apply { + selectedHotbarSlot = slot + containerId = 0 + isSelectHotbarSlot = true + } + session.clientBound(packet) } } From fd6313c09393dbbd94c9f264fd8351c7418b8ce6 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 01:27:11 +0900 Subject: [PATCH 53/82] Fix block placement for PistonCrystal and Surround --- .../wclient/game/entity/LocalPlayer.kt | 1 + .../game/module/combat/PistonCrystalModule.kt | 21 ++++++------------ .../game/module/combat/SurroundModule.kt | 22 +++++++------------ 3 files changed, 16 insertions(+), 28 deletions(-) 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 aba501ad..2d463041 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 @@ -5,6 +5,7 @@ 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 org.cloudburstmc.math.vector.Vector3f import org.cloudburstmc.math.vector.Vector3i import org.cloudburstmc.protocol.bedrock.data.AuthoritativeMovementMode 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 index e9c4d8c4..174c2dbf 100644 --- 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 @@ -10,8 +10,6 @@ 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 @@ -280,18 +278,10 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { // piston's push direction) when possible. val (refPos, face) = resolvePlacementReference(pos, preferredFace) ?: return true - val transaction = InventoryTransactionPacket().apply { - transactionType = InventoryTransactionType.ITEM_USE - actionType = 0 - blockPosition = refPos - blockFace = face - hotbarSlot = slot - itemInHand = localPlayer.inventory.hand - playerPosition = localPlayer.vec3Position - clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) - blockDefinition = session.level.getBlockAt(refPos) - } - session.serverBound(transaction) + // Reuse LocalPlayer.placeBlock so piston placement has the same + // server-authoritative inventory handling as ProtoHax. + val definition = localPlayer.inventory.hand.blockDefinition ?: return true + localPlayer.placeBlock(pos, refPos, face, definition) return true } @@ -338,6 +328,9 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { containerId = 0 isSelectHotbarSlot = true } + // The hotbar selection has to reach the server as well as our local + // inventory tracker. + session.serverBound(packet) session.clientBound(packet) } 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 index 3464a17c..43f20ce8 100644 --- 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 @@ -5,8 +5,6 @@ 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 @@ -200,18 +198,11 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { // to click instead. val (refPos, face) = resolvePlacementReference(pos) ?: return - val packet = InventoryTransactionPacket().apply { - transactionType = InventoryTransactionType.ITEM_USE - actionType = 0 - blockPosition = refPos - blockFace = face - hotbarSlot = slot - itemInHand = localPlayer.inventory.hand - playerPosition = localPlayer.vec3Position - clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) - blockDefinition = session.level.getBlockAt(refPos) - } - session.serverBound(packet) + // Use the shared placement implementation copied from ProtoHax. It supplies + // the definition of the block being placed and, when required, the + // server-authoritative inventory action instead of the clicked block definition. + val definition = localPlayer.inventory.hand.blockDefinition ?: return + localPlayer.placeBlock(pos, refPos, face, definition) } /** @@ -238,6 +229,9 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { containerId = 0 isSelectHotbarSlot = true } + // Keep both sides in sync: the server must receive the hotbar change, while + // the local inventory tracker also needs the client-bound packet. + session.serverBound(packet) session.clientBound(packet) } } From 4e450f72955225f1f3696c40a544ed07fc1e8bc4 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 01:41:29 +0900 Subject: [PATCH 54/82] Fix BlockDefinition type mismatch in PistonCrystal and Surround --- .../wclient/game/module/combat/PistonCrystalModule.kt | 2 +- .../retrivedmods/wclient/game/module/combat/SurroundModule.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 174c2dbf..706efc0c 100644 --- 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 @@ -280,7 +280,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { // Reuse LocalPlayer.placeBlock so piston placement has the same // server-authoritative inventory handling as ProtoHax. - val definition = localPlayer.inventory.hand.blockDefinition ?: return true + val definition = session.blockMapping.getDefinition(localPlayer.inventory.hand.blockDefinition?.runtimeId ?: return true) localPlayer.placeBlock(pos, refPos, face, definition) return true } 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 index 43f20ce8..409bc6f6 100644 --- 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 @@ -201,7 +201,7 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { // Use the shared placement implementation copied from ProtoHax. It supplies // the definition of the block being placed and, when required, the // server-authoritative inventory action instead of the clicked block definition. - val definition = localPlayer.inventory.hand.blockDefinition ?: return + val definition = session.blockMapping.getDefinition(localPlayer.inventory.hand.blockDefinition?.runtimeId ?: return) localPlayer.placeBlock(pos, refPos, face, definition) } From 62645685258af6bcefd4c2781b2889879c70f3c4 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 02:02:09 +0900 Subject: [PATCH 55/82] Align PistonCrystal and Surround placement with latest WClient --- PLACEMENT_FIX_NOTES.md | 12 ++++++++++++ .../retrivedmods/wclient/game/GameSession.kt | 17 +++++++++++++++++ .../wclient/game/entity/LocalPlayer.kt | 17 ++++++++++++----- .../game/module/combat/PistonCrystalModule.kt | 2 +- .../game/module/combat/SurroundModule.kt | 2 +- 5 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 PLACEMENT_FIX_NOTES.md 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/src/main/java/com/retrivedmods/wclient/game/GameSession.kt b/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt index 001a8080..97ae73e1 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt @@ -12,6 +12,8 @@ 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 +import org.cloudburstmc.protocol.bedrock.packet.InventoryTransactionPacket +import org.cloudburstmc.protocol.bedrock.data.inventory.transaction.InventoryTransactionType import org.cloudburstmc.protocol.bedrock.packet.ItemComponentPacket import org.cloudburstmc.protocol.bedrock.packet.StartGamePacket import org.cloudburstmc.protocol.bedrock.packet.TextPacket @@ -21,6 +23,10 @@ import org.cloudburstmc.protocol.common.SimpleDefinitionRegistry class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { val localPlayer = LocalPlayer(this) + + /** Last real outgoing block-use transaction, used as a packet template by placement modules. */ + @Volatile + var lastBlockPlacementPacket: InventoryTransactionPacket? = null val level = Level(this) val protocolVersion: Int @@ -71,6 +77,17 @@ class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { } private fun handlePacketBound(packet: BedrockPacket, isClientBound: Boolean): Boolean { + // The stock WClient decompiled from the latest build clones a real ITEM_USE + // transaction and only changes its target/item/position fields. Preserve that + // behavior so newer protocol fields (prediction/trigger/etc.) are not lost. + if (!isClientBound && packet is InventoryTransactionPacket && + packet.transactionType == InventoryTransactionType.ITEM_USE && + packet.actionType == 0 && + packet.blockPosition != null + ) { + lastBlockPlacementPacket = packet.clone() + } + when (packet) { is StartGamePacket -> { try { 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 2d463041..79c6a3e0 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,7 +4,7 @@ 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 org.cloudburstmc.protocol.bedrock.data.definitions.BlockDefinition import com.retrivedmods.wclient.game.utils.misc.removeNetInfo import org.cloudburstmc.math.vector.Vector3f import org.cloudburstmc.math.vector.Vector3i @@ -20,6 +20,7 @@ import org.cloudburstmc.protocol.bedrock.packet.ContainerClosePacket import org.cloudburstmc.protocol.bedrock.packet.ContainerOpenPacket import org.cloudburstmc.protocol.bedrock.packet.InventoryTransactionPacket import org.cloudburstmc.protocol.bedrock.packet.LevelSoundEventPacket +import org.cloudburstmc.protocol.bedrock.packet.UpdateBlockPacket import org.cloudburstmc.protocol.bedrock.packet.PlayerAuthInputPacket import org.cloudburstmc.protocol.bedrock.packet.StartGamePacket import java.util.UUID @@ -105,15 +106,16 @@ class LocalPlayer(val session: GameSession) : Player(0L, 0L, UUID.randomUUID(), * 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 { + // The latest stock WClient does not invent an InventoryTransactionPacket from + // scratch: its Scaffold module clones a real client placement transaction and + // changes only the placement-specific fields. Reuse the same approach here. + val packet = (session.lastBlockPlacementPacket?.clone() ?: InventoryTransactionPacket()).apply { transactionType = InventoryTransactionType.ITEM_USE actionType = 0 blockPosition = referencePos blockFace = face hotbarSlot = inventory.heldItemSlot - itemInHand = inventory.hand + itemInHand = inventory.hand.removeNetInfo() playerPosition = vec3Position clickPosition = Vector3f.from( Math.random().toFloat(), @@ -121,6 +123,7 @@ class LocalPlayer(val session: GameSession) : Player(0L, 0L, UUID.randomUUID(), Math.random().toFloat() ) blockDefinition = definition + actions.clear() if (inventoriesServerAuthoritative) { val current = itemInHand @@ -140,6 +143,10 @@ class LocalPlayer(val session: GameSession) : Player(0L, 0L, UUID.randomUUID(), } } + // Keep local world state in sync immediately, as the stock/ProtoHax placement + // implementation does. The actual server-bound packet remains authoritative. + session.level.setBlockIdAt(target.x, target.y, target.z, definition.runtimeId) + session.serverBound(packet) } 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 index 706efc0c..174c2dbf 100644 --- 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 @@ -280,7 +280,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { // Reuse LocalPlayer.placeBlock so piston placement has the same // server-authoritative inventory handling as ProtoHax. - val definition = session.blockMapping.getDefinition(localPlayer.inventory.hand.blockDefinition?.runtimeId ?: return true) + val definition = localPlayer.inventory.hand.blockDefinition ?: return true localPlayer.placeBlock(pos, refPos, face, definition) return true } 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 index 409bc6f6..43f20ce8 100644 --- 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 @@ -201,7 +201,7 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { // Use the shared placement implementation copied from ProtoHax. It supplies // the definition of the block being placed and, when required, the // server-authoritative inventory action instead of the clicked block definition. - val definition = session.blockMapping.getDefinition(localPlayer.inventory.hand.blockDefinition?.runtimeId ?: return) + val definition = localPlayer.inventory.hand.blockDefinition ?: return localPlayer.placeBlock(pos, refPos, face, definition) } From 174f6912e3dc83a57231eb37f86ed9be013ebd29 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 02:19:03 +0900 Subject: [PATCH 56/82] Use real ITEM_USE transaction template for Surround and PistonCrystal --- .../retrivedmods/wclient/game/GameSession.kt | 28 +++++++------- .../wclient/game/entity/LocalPlayer.kt | 18 +++------ .../game/module/combat/PistonCrystalModule.kt | 28 ++++++++++---- .../game/module/combat/SurroundModule.kt | 37 ++++++++++++------- 4 files changed, 64 insertions(+), 47 deletions(-) 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 97ae73e1..8d31c43c 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt @@ -12,9 +12,9 @@ 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 +import org.cloudburstmc.protocol.bedrock.packet.ItemComponentPacket import org.cloudburstmc.protocol.bedrock.packet.InventoryTransactionPacket import org.cloudburstmc.protocol.bedrock.data.inventory.transaction.InventoryTransactionType -import org.cloudburstmc.protocol.bedrock.packet.ItemComponentPacket import org.cloudburstmc.protocol.bedrock.packet.StartGamePacket import org.cloudburstmc.protocol.bedrock.packet.TextPacket import org.cloudburstmc.protocol.common.SimpleDefinitionRegistry @@ -23,10 +23,6 @@ import org.cloudburstmc.protocol.common.SimpleDefinitionRegistry class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { val localPlayer = LocalPlayer(this) - - /** Last real outgoing block-use transaction, used as a packet template by placement modules. */ - @Volatile - var lastBlockPlacementPacket: InventoryTransactionPacket? = null val level = Level(this) val protocolVersion: Int @@ -53,6 +49,14 @@ class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { wRelaySession.clientBound(packet) } + /** + * A real ITEM_USE packet captured from the vanilla client. The stock latest WClient + * Scaffold clones this packet instead of constructing placement transactions from + * scratch, preserving protocol fields such as trigger/prediction and legacy actions. + */ + @Volatile + var lastPlacementTransaction: InventoryTransactionPacket? = null + fun serverBound(packet: BedrockPacket) { wRelaySession.serverBound(packet) } @@ -73,21 +77,17 @@ class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { } override fun beforeServerBound(packet: BedrockPacket): Boolean { - return handlePacketBound(packet, isClientBound = false) - } - - private fun handlePacketBound(packet: BedrockPacket, isClientBound: Boolean): Boolean { - // The stock WClient decompiled from the latest build clones a real ITEM_USE - // transaction and only changes its target/item/position fields. Preserve that - // behavior so newer protocol fields (prediction/trigger/etc.) are not lost. - if (!isClientBound && packet is InventoryTransactionPacket && + if (packet is InventoryTransactionPacket && packet.transactionType == InventoryTransactionType.ITEM_USE && packet.actionType == 0 && packet.blockPosition != null ) { - lastBlockPlacementPacket = packet.clone() + lastPlacementTransaction = packet.clone() } + return handlePacketBound(packet, isClientBound = false) + } + private fun handlePacketBound(packet: BedrockPacket, isClientBound: Boolean): Boolean { when (packet) { is StartGamePacket -> { try { 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 79c6a3e0..aba501ad 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,8 +4,7 @@ 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 org.cloudburstmc.protocol.bedrock.data.definitions.BlockDefinition -import com.retrivedmods.wclient.game.utils.misc.removeNetInfo +import com.retrivedmods.wclient.game.registry.BlockDefinition import org.cloudburstmc.math.vector.Vector3f import org.cloudburstmc.math.vector.Vector3i import org.cloudburstmc.protocol.bedrock.data.AuthoritativeMovementMode @@ -20,7 +19,6 @@ import org.cloudburstmc.protocol.bedrock.packet.ContainerClosePacket import org.cloudburstmc.protocol.bedrock.packet.ContainerOpenPacket import org.cloudburstmc.protocol.bedrock.packet.InventoryTransactionPacket import org.cloudburstmc.protocol.bedrock.packet.LevelSoundEventPacket -import org.cloudburstmc.protocol.bedrock.packet.UpdateBlockPacket import org.cloudburstmc.protocol.bedrock.packet.PlayerAuthInputPacket import org.cloudburstmc.protocol.bedrock.packet.StartGamePacket import java.util.UUID @@ -106,16 +104,15 @@ class LocalPlayer(val session: GameSession) : Player(0L, 0L, UUID.randomUUID(), * drop the whole transaction. */ fun placeBlock(target: Vector3i, referencePos: Vector3i, face: Int, definition: BlockDefinition) { - // The latest stock WClient does not invent an InventoryTransactionPacket from - // scratch: its Scaffold module clones a real client placement transaction and - // changes only the placement-specific fields. Reuse the same approach here. - val packet = (session.lastBlockPlacementPacket?.clone() ?: InventoryTransactionPacket()).apply { + 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.removeNetInfo() + itemInHand = inventory.hand playerPosition = vec3Position clickPosition = Vector3f.from( Math.random().toFloat(), @@ -123,7 +120,6 @@ class LocalPlayer(val session: GameSession) : Player(0L, 0L, UUID.randomUUID(), Math.random().toFloat() ) blockDefinition = definition - actions.clear() if (inventoriesServerAuthoritative) { val current = itemInHand @@ -143,10 +139,6 @@ class LocalPlayer(val session: GameSession) : Player(0L, 0L, UUID.randomUUID(), } } - // Keep local world state in sync immediately, as the stock/ProtoHax placement - // implementation does. The actual server-bound packet remains authoritative. - session.level.setBlockIdAt(target.x, target.y, target.z, definition.runtimeId) - session.serverBound(packet) } 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 index 174c2dbf..a8a3adf2 100644 --- 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 @@ -10,6 +10,8 @@ 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 @@ -278,10 +280,25 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { // piston's push direction) when possible. val (refPos, face) = resolvePlacementReference(pos, preferredFace) ?: return true - // Reuse LocalPlayer.placeBlock so piston placement has the same - // server-authoritative inventory handling as ProtoHax. - val definition = localPlayer.inventory.hand.blockDefinition ?: return true - localPlayer.placeBlock(pos, refPos, face, definition) + // Match latest WClient Scaffold: clone a real ITEM_USE transaction so protocol fields + // such as trigger/prediction/legacy actions are preserved. Only placement-specific fields + // are changed here. + val transaction = (session.lastPlacementTransaction?.clone() ?: InventoryTransactionPacket()).apply { + transactionType = InventoryTransactionType.ITEM_USE + actionType = 0 + blockPosition = refPos + blockFace = face + hotbarSlot = slot + itemInHand = localPlayer.inventory.hand + playerPosition = localPlayer.vec3Position + headPosition = Vector3f.from(localPlayer.vec3Position.x, localPlayer.vec3Position.y + 1.62f, localPlayer.vec3Position.z) + clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) + // Keep blockDefinition from the real clicked transaction when a template exists. + if (session.lastPlacementTransaction == null) { + blockDefinition = session.level.getBlockAt(refPos) + } + } + session.serverBound(transaction) return true } @@ -328,9 +345,6 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { containerId = 0 isSelectHotbarSlot = true } - // The hotbar selection has to reach the server as well as our local - // inventory tracker. - session.serverBound(packet) session.clientBound(packet) } 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 index 43f20ce8..a07991ff 100644 --- 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 @@ -5,6 +5,8 @@ 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 @@ -98,8 +100,7 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { val iterator = placeList.iterator() while (iterator.hasNext() && placed < blocksPerTick) { val pos = iterator.next() - place(OBSIDIAN, pos, obsidianSlot) - placed++ + if (place(OBSIDIAN, pos, obsidianSlot)) placed++ } } else { tickCounter++ @@ -188,7 +189,7 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { return dx * dx + dy * dy + dz * dz } - private fun place(identifier: String, pos: Vector3i, slot: Int) { + private fun place(identifier: String, pos: Vector3i, slot: Int): Boolean { val localPlayer = session.localPlayer // Real Bedrock block placement clicks an existing SOLID block's face; the server computes @@ -196,13 +197,26 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { // = pos (the empty air cell we want to fill) was wrong - there's nothing solid there to // click, so the server had nothing valid to place against. Resolve a real solid neighbour // to click instead. - val (refPos, face) = resolvePlacementReference(pos) ?: return - - // Use the shared placement implementation copied from ProtoHax. It supplies - // the definition of the block being placed and, when required, the - // server-authoritative inventory action instead of the clicked block definition. - val definition = localPlayer.inventory.hand.blockDefinition ?: return - localPlayer.placeBlock(pos, refPos, face, definition) + val (refPos, face) = resolvePlacementReference(pos) ?: return false + + // Latest WClient Scaffold clones a real client ITEM_USE packet. This is important: + // newer Bedrock versions carry prediction/trigger/legacy-action fields that are easy + // to get wrong when constructing InventoryTransactionPacket from scratch. + val packet = (session.lastPlacementTransaction?.clone() ?: InventoryTransactionPacket()).apply { + transactionType = InventoryTransactionType.ITEM_USE + actionType = 0 + blockPosition = refPos + blockFace = face + hotbarSlot = slot + itemInHand = localPlayer.inventory.hand + playerPosition = localPlayer.vec3Position + headPosition = Vector3f.from(localPlayer.vec3Position.x, localPlayer.vec3Position.y + 1.62f, localPlayer.vec3Position.z) + clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) + // Do NOT overwrite blockDefinition: Scaffold keeps the definition from the real + // clicked-block transaction when cloning it. + } + session.serverBound(packet) + return true } /** @@ -229,9 +243,6 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { containerId = 0 isSelectHotbarSlot = true } - // Keep both sides in sync: the server must receive the hotbar change, while - // the local inventory tracker also needs the client-bound packet. - session.serverBound(packet) session.clientBound(packet) } } From 997f582c5b5c1ffe3ce0be9c95f5f360f50ffc9c Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 09:36:13 +0900 Subject: [PATCH 57/82] Add files via upload --- .../java/com/retrivedmods/wclient/game/entity/LocalPlayer.kt | 2 ++ 1 file changed, 2 insertions(+) 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 aba501ad..38a67dee 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 @@ -5,6 +5,7 @@ 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 org.cloudburstmc.math.vector.Vector3f import org.cloudburstmc.math.vector.Vector3i import org.cloudburstmc.protocol.bedrock.data.AuthoritativeMovementMode @@ -114,6 +115,7 @@ class LocalPlayer(val session: GameSession) : Player(0L, 0L, UUID.randomUUID(), hotbarSlot = inventory.heldItemSlot itemInHand = inventory.hand playerPosition = vec3Position + headPosition = Vector3f.from(vec3Position.x, vec3Position.y + 1.62f, vec3Position.z) clickPosition = Vector3f.from( Math.random().toFloat(), Math.random().toFloat(), From 7ba157b68b53ff890799f673491a1fc824023e21 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 09:48:29 +0900 Subject: [PATCH 58/82] Add files via upload --- .../retrivedmods/wclient/game/world/Level.kt | 59 ++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) 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 0537dbce..14d17dfb 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 @@ -18,6 +18,7 @@ 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.math.vector.Vector3f @@ -77,19 +78,25 @@ class Level(val session: GameSession) { return } - if (packet.isCachingEnabled || packet.isRequestSubChunks) { - // blob-cache / newer subchunk-request chunk loading isn't supported, see note above + if (packet.isCachingEnabled) { + // blob-cache chunk loading isn't supported, see note above return } val chunk = Chunk(packet.chunkX, packet.chunkZ, is384WorldSupported, session.blockMapping) try { - // 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) + 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: when + // isRequestSubChunks is true, LevelChunkPacket only carries biome/border data + // and the actual block data streams in afterwards via SubChunkPacket, 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 @@ -97,6 +104,42 @@ class Level(val session: GameSession) { } } + 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( From 39f421f16bd000c9fd8199c731d381de0bccefb9 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 10:02:23 +0900 Subject: [PATCH 59/82] Add files via upload --- .../game/module/misc/PacketLoggerModule.kt | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 app/src/main/java/com/retrivedmods/wclient/game/module/misc/PacketLoggerModule.kt 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..d3398e92 --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/module/misc/PacketLoggerModule.kt @@ -0,0 +1,64 @@ +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.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 real ITEM_USE (block place) InventoryTransactionPacket + * to chat as it passes through the relay - including ones the real Minecraft client sends when + * you manually place a block by hand. Enable this, place one block yourself, and compare the + * logged fields against what PistonCrystalModule/SurroundModule construct, field by field, to + * find whatever's still wrong/missing. + * + * Doesn't see our own modules' auto-placed packets - those go out via session.serverBound(...) + * directly, bypassing the intercept pipeline this module listens on, same as every other module + * here (see e.g. PositionLoggerModule for the same TextPacket-based logging pattern). + */ +class PacketLoggerModule : Module("packet_logger", ModuleCategory.Misc) { + + private var logPlacements by boolValue("log_placements", true) + + 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) + } +} From 15a006e89e762afa33c577db5082aa26bc28094d Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 10:03:19 +0900 Subject: [PATCH 60/82] Add files via upload --- .../main/java/com/retrivedmods/wclient/game/ModuleManager.kt | 2 ++ 1 file changed, 2 insertions(+) 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 9b709fde..c6da3209 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/ModuleManager.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/ModuleManager.kt @@ -32,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 @@ -156,6 +157,7 @@ object ModuleManager { add(SpammerModule()) add(WaterMarkModule()) add(PositionLoggerModule()) + add(PacketLoggerModule()) add(NoChatModule()) add(CommandHandlerModule()) add(ReplayModule()) From 06137c12ecbf45077a379b280df3e6fb46d4b454 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 10:24:20 +0900 Subject: [PATCH 61/82] Use server's own StartGamePacket block palette instead of outdated bundled tables; fix blockDefinition/blockPosition in PistonCrystal and Surround --- .../wclient/game/BlockPlacementUtils.kt | 69 ++++++++++++++ .../retrivedmods/wclient/game/GameSession.kt | 69 ++++++++++---- .../game/module/combat/PistonCrystalModule.kt | 89 ++++++++----------- .../game/module/combat/SurroundModule.kt | 47 +++------- .../wclient/game/registry/BlockMapping.kt | 18 ++++ 5 files changed, 185 insertions(+), 107 deletions(-) create mode 100644 app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt 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..cd1cd0d3 --- /dev/null +++ b/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt @@ -0,0 +1,69 @@ +package com.retrivedmods.wclient.game + +import org.cloudburstmc.math.vector.Vector3i +import org.cloudburstmc.protocol.bedrock.data.definitions.BlockDefinition + +/** + * Shared helpers for modules that place blocks via InventoryTransactionPacket (PistonCrystalModule, + * SurroundModule). Ported by comparing against ProtoHax's EntityLocalPlayer.placeBlock()/useItem(), + * which turned up two things our ported modules were missing - most likely why every placement was + * being silently ignored/rejected by the server: + * - InventoryTransactionPacket.blockDefinition (which block actually gets placed) was never set + * - 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? { + for ((normal, face) in FACES) { + val neighbor = pos.add(-normal.x, -normal.y, -normal.z) + if (session.level.getBlockAt(neighbor).identifier != "minecraft:air") { + return neighbor to face + } + } + return null + } + + /** + * Runtime block definition for [identifier] (e.g. "minecraft:piston"), for use as + * InventoryTransactionPacket.blockDefinition. Returns null for non-block items (like + * "minecraft:end_crystal") - those items aren't in the block mapping at all, and the packet's + * blockDefinition is expected to stay unset for them. + */ + 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) + } + + /** + * 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 8d31c43c..70aea93a 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt @@ -13,8 +13,6 @@ import com.retrivedmods.wrelay.WRelaySession import org.cloudburstmc.protocol.bedrock.data.definitions.ItemDefinition import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket import org.cloudburstmc.protocol.bedrock.packet.ItemComponentPacket -import org.cloudburstmc.protocol.bedrock.packet.InventoryTransactionPacket -import org.cloudburstmc.protocol.bedrock.data.inventory.transaction.InventoryTransactionType import org.cloudburstmc.protocol.bedrock.packet.StartGamePacket import org.cloudburstmc.protocol.bedrock.packet.TextPacket import org.cloudburstmc.protocol.common.SimpleDefinitionRegistry @@ -49,14 +47,6 @@ class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { wRelaySession.clientBound(packet) } - /** - * A real ITEM_USE packet captured from the vanilla client. The stock latest WClient - * Scaffold clones this packet instead of constructing placement transactions from - * scratch, preserving protocol fields such as trigger/prediction and legacy actions. - */ - @Volatile - var lastPlacementTransaction: InventoryTransactionPacket? = null - fun serverBound(packet: BedrockPacket) { wRelaySession.serverBound(packet) } @@ -77,13 +67,6 @@ class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { } override fun beforeServerBound(packet: BedrockPacket): Boolean { - if (packet is InventoryTransactionPacket && - packet.transactionType == InventoryTransactionType.ITEM_USE && - packet.actionType == 0 && - packet.blockPosition != null - ) { - lastPlacementTransaction = packet.clone() - } return handlePacketBound(packet, isClientBound = false) } @@ -107,8 +90,31 @@ 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", "Could not find a block palette on StartGamePacket (tried: getBlockPalette/blockPalette/getBlockProperties/blockProperties) - 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") @@ -180,4 +186,31 @@ class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { clientBound(textPacket) } + /** + * 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. + */ + private fun extractBlockPaletteFromStartGame(packet: StartGamePacket): List? { + val candidates = listOf("getBlockPalette", "blockPalette", "getBlockProperties", "blockProperties") + for (name in candidates) { + try { + val method = packet.javaClass.getMethod(name) + val result = method.invoke(packet) + @Suppress("UNCHECKED_CAST") + val list = result as? List ?: continue + if (list.isNotEmpty()) return list + } catch (e: NoSuchMethodException) { + // try the next candidate name + } catch (e: Exception) { + Log.w("GameSession", "Found StartGamePacket.$name() but couldn't read it as a block palette", e) + } + } + return null + } + } 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 index a8a3adf2..2948bc23 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -59,7 +60,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { private data class Placement(val crystalPos: Vector3i, val pistonPos: Vector3i, val redstonePos: Vector3i, val dir: Vector3i) - private enum class Step { CRYSTAL, PISTON, REDSTONE, DONE } + private enum class Step { PISTON, CRYSTAL, REDSTONE, DONE } private var step = Step.DONE private var placement: Placement? = null @@ -104,7 +105,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { val found = findValidPlacement(target) if (found != null) { placement = found - step = Step.CRYSTAL + step = if (usePiston) Step.PISTON else Step.CRYSTAL tickCounter = 0 } } @@ -218,21 +219,30 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { } // --- 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.CRYSTAL -> { - if (place(CRYSTAL_ITEM, config.crystalPos, packet, lookTowards = config.crystalPos)) { - step = if (usePiston) Step.PISTON else Step.DONE - if (!usePiston) resetState() + 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.PISTON -> { - if (place(PISTON, config.pistonPos, packet, lookTowards = config.crystalPos, preferredFace = faceForDirection(Vector3i.from(-config.dir.x, 0, -config.dir.z)))) { - step = Step.REDSTONE + Step.CRYSTAL -> { + if (place(CRYSTAL_ITEM, config.crystalPos, packet, lookTowards = config.crystalPos)) { + step = if (usePiston) Step.REDSTONE else Step.DONE + if (!usePiston) resetState() } } @@ -251,7 +261,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { pos: Vector3i, authInput: PlayerAuthInputPacket, lookTowards: Vector3i?, - preferredFace: Int? = null + pushDir: Vector3i? = null ): Boolean { val localPlayer = session.localPlayer val slot = localPlayer.inventory.searchForItemInHotbar { @@ -273,61 +283,34 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { authInput.rotation = Vector3f.from(0f, yaw, yaw) } - // Real Bedrock block placement clicks an existing SOLID block's face; the server computes - // the new block's position as (clickedBlockPos + faceNormal). Sending blockPosition = pos - // (the empty cell we want to fill) was wrong - resolve a real solid neighbour to click - // instead, preferring one that gives the orientation we actually want (e.g. for the - // piston's push direction) when possible. - val (refPos, face) = resolvePlacementReference(pos, preferredFace) ?: return true - - // Match latest WClient Scaffold: clone a real ITEM_USE transaction so protocol fields - // such as trigger/prediction/legacy actions are preserved. Only placement-specific fields - // are changed here. - val transaction = (session.lastPlacementTransaction?.clone() ?: InventoryTransactionPacket()).apply { + // The crystal item itself isn't a block (it spawns an entity), so it has no + // blockDefinition - but 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 + } + val blockDefinition = BlockPlacementUtils.blockDefinitionFor(session, identifier) + + val transaction = InventoryTransactionPacket().apply { transactionType = InventoryTransactionType.ITEM_USE actionType = 0 blockPosition = refPos - blockFace = face + blockFace = refFace hotbarSlot = slot itemInHand = localPlayer.inventory.hand playerPosition = localPlayer.vec3Position - headPosition = Vector3f.from(localPlayer.vec3Position.x, localPlayer.vec3Position.y + 1.62f, localPlayer.vec3Position.z) clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) - // Keep blockDefinition from the real clicked transaction when a template exists. - if (session.lastPlacementTransaction == null) { - blockDefinition = session.level.getBlockAt(refPos) - } + this.blockDefinition = blockDefinition } session.serverBound(transaction) + BlockPlacementUtils.predictLocalBlockChange(session, pos, blockDefinition) return true } - /** - * Finds a real, currently-solid block adjacent to [target] and the face of it that faces - * [target], so a placement packet can click that face like a real client would (see place()). - * If [preferredFace] is given (e.g. to aim the piston in a specific push direction) and that - * particular neighbour happens to be solid, it's used first; otherwise falls back to any - * solid neighbour, preferring straight down. - * Bedrock face indices: 0=down,1=up,2=north(-z),3=south(+z),4=west(-x),5=east(+x). - */ - private fun resolvePlacementReference(target: Vector3i, preferredFace: Int? = null): Pair? { - val candidates = listOf( - target.add(0, -1, 0) to 1, - target.add(0, 0, -1) to 3, - target.add(0, 0, 1) to 2, - target.add(-1, 0, 0) to 5, - target.add(1, 0, 0) to 4, - target.add(0, 1, 0) to 0 - ) - if (preferredFace != null) { - val preferred = candidates.firstOrNull { it.second == preferredFace } - if (preferred != null && session.level.getBlockAt(preferred.first).identifier != "minecraft:air") { - return preferred - } - } - return candidates.firstOrNull { (pos, _) -> session.level.getBlockAt(pos).identifier != "minecraft:air" } - } - /** Bedrock block face indices: 0=down,1=up,2=north,3=south,4=west,5=east */ private fun faceForDirection(dir: Vector3i): Int { return when { 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 index a07991ff..a3b0395a 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -100,7 +101,8 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { val iterator = placeList.iterator() while (iterator.hasNext() && placed < blocksPerTick) { val pos = iterator.next() - if (place(OBSIDIAN, pos, obsidianSlot)) placed++ + place(OBSIDIAN, pos, obsidianSlot) + placed++ } } else { tickCounter++ @@ -189,52 +191,25 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { return dx * dx + dy * dy + dz * dz } - private fun place(identifier: String, pos: Vector3i, slot: Int): Boolean { + 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 blockDefinition = BlockPlacementUtils.blockDefinitionFor(session, identifier) - // Real Bedrock block placement clicks an existing SOLID block's face; the server computes - // the new block's position as (clickedBlockPos + faceNormal) itself. Sending blockPosition - // = pos (the empty air cell we want to fill) was wrong - there's nothing solid there to - // click, so the server had nothing valid to place against. Resolve a real solid neighbour - // to click instead. - val (refPos, face) = resolvePlacementReference(pos) ?: return false - - // Latest WClient Scaffold clones a real client ITEM_USE packet. This is important: - // newer Bedrock versions carry prediction/trigger/legacy-action fields that are easy - // to get wrong when constructing InventoryTransactionPacket from scratch. - val packet = (session.lastPlacementTransaction?.clone() ?: InventoryTransactionPacket()).apply { + val packet = InventoryTransactionPacket().apply { transactionType = InventoryTransactionType.ITEM_USE actionType = 0 blockPosition = refPos - blockFace = face + blockFace = refFace hotbarSlot = slot itemInHand = localPlayer.inventory.hand playerPosition = localPlayer.vec3Position - headPosition = Vector3f.from(localPlayer.vec3Position.x, localPlayer.vec3Position.y + 1.62f, localPlayer.vec3Position.z) clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) - // Do NOT overwrite blockDefinition: Scaffold keeps the definition from the real - // clicked-block transaction when cloning it. + this.blockDefinition = blockDefinition } session.serverBound(packet) - return true - } - - /** - * Finds a real, currently-solid block adjacent to [target] and the face of it that faces - * [target], so a placement packet can click that face like a real client would (see place()). - * Prefers straight down, since that's the most common/expected case for these two modules. - * Bedrock face indices: 0=down,1=up,2=north(-z),3=south(+z),4=west(-x),5=east(+x). - */ - private fun resolvePlacementReference(target: Vector3i): Pair? { - val candidates = listOf( - target.add(0, -1, 0) to 1, - target.add(0, 0, -1) to 3, - target.add(0, 0, 1) to 2, - target.add(-1, 0, 0) to 5, - target.add(1, 0, 0) to 4, - target.add(0, 1, 0) to 0 - ) - return candidates.firstOrNull { (pos, _) -> session.level.getBlockAt(pos).identifier != "minecraft:air" } + BlockPlacementUtils.predictLocalBlockChange(session, pos, blockDefinition) } private fun switchToSlot(slot: Int) { 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 cd93fefa..4777a234 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 @@ -42,6 +42,24 @@ class BlockMapping( 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. Each entry's position in [palette] is its + * runtime id (the modern Bedrock convention, since explicit per-entry runtime ids were + * removed from the palette format) - unless the entry itself still carries an explicit + * "runtimeId" tag (older-style palette), which takes priority when present. + */ + fun fromPalette(palette: List): BlockMapping { + val runtimeToBlock = mutableMapOf() + palette.forEachIndexed { index, entry -> + val name = entry.getString("name", null) ?: return@forEachIndexed + val explicitRuntimeId = entry.getInt("runtimeId", Int.MIN_VALUE) + val runtime = if (explicitRuntimeId != Int.MIN_VALUE) explicitRuntimeId else index + runtimeToBlock[runtime] = BlockDefinition(runtime, name) + } + return BlockMapping(runtimeToBlock) + } + fun read(context: Context, version: Short): BlockMapping { val path = "mcpedata/blocks/runtime_block_states_$version.dat" context.assets.open(path).use { stream -> From f8ccc2e57c5214def86f3a7ab4ac71f3ed77e5a4 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 16:07:50 +0900 Subject: [PATCH 62/82] Add files via upload --- .../wclient/game/entity/LocalPlayer.kt | 72 +++++++++++++------ 1 file changed, 49 insertions(+), 23 deletions(-) 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 38a67dee..69484f15 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 @@ -115,35 +115,61 @@ class LocalPlayer(val session: GameSession) : Player(0L, 0L, UUID.randomUUID(), hotbarSlot = inventory.heldItemSlot itemInHand = inventory.hand playerPosition = vec3Position - headPosition = Vector3f.from(vec3Position.x, vec3Position.y + 1.62f, vec3Position.z) - clickPosition = Vector3f.from( - Math.random().toFloat(), - Math.random().toFloat(), - Math.random().toFloat() - ) - blockDefinition = definition - - if (inventoriesServerAuthoritative) { - 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 - ) - ) + // 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) } + /** + * 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 From 74e14cf646756dc27edca984012b831dc217d692 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 17:34:41 +0900 Subject: [PATCH 63/82] Add files via upload --- .../game/module/misc/PacketLoggerModule.kt | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) 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 index d3398e92..1f7194f1 100644 --- 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 @@ -3,26 +3,37 @@ 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 real ITEM_USE (block place) InventoryTransactionPacket - * to chat as it passes through the relay - including ones the real Minecraft client sends when - * you manually place a block by hand. Enable this, place one block yourself, and compare the - * logged fields against what PistonCrystalModule/SurroundModule construct, field by field, to - * find whatever's still wrong/missing. - * - * Doesn't see our own modules' auto-placed packets - those go out via session.serverBound(...) - * directly, bypassing the intercept pipeline this module listens on, same as every other module - * here (see e.g. PositionLoggerModule for the same TextPacket-based logging pattern). + * 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 From 8ba22ceb608ab0f93aa428ee2ad49baeda74b332 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 17:35:39 +0900 Subject: [PATCH 64/82] Add files via upload --- .../wclient/util/PacketDebugLog.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 app/src/main/java/com/retrivedmods/wclient/util/PacketDebugLog.kt 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) + } +} From b644c1781a6d52bc1bb1604da76249db324cb68c Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 17:35:56 +0900 Subject: [PATCH 65/82] Add files via upload --- .../wclient/game/entity/LocalPlayer.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 69484f15..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 @@ -6,6 +6,7 @@ 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 @@ -146,6 +147,21 @@ class LocalPlayer(val session: GameSession) : Player(0L, 0L, UUID.randomUUID(), } 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}") + } + ) } /** From eccaa0ca88edf4c49972edfb963505fd48c44648 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 18:20:16 +0900 Subject: [PATCH 66/82] Add files via upload --- .../wclient/game/BlockPlacementUtils.kt | 57 ++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt b/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt index cd1cd0d3..4545b1c5 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt @@ -2,13 +2,24 @@ package com.retrivedmods.wclient.game 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 /** * Shared helpers for modules that place blocks via InventoryTransactionPacket (PistonCrystalModule, - * SurroundModule). Ported by comparing against ProtoHax's EntityLocalPlayer.placeBlock()/useItem(), - * which turned up two things our ported modules were missing - most likely why every placement was - * being silently ignored/rejected by the server: - * - InventoryTransactionPacket.blockDefinition (which block actually gets placed) was never set + * 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 @@ -43,10 +54,19 @@ object BlockPlacementUtils { } /** - * Runtime block definition for [identifier] (e.g. "minecraft:piston"), for use as - * InventoryTransactionPacket.blockDefinition. Returns null for non-block items (like - * "minecraft:end_crystal") - those items aren't in the block mapping at all, and the packet's - * blockDefinition is expected to stay unset for them. + * 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 @@ -54,6 +74,27 @@ object BlockPlacementUtils { 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 + ) + } + /** * 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 -> From 499ee5ca50db436878a33d5ca010e27889d0d5d2 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 18:31:36 +0900 Subject: [PATCH 67/82] Add files via upload --- .../game/module/combat/PistonCrystalModule.kt | 49 ++++++++++++++++--- .../game/module/combat/SurroundModule.kt | 37 +++++++++++--- 2 files changed, 72 insertions(+), 14 deletions(-) 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 index 2948bc23..5c41097c 100644 --- 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 @@ -87,6 +87,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { tickCounter = 0 oldSlot = -1 lastCrystalAttack.clear() + lastWarnedMessage = null } override fun beforePacketBound(interceptablePacket: InterceptablePacket) { @@ -266,7 +267,11 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { val localPlayer = session.localPlayer val slot = localPlayer.inventory.searchForItemInHotbar { it.definition?.identifier == identifier - } ?: return true // don't get stuck retrying forever if we're out of the item + } + 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 @@ -283,8 +288,9 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { authInput.rotation = Vector3f.from(0f, yaw, yaw) } - // The crystal item itself isn't a block (it spawns an entity), so it has no - // blockDefinition - but for the crystal specifically we already know pos.add(0,-1,0) is a + // 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) { @@ -293,7 +299,10 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { BlockPlacementUtils.findReferenceBlock(session, pos) ?: return false // no solid neighbor to click yet - retry next tick } - val blockDefinition = BlockPlacementUtils.blockDefinitionFor(session, identifier) + // 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 @@ -301,13 +310,17 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { blockPosition = refPos blockFace = refFace hotbarSlot = slot - itemInHand = localPlayer.inventory.hand + itemInHand = heldItem playerPosition = localPlayer.vec3Position clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) - this.blockDefinition = blockDefinition + // 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)) } session.serverBound(transaction) - BlockPlacementUtils.predictLocalBlockChange(session, pos, blockDefinition) + BlockPlacementUtils.predictLocalBlockChange(session, pos, placedDefinition) return true } @@ -328,7 +341,27 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { containerId = 0 isSelectHotbarSlot = true } - session.clientBound(packet) + // 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) + } + + 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 ---------------------------------------------------------- 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 index a3b0395a..5efdaa57 100644 --- 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 @@ -46,6 +46,7 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { tickCounter = 0 oldSlot = -1 hasCentered = false + lastWarnedMessage = null } override fun onDisabled() { @@ -75,6 +76,10 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { it.definition?.identifier == OBSIDIAN } + if (obsidianSlot == null) { + warnMissingItem("§c黒曜石を持っていません!") + } + placeList = computePlaceList() if (fakeRotation && placeList.isNotEmpty()) { @@ -113,7 +118,9 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { val buttonSlot = localPlayer.inventory.searchForItemInHotbar { it.definition?.identifier == BUTTON } - if (buttonSlot != null) { + if (buttonSlot == null) { + warnMissingItem("§cボタン(stone_button)を持っていません!") + } else { val buttonPos = Vector3i.from( floor(localPlayer.vec3Position.x).toInt(), floor(localPlayer.vec3Position.y).toInt() - 1, @@ -124,6 +131,15 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { } } + 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 @@ -195,7 +211,8 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { 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 blockDefinition = BlockPlacementUtils.blockDefinitionFor(session, identifier) + val placedDefinition = BlockPlacementUtils.blockDefinitionFor(session, identifier) + val heldItem = localPlayer.inventory.hand val packet = InventoryTransactionPacket().apply { transactionType = InventoryTransactionType.ITEM_USE @@ -203,13 +220,17 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { blockPosition = refPos blockFace = refFace hotbarSlot = slot - itemInHand = localPlayer.inventory.hand + itemInHand = heldItem playerPosition = localPlayer.vec3Position clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) - this.blockDefinition = blockDefinition + // 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) - BlockPlacementUtils.predictLocalBlockChange(session, pos, blockDefinition) + BlockPlacementUtils.predictLocalBlockChange(session, pos, placedDefinition) } private fun switchToSlot(slot: Int) { @@ -218,6 +239,10 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { containerId = 0 isSelectHotbarSlot = true } - session.clientBound(packet) + // 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) } } From 3398f6606908eafccf82ca80ac417b17fb14affe Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 18:57:49 +0900 Subject: [PATCH 68/82] Add temporary diagnostic logging to PistonCrystalModule to find where placement is being rejected --- .../game/module/combat/PistonCrystalModule.kt | 99 +++++++++++++++---- 1 file changed, 82 insertions(+), 17 deletions(-) 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 index 2948bc23..276b3cbf 100644 --- 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 @@ -87,8 +87,21 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { 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 @@ -107,7 +120,13 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { 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 nearbyCount = session.level.entityMap.values.count { it.distance(session.localPlayer) <= range } + diag("no target found (range=$range, entities within range incl. non-targetable: $nearbyCount, total entities tracked: ${session.level.entityMap.size})") } } @@ -162,17 +181,24 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { localPlayer.distance(testPos) } + var firstFailureReason: String? = null for (dir in orderedDirs) { for (yLevel in 0..1) { val config = calculatePlacement(targetBlockPos, dir, yLevel) - if (isValidPlacement(config, target)) { + 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) @@ -194,13 +220,20 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { /** 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 { - if (!canPlaceCrystal(config.crystalPos)) return false - if (!canPlaceBlock(config.pistonPos)) return false - if (!canPlaceBlock(config.redstonePos)) return false + 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 false + if (target.distance(crystalCenter) > range) return "target too far from crystal spot (${"%.1f".format(target.distance(crystalCenter))} > $range)" var estimatedTargetDamage = 0f var estimatedSelfDamage = 0f @@ -212,10 +245,10 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { if (entity.runtimeEntityId == target.runtimeEntityId) estimatedTargetDamage = damage if (entity is LocalPlayer) estimatedSelfDamage = damage } - if (estimatedTargetDamage < targetDamageMin) return false - if (estimatedSelfDamage > selfDamageLimit) return false + 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 true + return null } // --- placement sequencing ---------------------------------------------------------------- @@ -266,7 +299,11 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { val localPlayer = session.localPlayer val slot = localPlayer.inventory.searchForItemInHotbar { it.definition?.identifier == identifier - } ?: return true // don't get stuck retrying forever if we're out of the item + } + 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 @@ -283,8 +320,9 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { authInput.rotation = Vector3f.from(0f, yaw, yaw) } - // The crystal item itself isn't a block (it spawns an entity), so it has no - // blockDefinition - but for the crystal specifically we already know pos.add(0,-1,0) is a + // 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) { @@ -293,7 +331,10 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { BlockPlacementUtils.findReferenceBlock(session, pos) ?: return false // no solid neighbor to click yet - retry next tick } - val blockDefinition = BlockPlacementUtils.blockDefinitionFor(session, identifier) + // 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 @@ -301,13 +342,17 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { blockPosition = refPos blockFace = refFace hotbarSlot = slot - itemInHand = localPlayer.inventory.hand + itemInHand = heldItem playerPosition = localPlayer.vec3Position clickPosition = Vector3f.from(0.5f, 0.5f, 0.5f) - this.blockDefinition = blockDefinition + // 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)) } session.serverBound(transaction) - BlockPlacementUtils.predictLocalBlockChange(session, pos, blockDefinition) + BlockPlacementUtils.predictLocalBlockChange(session, pos, placedDefinition) return true } @@ -328,7 +373,27 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { containerId = 0 isSelectHotbarSlot = true } - session.clientBound(packet) + // 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) + } + + 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 ---------------------------------------------------------- From 976e543961830f8333e058a318a1cd8d21685be6 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sat, 22 Aug 2026 19:31:56 +0900 Subject: [PATCH 69/82] Wire up codecHelper.blockDefinitions to our block mapping (was never set - only itemDefinitions was) --- .../retrivedmods/wclient/game/GameSession.kt | 20 +++++++++++++++++++ .../game/module/combat/PistonCrystalModule.kt | 18 +++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) 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 70aea93a..2d9ed173 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/GameSession.kt @@ -121,6 +121,26 @@ class GameSession(val wRelaySession: WRelaySession) : ComposedPacketHandler { } 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) + } + } } } 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 index 276b3cbf..b1731dbe 100644 --- 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 @@ -125,8 +125,22 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { diag("target found (${target.javaClass.simpleName}, dist=${"%.1f".format(target.distance(session.localPlayer))}) but no valid placement. First failure: ${lastPlacementFailureReason}") } } else { - val nearbyCount = session.level.entityMap.values.count { it.distance(session.localPlayer) <= range } - diag("no target found (range=$range, entities within range incl. non-targetable: $nearbyCount, total entities tracked: ${session.level.entityMap.size})") + 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") } } From 71f4f4b89ae7efcd4c8fdc538c42fe73e2314375 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sun, 23 Aug 2026 01:32:32 +0900 Subject: [PATCH 70/82] Add diagnostic logging to SurroundModule --- .../wclient/game/module/combat/SurroundModule.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 index 5efdaa57..b3b98356 100644 --- 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 @@ -39,6 +39,7 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { private var tickCounter = 0 private var oldSlot = -1 private var hasCentered = false + private var surroundDiagTickCounter = 0 override fun onEnabled() { super.onEnabled() @@ -82,6 +83,20 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { placeList = computePlaceList() + run { + surroundDiagTickCounter++ + if (surroundDiagTickCounter % 40 == 0) { + val pos = localPlayer.vec3Position + val currentPos = Vector3i.from(floor(pos.x).toInt(), floor(pos.y + 0.5f).toInt(), floor(pos.z).toInt()) + val feetBelow = session.level.getBlockAt(currentPos.add(0, -1, 0)).identifier + val north = session.level.getBlockAt(currentPos.add(0, -1, -1)).identifier + session.displayClientMessage( + "§b[SurroundDiag] placeList size=${placeList.size}, airPlace=$airPlace, " + + "block directly below feet=$feetBelow, block below N cell=$north, obsidianSlot=$obsidianSlot" + ) + } + } + if (fakeRotation && placeList.isNotEmpty()) { val eye = localPlayer.vec3Position val target = placeList.first() From 1212d65c0f75004405bc90ecde33c849e1bbce65 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sun, 23 Aug 2026 11:00:13 +0900 Subject: [PATCH 71/82] Add files via upload --- .../game/module/combat/PistonCrystalModule.kt | 12 +++++++++++- .../wclient/game/module/combat/SurroundModule.kt | 9 ++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) 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 index b1731dbe..63bd4d31 100644 --- 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 @@ -220,7 +220,17 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { return Placement(crystalPos, pistonPos, redstonePos, dir) } - private fun isAir(pos: Vector3i) = session.level.getBlockAt(pos).identifier == "minecraft:air" + 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 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 index b3b98356..089fbe59 100644 --- 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 @@ -158,7 +158,14 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { /** [Surround.cpp]'s canPlaceBlock(): the target itself must be air (or, with airPlace, anything). */ private fun canPlaceAt(pos: Vector3i): Boolean { if (airPlace) return true - return session.level.getBlockAt(pos).identifier == "minecraft:air" + 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 { From 014fef01282d6e621f53d05b209bbddea2c449a0 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sun, 23 Aug 2026 11:00:46 +0900 Subject: [PATCH 72/82] Add files via upload --- .../retrivedmods/wclient/game/world/Level.kt | 97 +++++++++++++++---- 1 file changed, 79 insertions(+), 18 deletions(-) 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 14d17dfb..5c919716 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 @@ -14,6 +14,7 @@ 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.ClientCacheMissResponsePacket import org.cloudburstmc.protocol.bedrock.packet.LevelChunkPacket import org.cloudburstmc.protocol.bedrock.packet.PlayerListPacket import org.cloudburstmc.protocol.bedrock.packet.RemoveEntityPacket @@ -21,6 +22,7 @@ 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 @@ -35,14 +37,32 @@ class Level(val session: GameSession) { val playerMap = ConcurrentHashMap() // --- world/chunk block tracking ----------------------------------------------------------- - // Ported (with real changes, see Chunk/ChunkSection/BlockStorage) from ProtoHax. Only the - // "normal" full LevelChunkPacket path is handled - servers using blob (disk) caching or the - // newer per-subchunk request system (SubChunkPacket) won't have their blocks tracked here, - // since that needs a blob cache we don't implement. UpdateBlockPacket (individual block - // changes) IS handled, so blocks placed/broken after a chunk loads stay accurate. + // 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 @@ -53,6 +73,7 @@ class Level(val session: GameSession) { entityMap.clear() playerMap.clear() chunks.clear() + pendingCacheBlobs.clear() } fun onPacketBound(packet: BedrockPacket) { @@ -78,14 +99,20 @@ class Level(val session: GameSession) { return } - if (packet.isCachingEnabled) { - // blob-cache chunk loading isn't supported, see note above - return - } - val chunk = Chunk(packet.chunkX, packet.chunkZ, is384WorldSupported, session.blockMapping) try { - if (!packet.isRequestSubChunks) { + 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 @@ -93,10 +120,11 @@ class Level(val session: GameSession) { val buf = packet.data.duplicate() chunk.read(buf, packet.subChunksLength) } - // Either way, register the (possibly still-empty) chunk now: when - // isRequestSubChunks is true, LevelChunkPacket only carries biome/border data - // and the actual block data streams in afterwards via SubChunkPacket, which - // needs a Chunk already sitting in the map to attach its sections to. + // 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 @@ -104,6 +132,23 @@ class Level(val session: GameSession) { } } + 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 @@ -151,6 +196,21 @@ class Level(val session: GameSession) { } } + 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 } @@ -299,9 +359,10 @@ class Level(val session: GameSession) { * 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 simply - * isn't tracked/loaded yet - see the LevelChunkPacket handling notes above for when that - * happens) or if none of its neighbors are known to be solid, e.g. floating in open air. + * 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 From 2871861d1e4b878c1163f63cd1f6970f24b7ab5d Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sun, 23 Aug 2026 11:19:57 +0900 Subject: [PATCH 73/82] Add files via upload --- .../wclient/game/BlockPlacementUtils.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt b/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt index 4545b1c5..e3858e6b 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt @@ -1,10 +1,12 @@ 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, @@ -95,6 +97,31 @@ object BlockPlacementUtils { ) } + /** + * 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 -> From 5299afb5038f342ea4a6bd8795bee06bee326b37 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sun, 23 Aug 2026 11:20:33 +0900 Subject: [PATCH 74/82] Add files via upload --- .../wclient/game/module/combat/PistonCrystalModule.kt | 2 +- .../retrivedmods/wclient/game/module/combat/SurroundModule.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 63bd4d31..fbbc5b15 100644 --- 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 @@ -375,7 +375,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { blockDefinition = BlockPlacementUtils.referenceBlockDefinition(session, refPos) actions.add(BlockPlacementUtils.consumeItemAction(slot, heldItem)) } - session.serverBound(transaction) + BlockPlacementUtils.sendAndLog(session, transaction) BlockPlacementUtils.predictLocalBlockChange(session, pos, placedDefinition) return true } 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 index 089fbe59..4062ab56 100644 --- 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 @@ -251,7 +251,7 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { blockDefinition = BlockPlacementUtils.referenceBlockDefinition(session, refPos) actions.add(BlockPlacementUtils.consumeItemAction(slot, heldItem)) } - session.serverBound(packet) + BlockPlacementUtils.sendAndLog(session, packet) BlockPlacementUtils.predictLocalBlockChange(session, pos, placedDefinition) } From aecfc13aa460aea17d522cb90ff54e8ebd4669e6 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Sun, 23 Aug 2026 11:45:48 +0900 Subject: [PATCH 75/82] Add files via upload --- .../wclient/game/BlockPlacementUtils.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt b/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt index e3858e6b..b974e883 100644 --- a/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt +++ b/app/src/main/java/com/retrivedmods/wclient/game/BlockPlacementUtils.kt @@ -46,13 +46,24 @@ object BlockPlacementUtils { * 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) - if (session.level.getBlockAt(neighbor).identifier != "minecraft:air") { + 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 null + return fallback } /** From 17d8d089ae384a11e28ea919cc7ca497f972a251 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Mon, 24 Aug 2026 11:42:16 +0900 Subject: [PATCH 76/82] Add files via upload --- .../retrivedmods/wclient/game/world/Level.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 5c919716..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 @@ -14,6 +14,7 @@ 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 @@ -132,6 +133,23 @@ class Level(val session: GameSession) { } } + 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 From b49c99cefead87c60626c9ece76bf1349785e8ad Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Mon, 24 Aug 2026 12:24:28 +0900 Subject: [PATCH 77/82] Add files via upload --- .../game/module/combat/SurroundModule.kt | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) 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 index 4062ab56..cb4592fd 100644 --- 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 @@ -87,12 +87,29 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { surroundDiagTickCounter++ if (surroundDiagTickCounter % 40 == 0) { val pos = localPlayer.vec3Position - val currentPos = Vector3i.from(floor(pos.x).toInt(), floor(pos.y + 0.5f).toInt(), floor(pos.z).toInt()) - val feetBelow = session.level.getBlockAt(currentPos.add(0, -1, 0)).identifier - val north = session.level.getBlockAt(currentPos.add(0, -1, -1)).identifier + // 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, " + - "block directly below feet=$feetBelow, block below N cell=$north, obsidianSlot=$obsidianSlot" + "§b[SurroundDiag] placeList size=${placeList.size}, airPlace=$airPlace, obsidianSlot=$obsidianSlot\n$cellDump" ) } } @@ -171,7 +188,9 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { private fun computePlaceList(): MutableList { val localPlayer = session.localPlayer val pos = localPlayer.vec3Position - val currentPos = Vector3i.from(floor(pos.x).toInt(), floor(pos.y + 0.5f).toInt(), floor(pos.z).toInt()) + // 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 @@ -251,7 +270,7 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { blockDefinition = BlockPlacementUtils.referenceBlockDefinition(session, refPos) actions.add(BlockPlacementUtils.consumeItemAction(slot, heldItem)) } - BlockPlacementUtils.sendAndLog(session, packet) + session.serverBound(packet) BlockPlacementUtils.predictLocalBlockChange(session, pos, placedDefinition) } From f3c8cffe8825d4843bc1c111674465689b768ef3 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Mon, 24 Aug 2026 15:20:22 +0900 Subject: [PATCH 78/82] Fix local-prediction poisoning: stop trusting our own optimistic block updates as permanent truth when the server may have silently rejected the placement --- .../game/module/combat/PistonCrystalModule.kt | 32 ++++++++++- .../game/module/combat/SurroundModule.kt | 53 +++++++++++++++---- 2 files changed, 74 insertions(+), 11 deletions(-) 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 index b1731dbe..989ab788 100644 --- 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 @@ -56,6 +56,14 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { // 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) @@ -63,6 +71,11 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { 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 @@ -112,6 +125,11 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { 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) { @@ -220,7 +238,17 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { return Placement(crystalPos, pistonPos, redstonePos, dir) } - private fun isAir(pos: Vector3i) = session.level.getBlockAt(pos).identifier == "minecraft:air" + 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 @@ -365,7 +393,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { blockDefinition = BlockPlacementUtils.referenceBlockDefinition(session, refPos) actions.add(BlockPlacementUtils.consumeItemAction(slot, heldItem)) } - session.serverBound(transaction) + BlockPlacementUtils.sendAndLog(session, transaction) BlockPlacementUtils.predictLocalBlockChange(session, pos, placedDefinition) return true } 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 index b3b98356..00ac6266 100644 --- 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 @@ -87,12 +87,29 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { surroundDiagTickCounter++ if (surroundDiagTickCounter % 40 == 0) { val pos = localPlayer.vec3Position - val currentPos = Vector3i.from(floor(pos.x).toInt(), floor(pos.y + 0.5f).toInt(), floor(pos.z).toInt()) - val feetBelow = session.level.getBlockAt(currentPos.add(0, -1, 0)).identifier - val north = session.level.getBlockAt(currentPos.add(0, -1, -1)).identifier + // 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, " + - "block directly below feet=$feetBelow, block below N cell=$north, obsidianSlot=$obsidianSlot" + "§b[SurroundDiag] placeList size=${placeList.size}, airPlace=$airPlace, obsidianSlot=$obsidianSlot\n$cellDump" ) } } @@ -158,13 +175,22 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { /** [Surround.cpp]'s canPlaceBlock(): the target itself must be air (or, with airPlace, anything). */ private fun canPlaceAt(pos: Vector3i): Boolean { if (airPlace) return true - return session.level.getBlockAt(pos).identifier == "minecraft:air" + 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 - val currentPos = Vector3i.from(floor(pos.x).toInt(), floor(pos.y + 0.5f).toInt(), floor(pos.z).toInt()) + // 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 @@ -226,7 +252,6 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { 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 placedDefinition = BlockPlacementUtils.blockDefinitionFor(session, identifier) val heldItem = localPlayer.inventory.hand val packet = InventoryTransactionPacket().apply { @@ -245,7 +270,17 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { actions.add(BlockPlacementUtils.consumeItemAction(slot, heldItem)) } session.serverBound(packet) - BlockPlacementUtils.predictLocalBlockChange(session, pos, placedDefinition) + // 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) { From 011d3181723de4926bcb1f324cdafb26f447cd2a Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Mon, 24 Aug 2026 18:06:36 +0900 Subject: [PATCH 79/82] Add files via upload --- .../wclient/game/module/combat/PistonCrystalModule.kt | 6 ++++++ .../wclient/game/module/combat/SurroundModule.kt | 11 +++++++++++ 2 files changed, 17 insertions(+) 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 index 989ab788..c54062dc 100644 --- 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 @@ -420,6 +420,12 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { // 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.heldItemSlot = slot } private var lastWarnedMessage: String? = null 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 index 00ac6266..8fc8f35b 100644 --- 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 @@ -294,5 +294,16 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { // 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.heldItemSlot = slot } } From 429c65d8cc0dad854579c3d2214617cfa8829fce Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Mon, 24 Aug 2026 18:18:52 +0900 Subject: [PATCH 80/82] Add files via upload --- .../wclient/game/inventory/PlayerInventory.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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..53f69e3c 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,23 @@ 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 set + private var requestId = -1 private val requestIdMap = mutableMapOf() private val pendingRequests = LinkedList() From f681b13eee74c32e67aad169859c9c74912b4b0b Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Mon, 24 Aug 2026 18:19:27 +0900 Subject: [PATCH 81/82] Add files via upload --- .../wclient/game/module/combat/PistonCrystalModule.kt | 2 +- .../retrivedmods/wclient/game/module/combat/SurroundModule.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index c54062dc..8077dec2 100644 --- 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 @@ -425,7 +425,7 @@ class PistonCrystalModule : Module("piston_crystal", ModuleCategory.Combat) { // 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.heldItemSlot = slot + session.localPlayer.inventory.predictHeldItemSlot(slot) } private var lastWarnedMessage: String? = null 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 index 8fc8f35b..9d3f1728 100644 --- 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 @@ -304,6 +304,6 @@ class SurroundModule : Module("surround", ModuleCategory.Combat) { // 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.heldItemSlot = slot + session.localPlayer.inventory.predictHeldItemSlot(slot) } } From 0c40ee109194e30547d20fd6a30a59ae6c6d8ae5 Mon Sep 17 00:00:00 2001 From: saikyoupurochita-dot Date: Mon, 24 Aug 2026 18:30:22 +0900 Subject: [PATCH 82/82] Add files via upload --- .../com/retrivedmods/wclient/game/inventory/PlayerInventory.kt | 1 - 1 file changed, 1 deletion(-) 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 53f69e3c..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 @@ -47,7 +47,6 @@ class PlayerInventory(private val player: LocalPlayer) : EntityInventory(player) fun predictHeldItemSlot(slot: Int) { heldItemSlot = slot } - private set private var requestId = -1 private val requestIdMap = mutableMapOf()