From f4517b47c894f3f2e2e0dd38f3812c82cf0f32b5 Mon Sep 17 00:00:00 2001 From: reldo-dev <316375707+reldo-dev@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:21:04 -0700 Subject: [PATCH] routefinder: name loc approach bits and cover rotation with tests Rotations.rotate masks its input to 4 bits (7cd2d93). The mask is correct but hides the reason: forceApproachFlags is a 5-bit mask, not 4. Every non-zero value in the rev 240 cache is 0b11111 with exactly one bit cleared - the single permitted approach. Bits 0-3 are N/E/S/W; bit 4 is a fifth position used by 59 locs and unmodelled server-side. Unmasked, only 0x1e broke: its stray bit wraps into the low nibble giving 0x0f, blocking every side. The other three rotate correctly by coincidence. No behaviour change. --- .../rsmod/routefinder/flag/BlockAccessFlag.kt | 13 ++ .../org/rsmod/routefinder/util/Rotations.kt | 15 ++- .../rsmod/routefinder/util/RotationsTest.kt | 112 ++++++++++++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 engine/routefinder/src/test/kotlin/org/rsmod/routefinder/util/RotationsTest.kt diff --git a/engine/routefinder/src/main/kotlin/org/rsmod/routefinder/flag/BlockAccessFlag.kt b/engine/routefinder/src/main/kotlin/org/rsmod/routefinder/flag/BlockAccessFlag.kt index d356ecf02..616bac783 100644 --- a/engine/routefinder/src/main/kotlin/org/rsmod/routefinder/flag/BlockAccessFlag.kt +++ b/engine/routefinder/src/main/kotlin/org/rsmod/routefinder/flag/BlockAccessFlag.kt @@ -6,4 +6,17 @@ public object BlockAccessFlag { public const val EAST: Int = 0x2 public const val SOUTH: Int = 0x4 public const val WEST: Int = 0x8 + + /** + * The compass approaches, and the only bits [org.rsmod.routefinder.util.Rotations.rotate] acts + * on. + */ + public const val DIRECTIONS: Int = NORTH or EAST or SOUTH or WEST + + /** + * Every approach position a loc can block. A restricted loc clears exactly one bit - the side + * it may be approached from. The fifth position is not a compass direction and is not modelled, + * so locs that clear it cannot be reached. + */ + public const val ALL_APPROACHES: Int = 0x1F } diff --git a/engine/routefinder/src/main/kotlin/org/rsmod/routefinder/util/Rotations.kt b/engine/routefinder/src/main/kotlin/org/rsmod/routefinder/util/Rotations.kt index 707d3aac9..27ea76709 100644 --- a/engine/routefinder/src/main/kotlin/org/rsmod/routefinder/util/Rotations.kt +++ b/engine/routefinder/src/main/kotlin/org/rsmod/routefinder/util/Rotations.kt @@ -1,5 +1,7 @@ package org.rsmod.routefinder.util +import org.rsmod.routefinder.flag.BlockAccessFlag + public object Rotations { public fun rotate(angle: Int, dimensionA: Int, dimensionB: Int): Int = if (angle and 0x1 != 0) { @@ -8,12 +10,19 @@ public object Rotations { dimensionA } - public fun rotate(angle: Int, blockAccessFlags: Int): Int { - val flags = blockAccessFlags and 0xF + /** + * Rotates the compass bits of [blockAccessFlags] clockwise by [angle] quarter turns. + * + * Only [BlockAccessFlag.DIRECTIONS] take part. Locs block a fifth, non-directional approach + * position in the same field (see [BlockAccessFlag.ALL_APPROACHES]); carrying it through the + * rotate would wrap it into the compass bits and block sides that should stay open. + */ + public fun rotate(angle: Int, blockAccessFlags: Int): Int { + val flags = blockAccessFlags and BlockAccessFlag.DIRECTIONS return if (angle == 0) { flags } else { - ((flags shl angle) and 0xF) or (flags shr (4 - angle)) + ((flags shl angle) and BlockAccessFlag.DIRECTIONS) or (flags shr (4 - angle)) } } } diff --git a/engine/routefinder/src/test/kotlin/org/rsmod/routefinder/util/RotationsTest.kt b/engine/routefinder/src/test/kotlin/org/rsmod/routefinder/util/RotationsTest.kt new file mode 100644 index 000000000..b1df41f39 --- /dev/null +++ b/engine/routefinder/src/test/kotlin/org/rsmod/routefinder/util/RotationsTest.kt @@ -0,0 +1,112 @@ +package org.rsmod.routefinder.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.rsmod.routefinder.flag.BlockAccessFlag.ALL_APPROACHES +import org.rsmod.routefinder.flag.BlockAccessFlag.DIRECTIONS +import org.rsmod.routefinder.flag.BlockAccessFlag.EAST +import org.rsmod.routefinder.flag.BlockAccessFlag.NORTH +import org.rsmod.routefinder.flag.BlockAccessFlag.SOUTH +import org.rsmod.routefinder.flag.BlockAccessFlag.WEST + +class RotationsTest { + // The only `forceApproachFlags` values present in the rev 240 cache. + private val northOpen = ALL_APPROACHES and NORTH.inv() // 0x1e + private val eastOpen = ALL_APPROACHES and EAST.inv() // 0x1d + private val southOpen = ALL_APPROACHES and SOUTH.inv() // 0x1b + private val westOpen = ALL_APPROACHES and WEST.inv() // 0x17 + private val fifthOpen = DIRECTIONS // 0x0f + + private val compassRestricted = listOf(northOpen, eastOpen, southOpen, westOpen) + + @Test + fun `north approach advances clockwise`() { + assertRotations( + northOpen, + EAST or SOUTH or WEST, // north + NORTH or SOUTH or WEST, // east + NORTH or EAST or WEST, // south + NORTH or EAST or SOUTH, // west + ) + } + + @Test + fun `east approach advances clockwise`() { + assertRotations( + eastOpen, + NORTH or SOUTH or WEST, // east + NORTH or EAST or WEST, // south + NORTH or EAST or SOUTH, // west + EAST or SOUTH or WEST, // north + ) + } + + @Test + fun `south approach advances clockwise`() { + assertRotations( + southOpen, + NORTH or EAST or WEST, // south + NORTH or EAST or SOUTH, // west + EAST or SOUTH or WEST, // north + NORTH or SOUTH or WEST, // east + ) + } + + @Test + fun `west approach advances clockwise`() { + assertRotations( + westOpen, + NORTH or EAST or SOUTH, // west + EAST or SOUTH or WEST, // north + NORTH or SOUTH or WEST, // east + NORTH or EAST or WEST, // south + ) + } + + @Test + fun `locs with no compass approach are unaffected by rotation`() { + assertRotations(fifthOpen, fifthOpen, fifthOpen, fifthOpen, fifthOpen) + } + + @Test + fun `unrestricted locs are unaffected by rotation`() { + assertRotations(0, 0, 0, 0, 0) + } + + @Test + fun `restricted locs keep exactly one open side at every angle`() { + for (flags in compassRestricted) { + for (angle in 0..3) { + val blocked = Rotations.rotate(angle, flags) + assertEquals( + 3, + blocked.countOneBits(), + "angle=$angle flags=0x${flags.toString(16)} blocked=0x${blocked.toString(16)}", + ) + } + } + } + + @Test + fun `rotation never returns bits outside the compass`() { + for (flags in compassRestricted + listOf(fifthOpen, 0)) { + for (angle in 0..3) { + assertEquals( + 0, + Rotations.rotate(angle, flags) and DIRECTIONS.inv(), + "angle=$angle flags=0x${flags.toString(16)} leaked a non-compass bit", + ) + } + } + } + + private fun assertRotations(flags: Int, vararg expected: Int) { + for (angle in expected.indices) { + assertEquals( + expected[angle], + Rotations.rotate(angle, flags), + "angle=$angle flags=0x${flags.toString(16)}", + ) + } + } +}