Rounding function broken by recent Roblox update

Reproduction Steps
A recent roblox update has caused this function to return an incorrect number. Using this command in the studio output window will replicate the issue:

local Number = 35.84905660377358
local Increment = 0.1
local function RoundNumber(Number, Increment)
return math.floor((Number / Increment) + 0.5) * Increment
end
print(RoundNumber(Number, Increment))

It will return this in the output window :

35.800000000000004

Expected Behavior
The function above should return 35.8 with no remaining decimals past the 8. Doing the function above in a calculator also results in 35.8, and I have further proof that this was caused by a Roblox update since my game World of Magic has not been updated in over a year, and is experiencing this same issue with numbers that are meant to be rounded using the above function.

Actual Behavior
It will return this in the output window :

35.800000000000004

Edit: This is not an issue with my roblox client either, as players of my game have posted pictures of this bug happening as well.
image

Issue Area: Engine
Issue Type: Other
Impact: Moderate
Frequency: Constantly
Date First Experienced: 2022-01-13 21:01:00 (-05:00)
Date Last Experienced: 2022-01-13 13:00:00 (-05:00)

8 Likes

If you want to round the number for display in the text form, you should use string.format:

> print(string.format("%.1f", 35.84905660377358))
35.8

Your RoundNumber function is incorrect in that it doesn’t return 35.8 due to inaccuracies in floating-point arithmetics; if you want to convince yourself this is true, you can run this code:

local function RoundNumber(Number, Increment)
  return math.floor((Number / Increment) + 0.5) * Increment
end

local Number = 35.84905660377358
local Increment = 0.1
print(RoundNumber(Number, Increment), RoundNumber(Number, Increment) == 35.8)

This code will print false as a second output. RoundNumber in this case never returned 35.8, but default number->string conversion would lie about the number it was returning. That was fixed in the update this week (see release notes) - now tostring(n) always returns the correct shortest representation of the input number, that happens to not be 35.8 here.

20 Likes

P.S. If you want a better version of RoundNumber that still returns a number, here it is:

local function RoundNumber(Number, Decimals)
  local Tens = 10^Decimals
  return math.round(Number * Tens) / Tens
end

local Number = 35.84905660377358
local Decimals = 1
print(RoundNumber(Number, Decimals), RoundNumber(Number, Decimals) == 35.8)

This function should return the closest possible number to the correct decimal representation, which the new tostring() implementation should display correctly without extra digits. You will note that this also prints true for the comparison.

12 Likes

It seems it’s not just when the numbers are rounded, I tried to do this math

(1.1 - 1) * 100

The expected result would be 10

But the result I’m getting is 10.000000000000009

I know I can use math.floor to fix this but it’s a little weird all my text was right with rounded numbers now they’re giant numbers :smiley:

unknown

2 Likes

That’s (unfortunately) the way computers work.
Here’s an explanation on it: Why Computers Screw up Floating Point Math - YouTube

Similarly, (1.1 - 1) * 100 is not equal to 10. I understand that the new behavior may be surprising because it brings these precision issues (that were always there!) to light, but it’s better to be correct than nice.

You can evaluate this in other languages and get the same result eg this is Chrome’s JS console:

image

As I said above, you should use %.1f or similar formats if you want to see a given number of digits

4 Likes

Roblox recently gifted us with math.round(n)

local Number = 35.84905660377358
local Increment = 0.1

local function round(number, increment)
	return math.round(number/increment)*increment
end

print(round(Number, Increment)) -- > 35.8
3 Likes

This doesn’t work (prints 35.800000000000004) ; it suffers from the same problem as the original function posted here. The problem is that 0.1 isn’t exactly representable so you aren’t rounding to 0.1, you’re rounding to a different number which in some cases results in the rounding result being off.

1 Like

Strange, prints 35.8 on my machine, but the == 35.8 check returns false.

Double-check that your Studio version is 509

1 Like

Is there a chance we’ll see similar improvements for string.format("%q", String)? Currently the results are pretty hard to read and aren’t always portable due to invalid utf-8 passing through.

These are the main use-cases for %q on Roblox as far as I know:

  1. Errors and output messages. The user may want to have neat error message formatting or need to inspect a problematic string.
  2. Formatting a valid Lua string to be used in code.

My proposal:

  • Newlines should format as "\n" instead of "\
    " (this behavior makes the formatter useless IMO.)
  • Bytes 1-8, 11-12, 14-31, and 127 should use escaped digits instead of this:
  • Escaped digits like "\000\000ABC\000123" could be shortened to "\0\0ABC\000123".
  • Strings with more quotes than apostrophes like "\"" could be shortened to '"'. Although this may make the result less portable if the user intends to use it for a language like JSON.
  • Tab could be represented as "\t" instead of " " because it may be difficult to distinguish from space depending on the context. I’m on the fence about this.
  • Simple utf8 sanitization should be done for bytes 128-255 to decide whether it should be represented as escaped digits or raw text depending on if the codes are corrupt. It could also suppress invisible/nonsense/zalgo/unsafe code combinations this way, but only if it can be done without bloating the implementation. It’s uncommon to use this formatter outside of a debugging/testing context so it should stay relatively simple.
2 Likes

Annoyingly this update to tostring() has created visual errors in several of my games, as for the last decade setting text equal to a decimal variable would always show the actual value of the variable and suddenly it is showing the floating point error.

For example in one game I have code where a variable is incremented by 0.5 and this number is put straight into a textlabel. Now they show the floating point errors.

I realise I may not have been doing the “best practice” all these years by not using string.format() but what worked, worked. Some prewarning of such an update and it’s potential to impact existing games would have been appreciated.

0.5 specifically is representable exactly - so it should be fine to add it repeatedly.

On your broader point - sorry this impacted your game. Unfortunately this is the case where we didn’t anticipate that many games would try to round decimals and expect concise, if incorrect, results. And there’s no way for us to automatically identify games that would be severely impacted because it’s not clear why the stringification is performed.

3 Likes

For future reference, it would have been nice to get some kind of communication that this behaviour is going to change soon, even if it only seems like a small change. People have been relying on the old behaviour of tostring since forever, and AFAIK it has never been formally communicated we should be using anything different.

Granted this isn’t anything game-breaking, but a number of UI elements in Islands have been affected by this. I’m yet to look into BedWars, but I’m sure a huge number of other games are affected in a similar way.

image

7 Likes

Yeah see above - it’s hard to anticipate whether any given change will have an impact. But hey maybe I really want to know about that extra 0.00000000000000000001 tax? :wink:

Since this change seems more broadly applicable than we anticipated, we’ll disable it for now, release an announcement explaining the reason why we’re making this change and possible consequences, and enable in a couple of weeks after that.

8 Likes

We’ve disabled this change on client/server for now but kept the change active in Studio - that will allow testing games in Studio. Announcement will follow on Monday Tuesday.

5 Likes

How would you approach rounding up/down on 0.5 with this upcoming change though? Here is the round function I used prior to this change:

local function round(number, decimalPlaces, roundDown)
	number = number * 10 ^ decimalPlaces
	if roundDown then
		return math.floor(number) / 10 ^ decimalPlaces
	else
		number = tonumber(tostring(number))
		-- cast to string and back to number to prevent floating point errors
		--[[ e.g.:
			local number = 1005 / 10^3 * 10^2 + 0.5 -- 101
			print(number, math.floor(number))
			> 101 100

			local number = 1005 / 10^3 * 10^2 + 0.5 -- 101
			number = tonumber(tostring(number))
			print(number, math.floor(number))
			> 101 101
		]]
		return math.floor(number + 0.5) / 10 ^ decimalPlaces
	end
end

The roundDown flag here determines if numbers like 1.999 should be forced down to 1, or if number should be rounded down if the decimal is 0, 1, 2, 3, 4.

This function worked fine for calls like

round(1.005, 2, false) --> 1.01

before this change, but after this change it will incorrectly round down to 1.00 because tostring(100.5 * 10^3) gives 100.49999999999999 (versus before where it gave 100.5).

String format is not acceptable as it does not give control over rounding up/down (string.format("%0.2f", 1.005) becomes 1.00 instead of 1.01), and there are cases where number format is desired.
math.round is also not a viable alternative as it rounds away from zero.

1 Like

You can always replace tostring(v) with string.format("%.14g", v) to get the old behavior - that’s what tostring used to do. However I do want to point out that your function does not have a very robust definition.

For example, you expect 1.005 to round to 1.01 when rounding to nearest. However, the decimal expansion of 1.005 when represented as a double-precision value is something along the lines of 1.0049999999999998934 - so it should round down. In fact that’s why string.format returns 1.00.

If you want a little bit of slop to bias numbers that are “almost” at a half-way point, you can add a small bias manually, eg:

local function round(number, decimalPlaces, roundDown)
	number = number * 10 ^ decimalPlaces
	if roundDown then
		return math.floor(number) / 10 ^ decimalPlaces
	else
		return math.round(number * (1 + 2e-16)) / 10 ^ decimalPlaces
	end
end

(similarly you can apply the same bias before calling string.format)

Which is at least explicit about trying to get numbers that are very close to the half-way boundary to go in a different direction. But it’s important to recognize that it’s not the case that previously the function was “right” and now it is “wrong” - tonumber(tostring(v)) didn’t “clean up” floating point inconsistensies somehow, that’s impossible, it simply threw away some bits from the input number.

P.S. It might be more consistent to then apply a similar bias to the “round down behavior” btw, because values like 1.9999…98 (sorry I didn’t count the exact 9’s) would round to 1 when roundDown is true, which is probably as unexpected as 1.499…98 rounding down when roundDown is false.

3 Likes

Can I ask what the purpose of this change is? I can’t possibly imagine that the number of people trying to convert and display exact decimals comes close to exceeding the number of people happily converting and displaying clean numbers.

From my end as a developer this seems like it’s just needlessly complicating my life by changing something that’s always just worked cleanly.

[rant] Between this and the decision that “only power users use F6 testing mode so let’s just get rid of it” I’m pretty fed up with how we’re being handed these changes that are making our jobs more difficult. [/rant]

4 Likes

what if my game prints 39,008.0 except 39,008