Let’s experiment a bit with converting the LSD mover to SLua. Today I’ll try to do it with tests, which should provide some confidence in refactoring. (Still not seeing enough benefit in an SLua conversion.)
I create a new SLua file, mover-tests-1, which shows how confident I am that this will be the final version, i.e. not at all. I begin with this header:
-- mover-tests-1.lua
local class = require('./class')
local _ = require('./expectations')
-- require('./test-samples')
local vector = require('./vector')
local function tovector(s)
return vector:tovector(s)
end
This brings in my files that let me develop SLua on the Mac with reasonable portability. I think we’ll be adding a new component today, one that provides dummy ll
functions. We’ll see.
I learned yesterday, from SuzannaLinn, that SLua will include a function tovector
that will convert our usual string formatted vector to a vector type, so I rigged up a little function to do the job. It’s in the repo, and we’ll look at it if we need to. It cracks the string with a Lua pattern and then converts the bits to numbers and creates a vector. Nothing special.
I’d like to get started with tests as soon as I can, so I’ll kind of start bottom up. My overall “plan” is to replicate the timer
code from the LSL mover, translating to SLua without much refactoring, and with tests in place where I can figure out how. Then, if it still seems like a good idea, I’ll inline all the code into just a few functions, and then observe how to refactor into something I like. Of course, by the time we’re ready for that, I may well have a different idea. Here goes …
I decide to put the LSL source into the SLua source as a comment:
--[[
timer() {
if ( VehicleType == FollowerType ) {
list details = llGetObjectDetails( EngineKey, [OBJECT_DESC]);
DistanceAroundTrack = llList2Float (details, 0) - CarOffset;
update_position();
} else { // ENGINE, TRAM, SHUNTER
update_position();
update_speed();
}
moveToTrackPosition();
}
]]
function timer()
if VehicleType == FollowerType then
local details = ll.GetObjectDetails(EngineKey, {OBJECT_DESC})
local engine_distance = tonumber(details)
DistanceAroundTrack = engine_distance - CarOffset
update_position()
else -- ENGINE / TRAM / SHUNTER
update_position()
update_speed()
end
move_to_track_position()
end
I haven’t defined any of the globals yet but I’ll have to get to that as soon as I figure out a test. For now, let’s just get some more code laid in.
--[[
update_position() {
DistanceAroundTrack = DistanceAroundTrack + Speed;
while (DistanceAroundTrack < 0) DistanceAroundTrack += ActualTrackLength;
while (DistanceAroundTrack >= ActualTrackLength) DistanceAroundTrack -= ActualTrackLength;
}
]]
function update_position()
DistanceAroundTrack += Speed
while DistanceAroundTrack < 0 do
DistanceAroundTrack += ActualTrackLength
end
while DistanceAroundTrack >= ActualTrackLength do
DistanceAroundTrack -= ActualTrackLength
end
end
Those while
loops should really be replaced with calls to the mod
function. If I recall correctly, LSL doesn’t make that quite as easy as it might be. In any case, we can have a test for this, and it’s good to get to testing as soon as possible so here goes:
local DistanceAroundTrack = 37.5
local ActualTrackLength = 100
local Speed = 21.6
function _:featureUpdatePosition()
_:describe('update_position', function()
_:setup(function()
DistanceAroundTrack = 37.5
ActualTrackLength = 100
Speed = 21.6
end)
_:test("vanilla update", function()
update_position()
_:expect(DistanceAroundTrack).is(59.1, 0.01)
end)
end)
end
Test passes to no one’s surprise. But let’s do some wrap-around ones.
_:test("wrap forward", function()
DistanceAroundTrack = 90
update_position()
_:expect(DistanceAroundTrack).is(11.6, 0.01)
end)
_:test("wrap backward", function()
DistanceAroundTrack = 10
Speed = -23
update_position()
_:expect(DistanceAroundTrack).is(87, 0.01)
end)
These pass. Let’s try mod.
I begin by commenting out the two while loops: I want to see the tests fail. They do.
Replace the code:
function update_position()
DistanceAroundTrack += Speed
DistanceAroundTrack = DistanceAroundTrack % ActualTrackLength
end
Test passes. Should we do this in a one-liner? No, but what about this:
function update_position()
local dist = DistanceAroundTrack + Speed
DistanceAroundTrack = dist % ActualTrackLength
end
Still passes, of course. Let’s do the one-liner to see what we think:
function update_position()
DistanceAroundTrack =
(DistanceAroundTrack + Speed) % ActualTrackLength
end
Tests pass. I think we’ll let this stand.
I am really pretty sure that that code, in LSL, would not work for negative distances. I emphasize “pretty sure”. But I don’t have a convenient testing framework in LSL, and while I have tested mod
manually, I elected to go with the while
solution, because I am sure it works and it generally doesn’t actually run any code. The new SLua code here, however, will be measurably faster, unless I miss my guess. In any case it is simpler and, to me at least, expresses what it’s doing better than the while
loops.
I know that I was thinking not to refactor until I had more code and tests in place, but we do have tests for this little function and there’s no reason not to make it better right away.
Let’s move on. I think move_to_position
will be interesting, so let’s go there. Starting with this:
moveToTrackPosition() {
// Check other llSetObjectDesc calls??
if (RootPos == ZERO_VECTOR) return;
if ( VehicleType == EngineType ) {
llSetObjectDesc((string)DistanceAroundTrack);
}
integer trackIndex = (integer)DistanceAroundTrack;
checkTriggers(trackIndex);
move_and_bank(trackIndex);
}
move_and_bank(integer trackIndex) {
// should check here that RootPos isn't zero
float fraction = DistanceAroundTrack - trackIndex;
vector prevNode = getTrackPosition(trackIndex-1);
vector currNode = getTrackPosition(trackIndex);
vector nextNode = getTrackPosition(trackIndex + 1);
vector movePos = currNode + fraction*(nextNode-currNode) + <0,0,HeightAboveRails>;
rotation trackRot = getTrackRotation(prevNode, currNode, nextNode, fraction);
rotation bankRot = calculateBankRotation(DistanceAroundTrack);
rotation moveRot = bankRot*trackRot;
llSetLinkPrimitiveParamsFast(link(), [PRIM_POSITION, movePos, PRIM_ROTATION, moveRot]);
}
I’ll transliterate first:
function move_to_track_position()
if RootPos == ZERO_VECTOR then return end
if VehicleType == EngineType then
ll.SetObjectDesc(tostring(DistanceAroundTrack))
end
local track_index = DistanceAroundTrack//1
check_triggers()
move_and_bank(track_index)
end
function move_and_bank(track_index)
local z_offset = vector(0,0,HeightAboveRails) -- should be constant
local fraction = DistanceAroundTrack - track_index
local prev_node = get_track_position(track_index - 1)
local curr_node = get_track_position(track_index)
local next_node = get_track_position(track_index + 1)
local move_pos = curr_node + fraction*(next_node - curr_node) + z_offset
local track_rot = get_track_rotation(prev_node, curr_node, next_node, fraction)
local bank_rot = calculate_bank_rotation(DistanceAroundTrack)
local move_rot = bank_rot * track_rot
ll.SetLinkPrimitiveParamsFast(link(), {PRIM_POSITION, move_pos, PRIM_ROTATION, move_rot})
end
Not much to test here but let’s look at the get_track_rotation
and calculate_bank_rotation
. I want to understand what’s going on there.
rotation getTrackRotation(vector previousNode, vector currentNode, vector nextNode, float fraction) {
rotation priorRotation = toLookSoberlyAt(currentNode, previousNode);
rotation nextRotation = toLookSoberlyAt(nextNode, currentNode);
return slerp(priorRotation, nextRotation, fraction);
}
rotation calculateBankRotation(float distanceAroundTrack) {
integer trackIndex = within((integer)distanceAroundTrack,ActualTrackLength);
float fraction = distanceAroundTrack - trackIndex;
rotation prevRot = getRotation(trackIndex-1);
rotation startRot = getRotation(trackIndex);
rotation slerpRot = slerp(prevRot, startRot, fraction);
return slerpRot;
}
rotation getRotation(integer n) {
rotation PackedRot = (rotation)llLinksetDataRead("datakey"+(string)within(n,ActualTrackLength));;
float radians = PackedRot.s*DEG_TO_RAD;
return llEuler2Rot(<-radians,0,0>);
}
Ah. This is kind of nasty. We have two different possible formats in the LSD. Sometimes it’s a vector, with just the node position, but sometimes a rotation (in format) with the fourth element being a bank angle around the x axis. (Maybe it’s always a four-element item: I’ll have to ask Dizzi. But we saw that we cast it to vector
in the position code.
You know what? This article is long enough, and I am ready for a break.
We’ve made a little actual progress, replacing six lines of code with one, which isn’t really backward progress. And we have a few tests showing that our change was righteous.
I think I’m going to like my idea of pasting the LSL code into the SLua as comments, from which I can transliterate to SLua.
I am absolutely gobsmacked by the mixture of naming styles. We really need to pick one. I think I prefer snake_case for general variables and functions, perhaps CapitalCamelCase for classes. However, I think Roblox standard is camelCase for variables. We might want to follow that standard.
For “globals”, definitely a capital letter, probably same as for classes. Naturally there will be no true globals, as they are notoriously slow in SLua. High-level locals, that’s the ticket. And LOUD_SNAKE_CASE seems to be the Linden standard for constants, so we’ll probably follow that.
I’m pleased to have written those few tests, as tests kind of magnetically attract more tests, and tests let me know whether what I’m doing has a chance of working. I like having a chance.
Looking at this transliteration:
function move_and_bank(track_index)
local z_offset = vector(0,0,HeightAboveRails) -- should be constant
local fraction = DistanceAroundTrack - track_index
local prev_node = get_track_position(track_index - 1)
local curr_node = get_track_position(track_index)
local next_node = get_track_position(track_index + 1)
local move_pos = curr_node + fraction*(next_node - curr_node) + z_offset
local track_rot = get_track_rotation(prev_node, curr_node, next_node, fraction)
local bank_rot = calculate_bank_rotation(DistanceAroundTrack)
local move_rot = bank_rot * track_rot
ll.SetLinkPrimitiveParamsFast(link(), {PRIM_POSITION, move_pos, PRIM_ROTATION, move_rot})
end
I see at least three separate things going on here, which would normally make me want to break out separate functions. However, this is in the central timer code, and speed matters too. Tests will help here, not necessarily with timing, but to give us confidence that improvements still work.
I see a lot of bouncing back and forth between the integer and fraction aspects of DistanceAroundTrack, and more references to the global than I’d like to see. We have had at least one defect arise where a variable name conflicted with the global, leading to a very tricky heisenbug.
Referring to the globals less often will be ideal. This test, which I just ginned up, offers a possibility:
_:test("mod one returns fraction", function()
_:expect(3.21%1).is(0.21, 0.01)
end)
If we mod DistanceAroundTrack with one, we’ll get the fraction part. That’s compact enough, and fast enough, that used judiciously we should be able to simplify the code. We’ll see, in due time.
For today, I’ve done enough and have some ideas forming for next time. It’s early days: I figure we have until a ways into 2026 before SLua works everywhere on the main grid. It’s not even in any main grid test regions yet, as far as we know.
I am still not seeing a big benefit to converting the LSL code to SLua. There is some performance to be had, some clarity, but nothing close to a factor of two better. It feels more to me like ten percent better. At that level, it’s probably not worth doing.
We’ll see …
Safe paths!