Skip to content

Walker infinite loop: ARRIVED early on unwalkable target with no line-of-sight #5

@runsonmypc

Description

@runsonmypc

Summary

Rs2Walker.walkWithStateInternal returns ARRIVED whenever the target tile is unwalkable and the player is within distance of it — without checking whether the player can actually interact from their current tile. When a solid wall sits between player and target, this produces an infinite loop in QuestScript (and any caller that re-issues walkTo after a failed interaction).

Reproduction

Observed in ironman-guide worktree, multiple QuestScript-N threads running concurrently:

  • Player at (3305,3501,0) (Falador / Port Sarim area).
  • Quest target at (3305,3500,0) — unwalkable tile (counter / NPC stance / similar).
  • Solid wall directly between the two tiles. There is no door — the actual route is around the wall.
  • Player has no line of sight to the target object/NPC from their current tile.

Result: 5+ concurrent quest scripts hot-loop indefinitely, each burning ~1s of CPU per tick. The player never moves.

Trace from the logs

[Pathfinder] run() started: src=(3305,3501,0), dst=(3305,3500,0), cutoff=3000ms
[Pathfinder] Loop exited. cancelled=false, boundaryEmpty=true, pendingEmpty=true, bestLastNode=(3305,3501,0)
[Pathfinder] run() completed. done=true, cancelled=false, stats=PathfinderStats(nodes=1037532,transports=1454,time=359ms)
[Walker] ARRIVED early. arrivalCheck=0ms, distToTarget=1

Sequence per quest tick (per script):

  1. QuestScript.applyObjectStep calls Rs2Walker.canReach(target) (line 1311 / 1371).
  2. canReach runs PathfinderConfig.refresh() (~560ms) → new Pathfinder(...).run() synchronously (~360ms, exhausts the world because target is unreachable on the static collision map) → another refresh() in finally (~560ms). ~1.5s per call.
  3. canReach returns based on path-endpoint-vs-target proximity intersection — true here because endpoint = src and target is 1 tile away.
  4. Quest helper falls to Rs2Walker.walkTo(target, 1).
  5. walkWithStateInternal arrival check:
    • walkableCheck = Rs2Tile.isWalkable(localTarget)false (target tile has BLOCK_MOVEMENT_FULL).
    • distToTarget=1, distance=1.
    • Branch (!walkableCheck && distToTarget <= distance)true → returns ARRIVED.
  6. Quest helper attempts to interact with the object/NPC at the target → fails because LOS is blocked by the wall.
  7. Quest helper iterates next tick, repeat from step 1.

Multiple quest scripts hitting this at the same time (QuestScript-2, -3, -7, -8, -10) compound the cost since they share ShortestPathPlugin.pathfinder and each setTarget cancels/restarts it.

Root cause

Rs2Walker.walkWithStateInternal (Rs2Walker.java:139):

if (reachableTileCheck || (!walkableCheck && distToTarget <= distance)) {
    log.info("[Walker] ARRIVED early. arrivalCheck={}ms, distToTarget={}", arrivalCheckMs, distToTarget);
    return WalkerState.ARRIVED;
}

The (!walkableCheck && distToTarget <= distance) branch assumes "target unwalkable + within proximity = arrived". That's correct for an NPC standing in an open room, but wrong when a wall separates the player from the target. There is no check that the player has a viable interaction angle (line of sight, or being on the correct side of the obstacle).

Proposed fix

When the target tile is unwalkable and distToTarget ≤ distance, additionally verify that the player can plausibly interact. Cheapest test: line-of-sight from player tile to target. If LOS is blocked, search nearby walkable tiles around the target for one that has LOS, walk to that tile instead of returning ARRIVED.

Sketch:

if (!walkableCheck && distToTarget <= distance) {
    if (Rs2Player.hasLineOfSight(target)) {
        return WalkerState.ARRIVED;          // genuinely close enough
    }
    WorldPoint losTile = Rs2Tile.getWalkableTilesAroundTile(target, 3).stream()
        .filter(t -> Rs2Tile.hasLineOfSightToTile(t, target))
        .min(Comparator.comparingInt(t -> t.distanceTo(Rs2Player.getWorldLocation())))
        .orElse(null);
    if (losTile != null && !losTile.equals(Rs2Player.getWorldLocation())) {
        // walk to losTile, then re-check
    }
    // else: genuinely can't reach with LOS; consider returning UNREACHABLE
    //       so the caller stops looping
}

Secondary issue: canReach is wasteful

Independent of the LOS bug, Rs2Walker.canReach(WorldPoint) (Rs2Walker.java:721) does refresh + new Pathfinder.run() (synchronous, full BFS) + refresh per call — ~1.5s including a world-exhausting BFS when the target is unreachable. Quest helper calls this every tick, often from multiple concurrent scripts. Even after the LOS fix, this is costly.

Possible improvements:

  • Cache the result for a few hundred ms keyed on (playerTile, target).
  • Skip the leading/trailing refresh() when called repeatedly within a refresh window.
  • Fall back to Rs2Tile-based reachability for short distances before invoking the full pathfinder.

Workaround in quest helper

Until the walker fix lands, QuestScript.applyObjectStep could detect "called walkTo and got ARRIVED but interaction still fails twice in a row" and re-target a tile with LOS via Rs2Tile.getWalkableTilesAroundTile(target, n) filtered by LOS.

Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions