implementing pathfinding in uxntal

deluge subpost it is not coming out for a very long time

but it's also kind of its own thing so i decided to split out

algorithm

breadth-first search (BFS): simplest good pathfinding algorithm

it explores the area around the start til it finds the end (solved!) or can't explore any further (no path)

here's an interactive intro from redblobgames

constraints

size

what data structures do we need?

dimensions should be a nice power of two

prototype

i like to use Scratch as a visual playground, nothing beats the interactivity

main downside is that it doesn't translate well to lower-level stuff (datatypes/overflow/bit-operations)

Tbh I think scratch is hard mode - bullno1

Scratch implementation of BFS

visualizations are cool :D

this implementation is slightly different from what i explained earlier, it uses a map of distance rather than direction to give us those pretty gradients
this is less efficient because the path backtracker must compare distances of each neighbouring tile to find the correct direction

now that we understand how BFS works, let's implement it in uxntal!

data structures

queue shall be a circular buffer, we don't even need to worry about the "circular" part because the range is 0-255 like a byte, so our index will just wrap around :D

as for the map, 1 tile is half a byte or 1 nybl, so map shall be a simple nyblmap (map of nybls!)

how about returning the path?

the path is actually calculated backwards, we backtrack from the goal and collect directions along the way

so the last direction we collect is the starting direction -- this is a stack!

fortunately our circular buffer impl can also behave like a stack, and since the node queue is temporary storage that won't be used after a path has been found, we can reuse it to store the path result!

algorithm

16x16 = 256, we can store nodes in a single byte (y is higher nybl and x is lower nybl)

i want the search area to be centered on a tile so make it 15x15

that'll lose us 16 + 15 = 31 tiles but it compensates by allowing us to detect wraparound easily :D

put the search origin in the middle: (8,8)
range is ±7 so 0x1 - 0xf
if we're on the low edge 0x1 and try to move even lower, it'll become 0x0
if we're on the high edge 0xf and try to move even higher, it'll wrap around to 0x0 too!

so detecting wraparound is as simple as checking if either x or y nybl is 0! no need to care which direction we're coming from because either way falls into the 0x0 gutter

%guttered ( index -- guttered? ) {
	DUP #0004 SFT2 #04 SFT MUL #00 EQU
}

this part actually tripped me up twice

original impl: #0f AND #00 EQU ?&skip DUP #f0 AND #00 EQU ?&skip (31 bytes)

if x is 0 then skip; if y is 0 then skip
if x OR y is 0 then skip

round 1: #0f AND ?&no-skip DUP #f0 AND ?&no-skip !&skip &no-skip (24 bytes)

rawraborked pathfinding example, the goal is trapped on the edge of the screen but the path has managed to wrap around the other edge
rawraIsnt this a bug or am I tripping
notchocoh yea that is definitely a bug
notchocwow how did you break it so fast HAHAHA
rawraim a very skilled destroyer of software

if x is not 0 then do not skip; if y is not 0 then do not skip
if x AND y are 0 then skip

see the difference?

yeah, i sure didn't, classic case of confusing AND with OR (which would catch only the corner gutters)

round 2: #0004 SFT2 MUL #00 EQU ?&skip (17 bytes) 🥹

#0004 SFT2 splits yx into their own bytes 0y and x0, then the MUL acts as an integer AND (if either byte is 0 then the result will be 0)

rawraborked pathfinding example, the goal is accessible but it couldn't find a pathnormal pathfinding example, the goal is shifted one tile to the right, showing that previous tile was indeed accessible
rawracursed position lmao
notchocreproducible
notchocborked pathfinding example, a reduced replica of the previous example
notchoc... it happens on the + tooo

rawra: I take my QA testing payment in monksilly currency btw

here's a map of all the "cursed" positions:

map of cursed positions, alternating tiles on x and y axes plus four diagonals

interesting pattern...

#0004 SFT2 splits yx into their own bytes 0y and x0, then the MUL acts as an integer AND (if either byte is 0 then the result will be 0)

let's take the top left diagonal (4,4), index is 44

#0004 SFT2 ( 44 -- 04 40 )
MUL ( 04 40 -- 00 )

huh?? both values are clearly nonzero, so why is the MUL giving us 0?

calculating 0x04 * 0x40 gives us 0x100... which gets truncated to 0x00

let's try the axes, (8,6) -> 68

#0004 SFT2 ( 68 -- 06 80 )
MUL ( 06 80 -- 00 )

0x06 * 0x80 = 0x300 which is again truncated to 0x00

the x nybl is leftshifted by 4 bits, if the result of x*y would have been a multiple of 0x10, then it would also be leftshifted into 0x00 and return a false positive

solution: #0004 SFT2 #04 SFT MUL #00 EQU ?&skip (20 bytes)

solution? un-leftshift the x nybl

so

#0004 SFT2 splits yx into their own bytes 0y and x0,

becomes

#0004 SFT2 #04 SFT splits yx into their own bytes 0y and 0x,

since x and y can never exceed 0x0f, multiplying them will always give a value within 0x100

no more false positives :D

and our pathfinding impl works perfectly!

source on codeberg

some custom syntax here, maybe in a future blogpost...


made with <3 and /.gen.sh