Log In  

Hit a strange bug tonight in trying to compare some values.
Ultimately, I was being told that 0 != 0 (apparently).
In digging in further, I can reproduce the problem in one line.

> print(8.5333-3.7667-4.7667)
-0

I understand that there can be rounding issues, but -0 is not such a useful return value I think.

Edit: Another strange occurrence
I'm doing time profiling on functions in my code. Call time() at the beginning and end of a test and subtract the results.

In the test, I received 37.4667 - 35.6333 = 1.8333

but if I just ask Pico-8 to compute that in the console I get the proper 1.8334

P#74960 2020-04-19 09:45 ( Edited 2020-04-19 11:42)

All this happens for several reaseons:

  • PICO-8 works internally with binary numbers (as do most computer environments)
  • the PICO-8 numbering system has limited precision (16 bits after the decimal point)
  • PICO-8 never prints more than 4 fractional decimal digits (that’s a design decision that could be reverted)
  • PICO-8 has inconsistent truncating/rounding behaviour when printing decimal numbers (that could be considered a bug)

When you see -0 in your first example, that’s because the number PICO-8 tries to print is -0x.0006, which has exact value -0.000091552734375 and gets truncated to 0. The consistent behaviour here should be to round to -0.0001 instead of truncating to -0.0000. For instance, print(0x7.ffff) will round to 8.

Some will argue that outputting -0 is actually useful; I tend to agree.

When you write 37.4667, PICO-8 is unable to create a number that has this exact value. Instead, it will convert that number to its closest internal representation, which is 0x25.7779 and has exact value 37.466705322265625.

However, when it’s PICO-8 that prints 37.4667, you don’t know that the original number was 0x25.7779. Given that the value comes from time(), it’s more likely that it was actually 0x25.7777, which has exact value 37.4666595458984375.

If you want to double check computations, I recommend looking at the hex representation of numbers:

print(tostr(37.4667,true))
P#74969 2020-04-19 13:55
> print((13.5-7.1) == 6.4)
false

This doesn't make sense to me.

@samhocevar Are you suggesting that when checking an evaluation of a mathematical expression in Pico-8 that the only way to truly verify any answer is to only check hexadecimal values? Because I really don't remember having to jump through that hoop except in extreme cases. The example I'm showing here in this response certainly shouldn't need to have such a convoluted check, surely?

Edit
Well, I don't get it but you're right.
print(tostr(13.5-7.1,true)) == 0x0006.6667
print(tostr(6.4,true)) == 0x0006.6666

So... I guess a number cannot be simply compared against the result of an equation that evaluates to the same number? That seems very unhelpful to not be able to just say (for example)
if (a-b) == 6.4 then

with any hope of it working reliably.

P#74972 2020-04-19 14:06 ( Edited 2020-04-19 15:38)
1

The really important thing here is that 7.1 or 6.4 do not exist in the PICO-8 numbering system (note that this is also true in many other programming languages, such as C++, or even the Python float type where 0.1 + 0.1 + 0.1 does not equal 0.3).

One acceptable workaround when dealing with such inexact values is to use a new eq() function that accounts for the rounding errors. This is more or less what many 3D and game frameworks do:

> function eq(x,y) return abs(x-y)<0.0001 end

> print(eq(13.5 - 7.1, 6.4))
true

Of course, this is not always necessary; for instance, if you only deal with integer numbers and do not perform divisions, you’re perfectly safe. 135 - 71 will always equal 64. So another solution is to ensure you always deal with integer values.

P#74973 2020-04-19 14:37

@samhocevar Well, thanks for the input. I really didn't expect to hit precision weirdness with such simple subtraction (division, I could understand). I guess I misunderstood and thought PICO-8's 16:16 fixed point wasn't as susceptible to this as floating point, until approaching it's smallest representable value of 0.0001. '.1' seemed "large enough" to avoid the issue, I thought.

Looking at the hexadecimal values, I get it. I do. But I have to say, the following makes for a frustrating experience. Getting positive results in certain circumstances could easily lead someone to think that they have working code without knowing the "gotcha" that lies in wait.

print(.6 - .5 == .1)
true

print(.4 - .3 == .1)
false

print(.3 - .2 == .1)
true

print(.2 - .1 == .1)
false

Thanks to your insight, I see now that these values can be "normalized" with

print(tonum(tostr(.4 - .3)) == .1)
true
P#74977 2020-04-19 15:32 ( Edited 2020-04-19 15:40)
:: Felice

I just want to chime in and emphasize what Sam said about representing numbers on computers.

As I'm sure you know, we humans can't write 1/3 in decimal (base 10), because its decimal value 0.33333... repeats infinitely and we don't have infinite pages to write it on. If we want to write it, we can only write an approximation and hope that the reader can interpret what we intended, rather than what we wrote. If they can, that's great, but no matter what, we cannot actually write 1/3 as a single number. It either has to be two numbers with an operator between them, or we have to write in a different base, e.g. base 3, where we could write it very easily as 0.1.

Similarly, computers obviously only work in binary (base 2), so if a computer can't store a number like 1/3 in binary, it has to store the closest possible approximation instead, and then hope that any code using the stored number is capable either of interpreting it as-intended or getting by with a slightly-incorrect value.

We see this on PICO-8 where numbers are stored in 32 bits, with 16 bits being the integer portion -32768..32767, and the remaining 16 bits are the fractional portion represented as (0..65535)/65536. So when you say x = 6.4, you get an integer portion of 6 and a fractional portion of 26214/65536, or 0x6666/0x10000 in hex. This is why printing 6.4 in hex gives you 0x0006.6666. The stored number is actually equal to exactly 6.399993896484375, which print() will round off to 6.4 for your poor human eyes to read more easily, but is not actually 6.4.

We could criticize PICO-8 for this lack of precision, but no computer in the world can actually represent 6.4 in binary, because binary cannot represent 6.4 without the same infinite repetition we see when we try to write 1/3 in decimal. Computers do a lot of trickery and hand-waving to work around this inability, and most of the time we don't notice the problem because of that. PICO-8's 16.16 fixed-point number system is just a little more primitive than, say, a 64-bit floating-point number and causes you to run into the problem a little more often, but it's not a problem that's specific to PICO-8 at all.

You can actually work around this yourself, by having a function to see if two numbers are nearly equal:

function near(a, b, range)
  -- if range is not provided, we use the smallest fractional step
  return abs(a - b) <= (range or 0x.0001)
end

> print(1/3 * 3 == 1)
false
> print(near(1/3 * 3, 1))
true

Note that this error due to imprecise representation can accumulate across multiple operations, or get amplified with multiplication, so you might need to supply a more generous range for some cases:

> print(near(1/3 * 30, 10))
false
> print(near(1/3 * 30, 10, 0.002))
true
P#74980 2020-04-19 16:26
:: Felice

Oh, I just saw that while I was writing that, you posted a response that makes it clear you do understand this stuff. I'll leave my response in case it's useful for others, but yes, I can see you already understand. ;) Sorry!

P#74984 2020-04-19 16:28

@Felice
No worries, Felice; your input is useful and always welcome. :) As you say, other systems make it so easy to ignore (and by extension, forget) things like this; PICO-8, despite its cute interface and simple APIs, takes me by the shirt collar and roughs me up sometimes. I think I also wasn't quite aware of just how many decimals of precision were actually being used by PICO-8 to do its math.

this error...can accumulate across multiple operations

That's a super good thing to keep in mind, thanks.

P#75014 2020-04-19 22:37

[Please log in to post a comment]

Follow Lexaloffle:        
Generated 2021-11-28 14:30:23 | 0.022s | Q:18