Rounding function broken by recent Roblox update

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

The purpose is very simple: we’ve had many cases of people being very confused as to why print() prints two numbers identically but they aren’t equal. Fundamentally the fact that tostring() returns the same string for two different numbers is a bug, while convenient at times.

It’s incorrect to think that previously tostring was somehow able to print clean numbers. That’s not the case, it’s just that by virtue of discarding a couple of bits of precision you would sometimes not see the difference. Consider this program that merrily counts until 54.1 until the error becomes too high and becomes visible:

local r = 0 for i=1,1000 do r += 0.1 print(string.format("%.14g", r)) end

I understand that this may be inconvenient, but I don’t think it makes the current tostring behavior any less wrong.

Note that after this change, we will guarantee that tonumber(tostring(v)) is equal to v. This is an important guarantee known as “round-tripping”, and not having this guarantee means that if you try to serialize numbers to a string in text form, you don’t get the same result back which is bad if you ever want to do computations off these numbers. In programming languages, correctness and determinism is important.

You will also find that probably any modern language will have the same behavior as the new behavior of tostring, eg try printing 1.1-1 in JS, Python, Swift, Rust.

10 Likes

Just checked - I’m on 509. And now it prints 35.800000000000004. I guess I wasn’t updated lol

1 Like

and not just that, in the STUDIO terrain modification tool this problem comes with 13 ZEROS

How i found it-
When i was busy making a regular shooting game with some terrain, when I wanted to modify a mountain, i clicked on “grow” and set the strength to 0.6, before that I found that there were 2 glitches, 0.600000000000001 (after 0.6) and 0.700000000000001, justt after the previous glitched one, then it went normal again

Video proof-

mac user here

system specs (if needed)

Did this update affect the engine so that part orientations would be getting rounded off?

We have a part with orientation 89.95 but it’s being set to 90 in Play Solo / Live Game.

In Studio:

In-Game:

(Notice the grey wall at the bottom has lots of gaps)

2 Likes

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.