JanetRossini.github.io

Lua, LSL, Blender, Python in Second Life


Project maintained by JanetRossini

Conversion with Tests

Aug 6, 2025 • [luamoverstesting]


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.

Reflection

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.

Pause

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.

Bottom Line

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!