A metatable keeps field names in the running script. TypeScript can check them at build time and emit plain positions instead. For 100 buffer records, that's of the script's memory rather than , with reads about 12 times as fast.
In What does named access cost?, a shared metatable let Lua read preset.intensity from an array or a buffer. The names worked, but the script paid for them twice. The lookup code and the name map used memory before the first record existed, and every read became a function call.
TypeScript gives the names somewhere else to live. The compiler knows each field's position before the script runs, so it can check every name and write the position in its place. The script ends up with the plain array or buffer from that post, and nothing in it knows the names.
Memory for 100 records
The same records with names at runtime or from the build · measured in Second Life
Array
Names through a metatable
preset.intensity
29,735 B of 128 KiB
Names from the compiler
preset[Field.Intensity]
27,582 B of 128 KiB
Buffer
Names through a metatable
preset.intensity
14,111 B of 128 KiB
Names from the compiler
buffer.readu8(preset, Offset.Intensity)
5,273 B of 128 KiB
Bars scale to the largest total
Records, their list, and code
What the names cost at runtime
The striped part is the metatable's lookup code, the shared name map, the stack growth from calling through it, and for buffers a wrapper table per record. The compiled rows are the plain array and buffer, which is the code the build emits. Built from TypeScript, their records took the same memory to the byte.
The same approach works for anything that stays fixed until you deploy again, such as command IDs and debug switches. Every byte the build removes leaves more room within the script's 128 KiB limit. Data that arrives while the script runs is another matter, so the last sections cover the checks that stay in the script.
A const enum gives each field a name in TypeScript and a plain number in the script. A tuple type gives each position its own type, so preset[Field.Intensity] is a number and preset[Field.Mode] is a Mode.
presets.ts
type="steady"|"fade"|"pulse"|"cycle";// Each field's position. The compiler writes the number// wherever the code names a field.constenum{,,,,,,,,,,,,,,}// The type at each position, in the same order as Fieldtype=[:number,:,:number,:number,:number,:number,:number,:number,:number,:boolean,:boolean,:boolean,:boolean,:boolean,];print([.]);// 0.75[.]=0.2;
The compiler writes each member's value wherever the code uses it and leaves no enum object behind. TypeScript arrays start at 0 and Lua tables start at 1, so TypeScriptToLua adds 1. Field.Intensity is 2 in TypeScript and reads preset[3] in Lua. Neither the enum nor the type reaches the script, so the Lua tab has only the two lines that use them.
The Compiled Lua tabs in this post are what the single-script template emits. They're compiler output, not code to edit, and they read worse than Lua written by hand. Calls spread over several lines, loops get placeholder names such as ____, and bare numbers stand where the names were. Trace errors back to TypeScript shows how to get from a line of it back to your source.
That's the array read by position from the earlier post. I ran that post's memory test again with its records built from this TypeScript. In Second Life, 100 of them and their list took 27,232 bytes, exactly what the hand-written array took.
The metatable checked names when a line ran. The compiler checks them before the script exists, and it checks the values too. Each of these lines fails the build with the error below it:
presets.ts
print(preset[Field.Intensitty]);// Property 'Intensitty' does not exist on type 'typeof Field'. Did you mean 'Intensity'?preset[Field.Intensity]="bright";// Type 'string' is not assignable to type 'number'.preset[Field.Mode]="blink";// Type '"blink"' is not assignable to type 'Mode'.
Through the array's metatable, the misspelled name raised an error only when that line ran, and the other two writes stored whatever they were given.
Two things stay your job. The compiler doesn't check that Field and Preset list the fields in the same order, so keep them next to each other. A tuple type also only describes values your own code writes. Data from outside the script, such as a message or a saved record, still needs checking when it arrives.
A buffer record reads each field at a byte offset. Name the offsets and the switch bits with const enums, and the build writes the numbers into each read and write:
presets.ts
// Each field's byte offsetconstenum{=0,=1,=2,=3,=4,=5,=6,=7,=8,=10,}// Each switch's bit in the Flags byteconstenum{=1,=2,=4,=8,=16,}if((.(,.)&.)!==0){print(.(,.) /100);// 0.75}.(,.,.(0.2*100));
The SLua plugin compiles the & test to bit32.btest, since SLua has no bitwise operators. The rest is the plain buffer from the earlier post. In the rerun, 100 records built from this TypeScript took 3,932 bytes with their list, the same as the hand-written buffer.
With names through a metatable, 100 of these records took about of the script's memory. With names from the compiler, they take about . The schema table, the lookup functions, and the stack growth from calling them are gone. So is the one-slot table that wrapped each buffer so it could have a metatable, which cost 48 bytes per record, just over twice the buffer inside it.
Reads got faster as well. Through a metatable, reading a record took about 13 times as long as the same array read by position, and about 12 times as long as the same buffer read by offset.
Reading one record
Check enabled, then read three fields if it's true · measured in Second Life
MedianRangeRatio
Named fields
Lua table
5.4 µs median
Range: 5.1– to 6.1 µs
baseline
Array
Compiled names
4.5 µs median
Range: 4.2– to 4.8 µs
0.8×
Metatable names
58.0 µs median
Range: 53.7– to 63.8 µs
10.7×
Buffer
Compiled names
7.9 µs median
Range: 7.4– to 16.5 µs
1.5×
Metatable names
95.1 µs median
Range: 88.9– to 115.8 µs
17.6×
020406080100120
Time in µs · lower is better
Medians of 8 trials on Agni and Aditi, each over 6 rounds of 50 records. The ratio compares each median with the Lua table's.
The compiled array read was a little faster than a Lua table's named fields. The compiled buffer read took about half again as long as named fields, since each field is a buffer.readu8 call and a conversion.
The compiler checks the offset names, not how you use them. buffer.readu16(preset, Offset.Intensity) compiles, and so does a percentage written without its * 100. Keep each field's read and write together, and generate the offsets from one list, as in Keep the numbers stable. Numbers too big for their field still wrap around, so 256 in a one-byte field reads back as 0.
In TypeScript, the usual way to put names over raw data is a class with getters. TypeScriptToLua compiles each getter into a property descriptor that the class's metatable looks up.
Reading preset.intensity misses the object, calls __index on the class's prototype, and walks the metatables in __TS__DescriptorGet until it finds the getter to call. That's the earlier post's lookup again, with more steps.
In Second Life, the class above had used 4,952 bytes by the time it read its one field. The buffer with names in the earlier post set up its schema and lookups for all 14 fields in 4,579. I timed a class with getters for the four fields the read test uses, over the same buffer. Reading a record through the getters took about 19 times as long as reading it through const enum offsets.
Getters on the same buffer
A read checks enabled, then reads three fields if it's true. A write sets one field · measured in Second Life
MedianRangeRatio
Compiled offsets
Read
8.6 µs median
Range: 8.1– to 9.2 µs
baseline
Write
5.8 µs median
Range: 5.5– to 6.3 µs
baseline
Getters and a setter
Read
160.7 µs median
Range: 150.3– to 193.7 µs
18.7×
Write
58.7 µs median
Range: 54.3– to 63.7 µs
10.1×
050100150200
Time in µs · lower is better
Both read the same buffer layout, one through const enum offsets and one through a class with getters and a setter. Medians of 8 trials on Agni and Aditi, each over 6 rounds of 50 records. Reads are per record, writes per field.
The metatable version in the earlier post read a record in about 95 µs, so the getters were slower still. For a record read in a busy loop, use const enums over an array or a buffer. Keep classes for things there are few of, where readable methods matter more than the bytes.
A command list is the same problem as a field list. Suppose a rotating sign has two functions, setSpeed(value) and setHue(value). Sent as text, a command such as "speed|5" makes the receiver split the message, match "speed" to a function, and parse "5" as a number. When your own code sends the command, a numeric ID and a numeric value do the same job:
OPS holds the handlers in the same order as the enum, and the compiler writes 0 wherever the source says Operation.Speed. The Operation type doesn't validate values at runtime, so this example assumes the ID comes from your own code. Check what arrives at runtime covers messages from other scripts.
In my timing test, receiving "speed|5" took about 4 times as long as receiving a numeric ID.
Receiving one command
Median time per pass · measured in Second Life
MedianRangeRatio
A numeric ID
receiveNumber(0, "5")
20.9 µs median
Range: 19.5– to 21.9 µs
baseline
A text command
receiveText("speed|5")
81.3 µs median
Range: 71.8– to 91.1 µs
3.9×
020406080100
Time in µs · lower is better
Each call looks up the handler, converts the value, and calls the handler. The text command splits the message first and looks the name up in a table. Each time is the median of 8 trials, four per grid, and each trial is the median of 6 rounds that lasted about half a second.
These elapsed times include the simulator's scheduling, so the ratio matters more than the microseconds. For a command that arrives on a click, either approach is fast enough. The numeric ID pays off when commands arrive often, or when shared code receives them as data, such as a sequence player reading saved steps. If commands still arrive as text from a chat channel or an older script, keep the parser there.
Leave out const and TypeScript builds a real object. It maps each name to its number and each number back to its name, so the script can turn 0 back into "Speed". The same example compiles to this:
That object is a name table, the thing the metatable version paid for. A 32-command enum added 4,126 bytes to my test script. The same commands as a const enum added none.
What a regular enum adds
Beyond the same const enum · measured in Second Life
2 commands
310 B
8 commands
1,088 B
32 commands
4,126 B
Compiled code
Enum object
The same controller compiled with enum and with const enum. The const enum added nothing, since its values are written into the code where they're used. The regular enum's object maps each name to its number and each number back to its name.
Use a const enum unless the running script shows the names, in a menu or a chat message. Even then, the labels may be all you need. The example's touch menu keeps its button labels in a list and matches each reply against it to get a const enum number.
A const enum's numbers end up in every script compiled from it. When two scripts exchange them, as they would through link messages, both have to agree on what each number means. The same goes for records one build saves to linkset data and a later build reads. Give shared members explicit values such as Speed = 0, and add new ones at the end. A field inserted in the middle of Field would make every saved record read from the wrong positions.
When a list grows long or several scripts share it, generate the enums from one source. A Bun script can check the list and write a TypeScript module before the compiler runs:
Build order in the example
buntools/generate.ts&&tstl-ptsconfig.json
The example's generator reads a list of fields and a list of menu buttons. It rejects repeated names and more switches than fit in a byte, then writes the Offset and Flag enums with the record's size, and the Button enum with its labels. The package doesn't include a generator, so copy it or write your own. Keep it out of the files the compiler reads, and run it again whenever its list changes, including during watch builds.
If you change a saved layout but still need to load older records, store a version number with each record and keep a way to convert the old ones.
Diagnostics help while you build a script, then take up memory after it ships. Luau already removes some of them. When a local holds a constant and is never reassigned, SLua's compiler knows its value, and an if whose condition is always false compiles to nothing. In Second Life, a script with a diagnostic behind const TRACING = false used exactly as much memory as the same script without it.
If the flag's value isn't known until runtime, the compiler keeps the code. Reading the flag from the object's description left 169 bytes for one diagnostic.
The SLua plugin can set a flag's value in the build configuration instead, so a testing build and a release build can share every source file. Add TRACING to the plugin's define option in tsconfig.json. If its entry has no define field yet, add one and keep your other settings:
The plugin replaces the flag with false and drops the block before Luau sees it. Set it to true and rebuild to bring the diagnostic back. The example keeps both builds side by side. Its tsconfig.debug.json extends tsconfig.json, sets TRACING to true, and writes to its own folder.
The define removed the body of trace, but the call and its message stayed. Ten calls like this, together with the empty trace, kept 856 bytes.
Each call also takes time. In my timing test, a disabled call that joined a number to some text took about 8 times as long per pass as checking a runtime flag first. With ll.GetUsedMemory() in the message, it took about 200 times as long. That call is slow in Second Life. The open-source SLua runtime computes it by adding up every allocation the script can still reach.
What a disabled diagnostic costs per pass
Median time per pass · measured in Second Life
MedianRangeRatio
Loop alone
for _ = 1, n do end
1.6 µs median
Range: 1.5– to 1.8 µs
Loop overhead
Runtime flag first
if (tracing) trace(…)
2.2 µs median
Range: 2.1– to 2.7 µs
baseline
Logger with a number
trace("Speed: " + last)
17.2 µs median
Range: 16.3– to 18.2 µs
7.7×
Logger with a memory reading
trace("Used memory: " + ll.GetUsedMemory())
490.8 µs median
Range: 421.6– to 557.1 µs
218.4×
0200400600
Time in µs · lower is better
The logger returned before printing in every loop. The time goes into building the message and calling the logger. Each time is the median of 8 trials, four per grid, and each trial is the median of 6 rounds that lasted about half a second.
Checking a flag at runtime is cheap by comparison, but it keeps the diagnostic's code in the script. Guard each call with the define instead. With the define set to false, the build removes the guarded calls. Tree shaking then removes trace, since nothing calls it anymore.
The compiler checks the code you wrote. It can't check a message that arrives later from another script, a chat channel, or a person.
Before calling a handler for an incoming message, check that:
The command ID has a handler
The value is a number within that command's range
The sender has permission to run the command, if access is restricted
This receiver takes link messages with the command ID in the numeric field and the value in the text field:
new-script.ts
// Other scripts in the object can send link messages too,// so these commands use numbers from 1000 upconst=1000;// The lowest and highest value each command accepts, by command IDconst=[[0,10],[0,360],]as;.("link_message",(,,,)=>{const=-;const: ((:number) =>void) |undefined=[];const:readonly [number,number] |undefined=[];const=();if (===||===||===) {return; }// Written so NaN fails too, since every comparison with NaN is falseif (!(>=[0] &&<=[1])) return;();});
Link messages come only from scripts in the same object, so this receiver skips the sender check. A chat listener would check who's speaking first. Other scripts in the object can send link messages too, so these commands start at 1,000, and the sender adds FIRST_COMMAND to each command ID.
tonumber returns nil for text that isn't a number, but it reads "nan" as NaN and "inf" as infinity. Both value < min and value > max are false when value is NaN, so value < min || value > max lets it through. The receiver requires the value to be inside the range instead, and NaN fails that check.
What tonumber returned
Checked against a range of 0 to 10 · in Second Life
Text
Result
Loose check
Strict check
"5"
5
let through
let through
" 5 "
5
let through
let through
"0x10"
16
rejected
rejected
"nan"
NaN
let through
rejected
"inf"
infinity
rejected
rejected
"1e999"
infinity
rejected
rejected
"5abc"
nil
rejected
rejected
""
nil
rejected
rejected
The loose check is value < 0 || value > 10, and the strict one is !(value >= 0 && value <= 10). Both reject nil before comparing. "0x10" reads as 16, outside the range, and " 5 " reads as 5. Only NaN separates the two checks. Both grids returned the same results.
Don't replace a failed conversion with 0. That turns malformed input into a valid command. The same range check belongs before any value from outside reaches a buffer field, since a value too big for its field wraps around.
Some facts that look fixed can change after deployment, so only settle what the build knows. If owners can relink the object, find the links at startup. Keep the inventory names an animation call needs. A compiled sequence can number its animations internally and still need each name to start one.
When a script stops with an error, Second Life gives line numbers in the compiled Lua. The single-script template sets "sourceMap": true in compilerOptions, so every build writes a map beside the script, such as new-script.slua.map. It lists the TypeScript line each Lua line came from. The template's flattening step moves code around, so it rewrites the map to match. The map stays on your computer, so it costs the script nothing.
This test script raises an error two calls deep. It catches the error only to say the traceback in owner chat, where the test could record it. An uncaught error names its lines the same way, as lua_script: and a line number.
Trace an error back to TypeScript
Choose a line of the traceback · recorded in Second Life
What the script said in owner chat
error-trace.slua · line 6, what ran
--[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]]--[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]]localfunction(,)ifnot(>=0and<=1)then("glow out of range: "..())end[4] =endlocalfunction(,)for, in()do(,)endendlocal={{2,"pulse",0.75,0.1},{3,"fade",0.5,0.2}}(,0.4)localfunction()ll.(debug.(,2))end(function()return(,1.5)end,)ll.(1)("Result: PASS; traced=true")
error-trace.slua.map
error-trace.ts · line 15, what you wrote
// Compile with @gwigz/slua-create's single-script template, with// "sourceMap": true in compilerOptions. It raises an error on purpose and// says the traceback in owner chat. Second Life reports the compiled// script's line numbers, and error-trace.slua.map turns them into this file's.constenum{,,,,}type=[:number,:string,:number,:number];function(:,:number){if (!(>=0&&<=1)) (`glow out of range: ${}`);[.] =;}function(:[],:number){for (constof) (,);}const:[]=[[2,"pulse",0.75,0.1],[3,"fade",0.5,0.2],];(,0.4);// The test catches the error to say its traceback in owner chat.// Uncaught, Second Life reports the same traceback as a script error.// @gwigz/slua-types gives xpcall's handler a self parameter, which would// receive the message, so this declaration types the handler without one.declarefunction(:()=>void,:(:string)=>void,):boolean;function(:string){.(.(,2));}(()=>(,1.5),);// Let the traceback reach owner chat before the result does..(1);print("Result: PASS; traced=true");
Line 6 of the compiled script comes from line 15 of the TypeScript.
Second Life numbers lines in the compiled script, header comments included. The build wrote the map beside the script, and it lists the TypeScript line each Lua line came from. Both grids reported the same traceback.
While you work, you don't need to look lines up yourself. The template's dev script runs slua-viewer connect, which prints what your script says in Second Life with each lua_script: line matched to its TypeScript line. slua-viewer logs does the same.
An error someone pastes to you later needs the map from the build they're running. Commit the compiled script and its map, or make sure you can rebuild both from the commit you deployed. The example's bun run where then looks up each line of a pasted traceback.
The map is exact to the line, not the column. Several Lua lines can come from one TypeScript line, as the if and the error call do here.
Inspect the emitted .slua after each change, then measure the running script. Removing a function's body can still leave its calls and arguments behind, as the logger showed.
Read memory on the script's first line. Second Life already counts the compiled code there, so comparing the first readings shows the difference between two builds.
new-script.ts
const=.();// The rest of the script.(`Memory at start: ${}`);
Build the script both ways and compare the first readings
Take a second reading once the script's records exist, to see what they hold
Time the paths that run often, such as a timer or a busy listener
To start from nothing, run bunx @gwigz/slua-create and choose the single-script template. Most of this post fits in one small script. The build-time example in the @gwigz/slua repository generates its enums from two lists, builds a release and a debug version side by side, and commits each script with its source map.
example project·gwigz/slua
A light with a touch menu
Touch it to change brightness, glow, and size, or to save and load a preset. Field offsets, switch bits, and buttons are all const enums, and the button labels are the only names the compiled script keeps.
From a fresh clonebun install && bun run build:examples
To reuse the setup in your own script, copy tools/, src/flags.d.ts, both tsconfig files, and the scripts from package.json into a project made with the single-script template. Point luaBundleEntry and include at your own files, and edit the two lists to describe your records and menu.
A shared metatable lets code read arrays and buffers by field name. In the test, 100 records use about of the script's memory with named fields, or as buffers with names.
Data··9 min
Pack records before they fill linkset data
For this 14-field test record, linkset data holds about 660 JSON objects or 6,000 packed buffers. Compare storage savings with script memory and code costs.
Capacity··10 min
Size lists before they round up
An appended 285-entry list in SLua uses 384 slots at 16 bytes each. I test when table.create saves memory and how to size my HUD's paint lists.