Loop ordering policy (patrol_policy):
weighted(default) — the next anchor is drawn by roulette with probability ∝ 1/route-cost^patrol_weight_temp(temp 1 = 1/cost). Near anchors are likely, far anchors keep a real chance — nothing is neglected, and the loop never becomes a metronome. Lower the temp toward uniform randomness; raise it toward greedy.greedy— always the cheapest next anchor, with ±20% cost jitter for variety.zigzag— row by row: the next anchor comes from the nearest row (platform rows within 6px share one) that still has anchors, cheapest first within it. With sweeps the loop snakes — a row one way, the next row back — instead of hopping between tiers.
All three ban unreachable anchors for 30s, execute the plan strictly
(splice-on-fail, 2 misses → ban), and pipeline the next loop before the
current one ends. (core/src/bot/patrol.rs, core/src/planner.rs)
With ≥2 anchors and drawn platforms the bot strictly follows a planned loop:
- The plan is a fixed loop of anchor-to-anchor segments over the movement graph, ordered greedily by route cost (±20% jitter so loops vary). Legs execute in order — no re-planning between legs. The plan is drawn on the Panel view (olive), the current segment yellow.
- The next loop is planned before the current one ends (when the bot enters the last segment), so a planned path always exists. If planning ever yields nothing, the bot halts and takes a break — no movement, no attacks — and retries every few seconds.
- Planning respects cooldowns: the planner runs a clock along the loop (route cost × a learned pace, the real seconds a segment takes per second of cost) and only plans rope lift or teleport where it will be ready again; a cooling one is replaced by the next-cheapest move. An unbound one is never planned. When only a cooling move gets somewhere, it's planned anyway (the run re-routes if it's still cooling) — a cooldown never skips an anchor. The next loop starts its clock after the current segment's legs.
- One leg per tick; safety checks run between legs. A failed leg splices a re-route into the plan on the next tick, from where the player ended up (after any time off the platforms); rope lift and teleport are excluded while cooling, without counting a failure. 2 missed landings in a row ban the anchor for 30s and re-route to the next one.
- A leg interrupted by focus loss, a hazard or a stop is aborted, not missed: no miss counted, nothing learned, and it's re-routed on resume.
- Anchors are pure pass-through waypoints: arriving fires the
anchor's non-summon
on_arriveskills and places at most one summon (see Summons) in passing, then the bot moves on — no linger, no dwell timers. - Platform sweeps (
patrol_mode: sweep, the default): an anchor marks a spawn platform, and the loop sweeps it rather than visiting the point. A sweep goes to whichever end of the platform is cheaper to reach, then crosses from 8px inside that end tosweep_reach_px(default 12, at least 8) short of the other — never nearer an edge than 8px, since a walk slides a few px past where it lets go — on flash hops with an attack window each, so the last attack, facing the far end, covers it. Several anchors on one platform make one sweep (the first anchor's; the others aren't targets). A platform too short for that is visited at its middle. The loop orders sweeps by the cost of getting to them only — every sweep is made once a loop, so a long platform isn't put off for its length — and goes on from each sweep's far end. Arrival skills, buffs and the summon happen at the end of the sweep. A missed landing re-routes to the sweep's entry, or straight on to its exit when already on the platform. The crossing is one walk leg along the platform, never a route through other tiers; when both ends cost about the same to reach, the nearer one is entered, so a row keeps its direction.patrol_mode: anchorspasses through anchor points as before. - Attack heatmap: every attack adds to a 4px cell at the player's
dot; cells fade with a 5-minute half-life and reset on a map change.
The Panel view's Heat toggle paints them (blue = rarely, red =
often) while the bot runs — drawn platforms with no colour on them are
the stretches the rotation neglects. (
core/src/heat.rs) - Loop policy and temperature: every reachable anchor is visited
exactly once per loop;
patrol_weight_temponly orders the loop. Higher temperature → nearer-first sweeps → shorter loops, so every anchor (far ones too) comes around sooner; lower → more random, longer loops. An anchor that falls behind is being skipped or missed — Setup → Tuning → Patrol → Anchors lists visits, last visit, missed landings and skips by reason per anchor (this host session; Reset anchor stats after fixing geometry). - Bans: no route, unreachable-at-plan-time (including anchors not on any drawn platform — "re-place it"), or 2 missed landings → skipped for 30s, so one bad anchor can't shrink or stall the patrol.
- Hand-authored
legsfor a pair still win (ropes aren't in the graph). - Fallbacks: no drawn platforms → the older straight-line patrol; platforms drawn but the player off all of them → wait with a throttled "not on any drawn platform" diagnostic (the legacy patrol would plan routes from an off-graph start and ban every anchor); one anchor → weave on its platform indefinitely (standing on another platform or level hands the anchor to TRAVEL, or takes a break while a failed leg has it banned — a leg interrupted by a pause keeps its target and is retried, unbanned); no anchors → weave around where grinding started.
- A walk (
move_to_point) gives up after 8s plus three times the straight walk to its target — blind, or bouncing around the target — so a tick can't run on long enough to starve the watchdog. - Platform matching is asymmetric: the player glyph floats above the
drawn row, so a point matches its platform when it sits up to
snap_pxabove the row (and up to 2px below) — stacked tiers stay unambiguous.
Anchors float anchor_float_px (default 4) above the drawn platform
line — the same height as the player icon — instead of sitting on it.
Placement snaps the height to the platform under the click and raises it;
the runtime checks arrival by platform membership, so older on-row
anchors keep working.
The movement graph links drawn platforms (plus learned ropes — see below) with every move whose reach covers the gap:
| Move | Input | Use |
|---|---|---|
| walk | flash weaves (walk near the goal) | along a platform |
jump / flash / double_flash |
jump; jump + re-press; + second re-press | horizontal gaps — a carried move always flies its full learned distance, so it's planned taking off as near its own platform's edge as the walk allows (at least 8px inside it) and backed off only to land 7px short of the target platform's far end (nav_carry_aim: edge; middle aims at the platform's middle instead), only where it comes down on that platform and no platform in between catches it |
up_flash |
jump, then Up + jump mid-air | platform directly above |
up_side_flash |
up flash, then a sideways flash mid-air | higher platform across a gap |
rope_lift |
up_jump_skill_key |
grabs the highest platform within nav_rope_lift_px (~90) of the takeoff column — only where no proven jump, flash or up flash reaches that platform (those come first); still used to skip a tier no single jump reaches. Costs rope_lift_cost (1.5s: its wind-up alone is ~1.2s), so a route doesn't lift to a higher tier just to drop back down |
down_jump / drop |
down + jump; walk off an end | lower platforms; a down jump takes off more than rope_clear_px (8px) from any learned rope hanging off its platform — Down on a rope's top grabs the rope instead |
climb_up |
a moving grab: Up held ~0.15–0.25s before takeoff, then a hop toward the rope from 6px beside it (or a flash jump off a platform end up to 20px out, flash kits) — then climb and mount the top platform. Straight-up only when the platform is too narrow to step aside | boards from any platform within jump reach below the rope's bottom end, which stays ≥5px above the platform under it |
climb_down |
grab at the top (hold down), descend past the rope's bottom end, land below | lands on the nearest platform under the rope's end |
There is no direct release from a rope: after any failed climb the bot leaps off (hold direction + jump) toward the nearest platform it can land on — at or below it, never one above — and replans from where it lands. Climb checks ignore 1px of dot jitter (it is neither progress nor a fall).
The dot is found by its colour (minimap_colors.player); pixels within
2px of each other count as one marker, so a rope or platform line
through the dot (which splits it) doesn't hide it. Groups under 6px are
specks. The reported point is the marker's true bottom row (its feet);
x rounds half-up so an even-width dot's centre always lands on the same
side. The overlay frames the dot itself — its detected bounds with a 1px
margin — not a box centred on the feet; anchors (also feet points) are
framed where a 6x6 dot standing on them would be, so an arrived player
lines up with its anchor.
Detection tracks the dot per thread: when several yellow markers
qualify, the one nearest the last position wins, and one missed frame
repeats the last position before the dot counts as lost. With the dot
lost the bot only waits — it never learns ropes or jumps blind. Each loss
saves the minimap crop for later study (see development.md, Debug frame
captures).
Rope climbs cost rope_penalty (default 5s) on top of climb time —
ropes are a last resort: platforms are normally reachable via jumps,
rope lift, or (future) teleport. Set rope_penalty: 0 to let the
planner use them freely.
Ropes are not drawn. When the bot is off every drawn platform at a
stable position for over 2 seconds it holds Down briefly: on a rope
the character slides down, on ground it only crouches (Down, not Up —
Up on a portal changes maps). A confirmed hang records a rope segment
from that spot up to the platform above (persisted in the map file,
drawn brown on the overlay); later hangs on the same column extend that
segment instead of adding another. A learned rope's top stands 3px above
its platform's row so the climb ends on the platform; older ropes ending
up to 6px under the row are lifted onto it when the graph is built. Then it leaps off and replans. Ground
that isn't drawn is logged ("not a rope; draw the platform there") and
the bot hops back toward drawn ground. Learned climbs carry the same
rope_penalty, so they're used only when nothing else connects.
- Reach = sideways
dxand upwardrisein minimap px. Starting values are realistic guesses (nav_*config keys); the Measure moves dashboard button replaces them with real numbers — the bot performs each move a few times and records the observed takeoff→landing intonav_reach_file(see layout.md). - Farming keeps learning: success grows the envelope to what was
observed; the planner may explore up to 1.3× the proven reach at a
cost penalty; the second consecutive miss shrinks the envelope
(one miss may be input timing) and an exploratory miss sets a ceiling.
Sideways moves (jump, flash, double flash) are the exception: their dx
is the median of the last 9 carries (
"carry"in the file, 3 make a median) — a flight that came down on its own row, or one that fell short to a lower row (an upper bound). The planner flies them their full distance, so a furthest-ever carry would put every takeoff too far back. Rises are measured between platform rows, not dot reads (an apex read isn't a landing). Up flashes and down jumps settle first (0.5s) so they don't take off moving. Deletenav_reach_fileto relearn. - Rope lift is preferred over up-flash whenever it's ready (cheapest rise). While it's cooling down it's left out of the plan and the bot up-flashes instead — it never waits on the cooldown. A lift that starts cooling during the approach re-routes without counting a failure.
- The dashboard's Route button shows every edge (colours per move) and previews routes on click.
config.json holds named class profiles with an active selector — the
kit decides which moves exist and where attacks fire:
"class": {
"active": "mage",
"profiles": {
"mage": {"travel": "teleport", "air_attacks": false,
"teleport_key": "shift", "teleport_cooldown": 0.8},
"hero": {"travel": "flash"}
}
}travel: flash— jump + mid-air re-press (the default kit); the graph generates flash/double-flash/up-flash/up-side edges.travel: teleport— blink (gains height): the graph generates teleport edges instead (horizontal gaps and up-teleports), with a learned reach envelope (nav_teleport_dx/nav_teleport_rise, refined like every move) and cooldown-aware planning (excluded while cooling, never waited on). The weave is blink → attack → blink.travel: walk— no air movement; attacks weave into walks.air_attacks: false— the attack tail fires after landing (mages can't attack suspended);trueweaves mid-air as usual.- A teleport without a bound key falls back to
flash. - Per-profile skills: a profile may carry its own
skillsdict — the character's kit (attack, buffs, summons, movement). When active, it replaces the global skill book; per-map overrides still merge on top. The dashboard's Skills panel edits the active profile's kit (seeded from the global book on first edit) and shows which book it's editing. - Measure-moves covers teleport when a key is bound.
- Reach is per profile: measurement writes
nav_reach_<profile>.json(e.g.nav_reach_mage.json) — each class measures its own movement ranges, and switching profiles swaps the learned envelopes.
Bot-controlled movement looks like a player farming, not a macro:
- Point to point = flash weaves (
_flash_weave): hold the direction, jump, re-press mid-air (the flash jump), then an attack window once the flash has triggered (see below). An attack before the re-press eats its input window and the flash never fires. move_to_pointtravels by flash weaves whenever the target is farther thanwalk_band_px(default 8) and the platform leaves a hop of room ahead; walking is only the final precise approach (and future precise destinations like rune solving). Hop distance is learned from observed hops (starts at 14 minimap px). Hops that don't move fall back to walking; withflash_jump_enabled: falseattacks weave into walks.- Walk legs are horizontal (
flat): a few px between the drawn row and the real standing line never triggers vertical jumps. - A gap flash that never triggers releases the direction as soon as the character drops below the takeoff row (or shows no progress for 0.5s) instead of walking into the gap.
- Landings are scored tolerantly (platform x-span + row slack): a move that succeeded never shrinks the reach model — only a real miss does.
- After each move the bot waits for the landing: first for the character to leave its takeoff spot (up to 1.2s for a rope lift, whose rope grapples while the character stands still), then for two steady reads on a drawn platform (≤2.5s) — steadiness alone is not enough, since the top of a jump is steady too, in the air.
- An up flash comes down on the highest platform its peak (the learned rise) clears, so the graph only plans it onto that one — never onto a lower tier it would fly past.
- A miss only counts against a move's reach when it fell short of the plan; one that overshot or came down elsewhere says nothing about reach, and neither does one never seen leaving its takeoff spot (an eaten key, a stun).
- Re-press gaps are tunable:
flash_repress_seconds(jump → flash) andcombo_repress_seconds(between chained flashes). - The up flash is timed from the first jump's key-down: the re-press lands inside the plateau of the class's up-flash timing sweep (the contiguous delays within 1 px of the highest peak), log-normally varied around its middle; without a sweep, 0.16–0.30 s around 0.22 s. Up goes down ~40 ms before the re-press.
- Slips (
move_miss_chance, default 0 — try 1–3%): a flash move can skip its last mid-air re-press (Body::slip), so the hop really falls short and the navigator treats it like any missed leg (logged "Slip: skipping the … re-press", then "Missed …"). Slips are marked (BotState::injected_miss) and never taught to the planner: noReachModelupdate, nohop_pxupdate. The session's move counts still record the miss. Rope grabs never slip, and a move measurement (BotState::measuring) never does. - Attack windows (
Body::attack_window): an air window opens once a flash has triggered (gap, double, up and up-then-side flashes; a blink or a class without air attacks gets a ground window after landing instead), and a ground window opens after every successful landing. A window fires with odds —move_attack_chance(default 1) after a move,ground_attack_chance(default 0) after a landing — and a firing window casts one attack, or two withweave_double_chance. Each cast picks among ready attacks legal in the window (stance), cooldown skills before spam, byweight. A ready skill tagged for only that window's stance fires at least 85% of the time, so ground-only skills get cast on landings.target_attacks_per_min(off at 0) adds the shortfall to each window's odds and subtracts the excess, over a one-minute count. With no player dot the bot waits instead of attacking in place; there is no stationary mode. The defaults reproduce the old behaviour: every move attacks, landings don't. - Single-anchor and roam weaving use the same primitive, bouncing between the drawn platform's ends.
- All gaps are log-normal (
timing.human_between: truncated to the game's input windows, e.g. re-press 0.11–0.26s around 0.17s).
- Session tempo: each bot start draws a pace (±~7%) that also drifts slowly within the session (mean-reverting, minutes-scale). It scales every median; bounds are applied after it, so input windows hold. A draw outside its bounds is redrawn rather than clamped — clamping piled up to a quarter of the samples on the exact bound.
- Key spacing:
HidControllerspaces consecutive key events (downs, ups, andrelease_all) by a drawn gap (~25ms, 10–70ms) — fingers never land at once. Time already spent (a deliberate sleep, the serial round-trip) counts toward it, so timed sequences barely shift; where a key leads the next press (Up before the up-flash re-press), the lead sleep gives the gap back so the jump still lands on time. A press by a different key than the last event follows within 5–20ms (~8ms) instead, with oddschord_gap_chance(default 0.2) — chords and quick rolls; the same key never does. The floor is the Pico's ACK round trip (~4.5ms), and the gap is read when the bot starts. - Holds: lognormal around ~85ms (a bit longer for arrows and modifiers), 45–220ms.
- Reactions (~0.22s, 0.13–0.55s) only where a person reacts to something unexpected: resuming after a pause clears, and noticing a missed landing before re-routing. Planned move chains flow without one. Walking lets go of the direction a moment (~40ms) after the target is seen.
- The Pico firmware polls every 1ms, so it doesn't round every key event to 10ms.
Registered skills have key, kind, cooldown:
attack— attack loop / travel weaving.movement— an attack that also moves the character (a rush, a hold-and-dash). It is cast exactly like anattack(same windows, odds, stance, weight, rate count); the difference is that its effect is measured (Skill effects) so it can be kept off platform ends. Plain attacks aren't measured. Traversal keys (jump, flash, rope lift, teleport) are config keys, not skills.buff— a ground cast, given priority at anchor checkpoints: arriving, the bot settles (two steady reads on a drawn line, up to 1s) and casts every due buff first — ~0.45-0.9s apart so each cast's animation finishes — then the anchor'son_arriveskills and summon. Not standing (still moving, off the drawn lines, no dot) holds the buffs for the next checkpoint. A buff is due once off cooldown and worn off: with aduration, it lasts that long from its last cast. With fewer than two anchors (no checkpoints) due buffs go out whenever the character stands.summon— placed at anchors (below). Allowed at an anchor that lists it inon_arrive, or at any anchor that lists none.
Skills also carry a stance (ground, air, or any — the default)
and a weight (default 1), stored only when not default. The attack
scheduler (windows, above) uses them.
Ropes are a last resort: a climb edge costs rope_penalty extra seconds
(default 5), so it is planned only when no hop path is within that much
cheaper — 30 or more all but bans ropes. walk_cost_factor (default 1)
multiplies every walking leg's cost, so routes and loop orders that walk
less win. Both are on Setup → Tuning → Patrol (patrol|rope_penalty,
patrol|walk_factor). The Home status list and the stop/heartbeat
summaries count the legs run by kind (flash 41 · walk 12 · climb up 3 (1 missed)), so you can see what the bot is really doing.
Some attacks change a flash — they hold the character up or shift where
it lands — and some move the character on the ground.
measure|profile|effects measures both for the kit's movement skills and saves profiles.skill_effects
(rows carry where: air, or ground):
- In the air (flash-jump classes, with flight recording): plain
flashes a few times, then with each skill that isn't ground-only cast
just after the re-press — where an attack window casts it. Per skill:
the landing shift toward the jump (
dx, negative = pulled back), the airtime added (hang) and the peak change (rise). - On the ground (every class): each skill that isn't air-only, cast
standing still after two short taps to face a direction, nothing held —
how a landing window casts it. Per skill: how far it moves the
character the way it faces (
dx).
A skill with a long cooldown is measured once; one on cooldown over 40 s
is skipped. measure|effect|<skill> (a Measure button per skill on the
Skills fold on Setup → Class and the Measure page) measures one skill and keeps the other
skills' rows; a whole run saves each skill as it finishes, so a stopped
run keeps what it finished. The plain-flash baseline is measured once per
run and shared by its skills.
While a flash is planned onto a platform, an air window only picks
skills whose measured air shift fits the room left: the navigator sets
air_slack (px the landing may move back and forward, from the target
platform's ends with a 6 px margin) for each flash leg, and the weave
sets it from the weave bounds. A ground window does the same with the
skills' ground shifts: inside a leg it uses that landing's room, else the
platform room either side of the character (one extra capture, only when
a skill with a measured ground shift is in the kit). Unmeasured skills
aren't held back.
measure|profile|walk (Setup → Measure moves → Walk taps) sweeps
keypresses of 30–260 ms on one drawn platform and saves how far each
carries (profiles.walk_taps: {ms, n, dx, sd}), then times the walking
pace and slide (profiles.walk_speed). Each direction change spends one
discarded tap: the first only turns the character. With a tap table,
Body::nudge_to closes in on a target with the longest tap that doesn't
overshoot, re-reading the dot after each (8 taps at most). Only a rope
grab's takeoff finishes that way — every other leg's takeoff alignment
stays a plain walk, so the taps don't slow the loop. The planner's walking
cost uses the measured pace. Without a sweep, nothing changes.
- Charges: a skill stores up to
chargesuses (default 1); while below the maximum, one returns everycooldownseconds (the timer restarts for the next). With one charge it's a plain cooldown. - Uptime: each cast lasts
durationseconds (0 = until its cooldown ends). Up tochargesinstances of a skill can be out at once; casting one more removes that skill's oldest (as the game does). - One summon per anchor, of any kind: an anchor with a live summon gets none. Otherwise, on arrival, the summon allowed there with the most charges banked (by fraction) is cast.
- Standing only: two position reads a moment apart must agree within 1px and be on a drawn platform (feet up to 6px under a line drawn a little high still count) — never mid-air or on a rope. A character still moving (a landing slide, a movement skill's carry) gets up to 1s to settle; the route is untouched. If it doesn't, the visit is skipped and logged ("still moving", "not on a drawn platform at (x, y)", "player dot not visible"); the anchor stays free, so the next loop's visit tries again.
- The bot can't see summons: placements are timed from casts, cleared
on a map change and when the bot starts. Home shows them ("fountain at
a2 · 38s left · 1/2").
wait_on_arrivalis ignored. - Movement keys (jump, flash, rope-lift) register the same way.
position_jitter_px adds entropy to hand-authored walk legs. There is
no wander state, dwell timer or idle breather — the bot keeps moving.
There are no wall zones: the drawn platforms are the only geometry, and their ends are the boundaries. The graph's walk edges run along drawn platforms, travel flashes measure room against the platform span, and the weave bounces within platform bounds — drawn geometry alone prevents wall-banging. Down-jumps only exist toward drawn platforms below, so the lowest drawn platform is the map's bottom.
Pauses the bot (and fires a Telegram alert if configured) on: window
focus loss, more other players than
allowed_other_players (the alert says how many), a map transfer
(loading blackout) mid-leg, a map no saved entry matches, and the lie
detector (pause_on_lie_detector, default on): once a second the
whole game window is searched for the window's "LIE DETECTOR" title (its
yellow-green header text, matched pixel for pixel against a template
from real captures, anywhere on screen; ~3ms). The bot pauses and
alerts; the mini-game is the player's to solve, and farming resumes once
the window closes. The first sighting is saved to
debug/frames/*_liedetector/. A rune is handled separately (below).
Reaction time: hazards a person at the keyboard would see — other players and the lie detector — are acted on after a human delay (~0.22s for a player's dot, ~0.7s for the lie-detector window, log-normal and bounded). The alert goes out at once; the bot keeps playing until the delay is up, then releases every key and pauses. Hazards that are the bot's own business (loading screens, map identity, focus loss) stop it immediately.
Session limits (Setup → Safety → Session): session_max_minutes
ends the run on its own (alert: "Session limit reached"), the limit drawn
per run within ±15% of the setting. break_every_minutes with
break_minutes schedules rests: after about that much farming (±30%)
the bot goes to PAUSE for about the break length (±40%) — keys released,
shown as "scheduled break", no alert and no long-pause warning — then
resumes where it was. A rune detour takes priority and a break waits
for it. All three are 0 (off) by default.
With stop_when_rune_appears on, a rune marker on the minimap
interrupts farming:
rune_action: solve(default) — RUNE detours onto the rune (the player's glyph centred over it, within 1px) and solves it:- Activate: after a short settle, press
rune_key(defaulty). - Read: watch the game window (a frame every 0.05s, up to 2.5s)
with
rune_arrows::ArrowWatch. Each arrow's direction is read by its shading (down the hue circle from tail to tip — green to red, magenta to cyan, blue to green) and by its silhouette (head, tip, shaft); the two must not disagree, and either decides alone when the other can't tell. Still arrows answer once two frames agree. An arrow whose reads keep changing is spinning: it pauses on, or wiggles across, its answer, so after 1.5s of watching it's answered with the direction it read most. At the deadline the bot answers what it has; a puzzle with no four arrows counts as a failed try (its window saved as anunreadrune event); two of those give up. - Answer like a person: ~0.4-1.4s to take in the puzzle, then the four arrows with uneven ~0.17-0.7s gaps (log-normal, tempo-scaled). Keys stop at once if the game window loses focus.
- Step off to verify: tap toward the side of the platform with more room until the dots no longer overlap — standing on the rune hides it, so only stepping off shows whether it's gone. A rune that shows again failed; one that stays gone (the tracker's 5 reads) was solved and farming resumes, the patrol re-routing from there.
- Failed: the rune is locked for 3s. The bot waits that out plus a human margin with small wiggles (short taps either way, human pauses), walks back on, and tries again — at most 3 tries, then it pauses with an alert.
- Activate: after a short settle, press
rune_action: approach— RUNE detours to a spot beside the rune and pauses there for the solve (dashboard Control page). The spot is on the rune's platform (the drawn row up to 12px under its marker), on whichever side is cheaper to reach, with the player's glyph touching the rune's (TARGET_GAP0px). The route is the navigator's (flash hops, attack windows, one leg per tick); on the rune's platform the bot places itself (tap nudges when a tap table is measured, else a precise walk), then a short tap toward the rune turns it to face it. Arrival is checked on the minimap: the glyphs must touch (0px gap) or overlap — standing on it counts.rune_action: pause— pauses where it stands.- On the way: a move that's cooling (rope lift, teleport) and the only way there is waited out at its takeoff, not counted as "no route"; a route check that finds none is retried twice (a read mid-move or off the drawn lines finds none for a moment).
- Fallback: a detour that fails on the way (no route, 3 missed landings, 90s) goes back to farming and tries again after ~15s (log-normal, 9–30s); the third failure pauses with an alert. No drawn platform under the rune or no room beside it can't be fixed by trying again: those pause at once.
The rune is remembered, not re-read per frame: the player's dot is drawn over the rune's, so standing on it hides it. A rune that vanishes while the player's glyph covers it is still there; it counts as gone after 5 reads without it with the player clearly off it (or once it has stayed covered 30s). Then the bot resumes farming, and the patrol re-routes from wherever the detour ended. A map change forgets the rune.
Telegram hears about hazards only: a hazard pause (other players, an
unrecognized map, a loading screen) and runes (spotted with rune_action: pause, reached, or out of reach). Health checks only reach the log:
every stop (with its reason and the run's summary), a failed start, a
crash (caught; keys released), a lost serial port (which stops the run;
the host reconnects on its own), the watchdog's three
episodes — paused over 60 s, no player dot for 30 s, standing still for
45 s while moving states run (each once per episode) — and a heartbeat
line (map, state, uptime, visits, misses, skips, pauses) every
heartbeat_minutes.