From 83a28e97f95eac22d3841418d7c7e1631a9287b6 Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Thu, 4 Jun 2026 17:08:40 +0000 Subject: [PATCH 1/2] Add rulesets for generating table based way of passing parameters to functions requiring lists. --- Sources.cmake | 2 + VM/include/llfluent_builder.h | 51 ++++ VM/src/llfluent_builder.cpp | 384 ++++++++++++++++++++++++++ build-cmd.sh | 2 +- tests/SLConformance.test.cpp | 54 ++++ tests/conformance/llfluentbuilder.lua | 232 ++++++++++++++++ 6 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 VM/include/llfluent_builder.h create mode 100644 VM/src/llfluent_builder.cpp create mode 100644 tests/conformance/llfluentbuilder.lua diff --git a/Sources.cmake b/Sources.cmake index 7736c0a9..81ea185e 100644 --- a/Sources.cmake +++ b/Sources.cmake @@ -429,6 +429,8 @@ target_sources(Luau.VM PRIVATE VM/src/llprim.cpp VM/src/llprim.h VM/src/llprim_set_primitive_params.inl + VM/src/llfluent_builder.cpp + VM/include/llfluent_builder.h VM/src/lyieldable.cpp VM/src/lstrbuf.cpp VM/src/lyieldstrlib.h diff --git a/VM/include/llfluent_builder.h b/VM/include/llfluent_builder.h new file mode 100644 index 00000000..7c8c8dbd --- /dev/null +++ b/VM/include/llfluent_builder.h @@ -0,0 +1,51 @@ +#pragma once +#include + +struct lua_State; + +struct FluentParamDescriptor +{ + const char* name; // effective property name (pretty alias or strict fallback) + char semantic; // 'i','f','s','v','r','b','a','k' + int tag; // PSYS_* constant integer value +}; + +struct FluentFlagDescriptor +{ + const char* name; // boolean property name, e.g. "interp_color" + int mask; // bitmask, e.g. 0x01 + int field_tag; // tag of the integer field holding the bits, e.g. 0 for "flags" +}; + +// Opaque handle — definition lives in llfluent_builder.cpp. +struct FluentBuilderDef; + +// Build a FluentBuilderDef from an array of descriptors. +// Deep-copies all strings. Caller owns the returned pointer (process lifetime expected). +// Descriptors are sorted by tag internally; caller order does not matter. +FluentBuilderDef* fluent_builder_def_build( + const char* apply_fn_name, // e.g. "ParticleSystem" + const char* apply_link_fn_name, // e.g. "LinkParticleSystem" + const FluentParamDescriptor* descs, + size_t count +); + +// Attach flag-bit boolean properties to an existing def. +// Each descriptor maps a property name to a bitmask within the integer field at field_tag. +// Deep-copies all strings. Call after fluent_builder_def_build(). +void fluent_builder_def_add_flags( + FluentBuilderDef* def, + const FluentFlagDescriptor* descs, + size_t count +); + +// Register a fluent builder module into L. +// Creates a global table named module_name containing a type table named type_name. +// def must remain valid for the lifetime of L. +// Call this on mConstsState before luaL_sandbox(), alongside luaL_register_noclobber. +void slua_open_fluent_builder( + lua_State* L, + const char* module_name, // e.g. "llparticle" + const char* type_name, // e.g. "ParticleParams" + const FluentBuilderDef* def +); diff --git a/VM/src/llfluent_builder.cpp b/VM/src/llfluent_builder.cpp new file mode 100644 index 00000000..56087fd2 --- /dev/null +++ b/VM/src/llfluent_builder.cpp @@ -0,0 +1,384 @@ +#define llfluent_builder_c + +#include "lua.h" +#include "lcommon.h" +#include "lualib.h" +#include "llsl.h" +#include "llfluent_builder.h" + +#include +#include +#include +#include + +struct FluentBuilderDef +{ + std::string apply_fn_name; + std::string apply_link_fn_name; + std::vector descs; // sorted by tag + std::vector names; // storage for descriptor name strings + std::unordered_map name_to_index; + + std::vector flag_descs; + std::vector flag_names; + std::unordered_map flag_name_to_index; +}; + +FluentBuilderDef* fluent_builder_def_build( + const char* apply_fn_name, + const char* apply_link_fn_name, + const FluentParamDescriptor* descs, + size_t count +) +{ + auto* def = new FluentBuilderDef; + def->apply_fn_name = apply_fn_name; + def->apply_link_fn_name = apply_link_fn_name; + + // Copy descriptors and deep-copy name strings. + def->descs.resize(count); + def->names.resize(count); + for (size_t i = 0; i < count; ++i) + { + def->names[i] = descs[i].name; + def->descs[i] = descs[i]; + def->descs[i].name = def->names[i].c_str(); + } + + // Sort by tag for deterministic serialization order. + // names and descs stay in sync via index sort. + std::vector order(count); + for (size_t i = 0; i < count; ++i) order[i] = i; + std::sort(order.begin(), order.end(), [&](size_t a, size_t b) { + return def->descs[a].tag < def->descs[b].tag; + }); + + std::vector sorted_descs(count); + std::vector sorted_names(count); + for (size_t i = 0; i < count; ++i) + { + sorted_names[i] = std::move(def->names[order[i]]); + sorted_descs[i] = def->descs[order[i]]; + sorted_descs[i].name = sorted_names[i].c_str(); + } + def->descs = std::move(sorted_descs); + def->names = std::move(sorted_names); + + // Build name-to-index lookup. + for (int i = 0; i < (int)count; ++i) + def->name_to_index[def->descs[i].name] = i; + + return def; +} + +void fluent_builder_def_add_flags( + FluentBuilderDef* def, + const FluentFlagDescriptor* descs, + size_t count +) +{ + def->flag_descs.resize(count); + def->flag_names.resize(count); + for (size_t i = 0; i < count; ++i) + { + def->flag_names[i] = descs[i].name; + def->flag_descs[i] = descs[i]; + def->flag_descs[i].name = def->flag_names[i].c_str(); + def->flag_name_to_index[def->flag_names[i]] = (int)i; + } +} + +// __newindex: validate type by semantic char, then rawset. +static int fluent_builder_newindex(lua_State* L) +{ + luaL_checktype(L, 1, LUA_TTABLE); + const char* key = luaL_checkstring(L, 2); + // value is at index 3 + + const auto* def = (const FluentBuilderDef*)lua_tolightuserdata(L, lua_upvalueindex(1)); + + // Flag boolean properties: virtual aliases over the backing integer field. + auto flag_it = def->flag_name_to_index.find(key); + if (flag_it != def->flag_name_to_index.end()) + { + const FluentFlagDescriptor& fdesc = def->flag_descs[flag_it->second]; + + // Find the backing field name by tag (done once per call; small linear scan). + const char* field_name = nullptr; + for (const auto& d : def->descs) + if (d.tag == fdesc.field_tag) { field_name = d.name; break; } + + lua_rawgetfield(L, 1, field_name); + int cur = lua_isnumber(L, -1) ? (int)lua_tointeger(L, -1) : 0; + lua_pop(L, 1); + + int next; + if (lua_isnoneornil(L, 3)) + { + // nil clears the bit + next = cur & ~fdesc.mask; + } + else + { + if (!lua_isboolean(L, 3) && lua_type(L, 3) != LUA_TNUMBER) + luaL_typeerrorL(L, 3, "boolean or integer"); + bool set = lua_isboolean(L, 3) ? (bool)lua_toboolean(L, 3) : (lua_tointeger(L, 3) != 0); + next = set ? (cur | fdesc.mask) : (cur & ~fdesc.mask); + } + lua_pushinteger(L, next); + lua_rawsetfield(L, 1, field_name); + return 0; + } + + auto it = def->name_to_index.find(key); + if (it == def->name_to_index.end()) + luaL_errorL(L, "unknown property '%s'", key); + + // nil clears the property + if (lua_isnoneornil(L, 3)) + { + lua_pushnil(L); + lua_rawsetfield(L, 1, key); + return 0; + } + + char sem = def->descs[it->second].semantic; + switch (sem) + { + case 'i': + luaL_checkinteger(L, 3); + break; + case 'f': + luaL_checknumber(L, 3); + break; + case 's': + if (lua_type(L, 3) != LUA_TSTRING) + luaL_typeerrorL(L, 3, "string"); + break; + case 'v': + luaL_checkvector(L, 3); + break; + case 'r': + luaSL_checkquaternion(L, 3); + break; + case 'b': + if (!lua_isboolean(L, 3) && lua_type(L, 3) != LUA_TNUMBER) + luaL_typeerrorL(L, 3, "boolean or integer"); + break; + case 'a': + case 'k': + { + int t = lua_type(L, 3); + if (t != LUA_TSTRING && !(t == LUA_TUSERDATA && lua_userdatatag(L, 3) == UTAG_UUID)) + luaL_typeerrorL(L, 3, "string or uuid"); + break; + } + default: + luaL_errorL(L, "internal: bad builder semantic char '%c'", sem); + } + + lua_rawsetfield(L, 1, key); + return 0; +} + +// apply(self [, linkNumber]) +static int fluent_builder_apply(lua_State* L) +{ + luaL_checktype(L, 1, LUA_TTABLE); + + const auto* def = (const FluentBuilderDef*)lua_tolightuserdata(L, lua_upvalueindex(1)); + bool has_link = !lua_isnoneornil(L, 2); + int link_num = 0; + if (has_link) + link_num = luaL_checkinteger(L, 2); + + // Build flattened rules list in tag order. + lua_newtable(L); + int list = lua_gettop(L); + int idx = 0; + + for (const auto& desc : def->descs) + { + lua_rawgetfield(L, 1, desc.name); + if (lua_isnil(L, -1)) + { + lua_pop(L, 1); + continue; + } + // append tag then value + lua_pushinteger(L, desc.tag); + lua_rawseti(L, list, ++idx); + + // For 'b' semantic, booleans must be coerced to integer for the wire format. + if (desc.semantic == 'b' && lua_isboolean(L, -1)) + lua_pushinteger(L, lua_toboolean(L, -1)); + else + lua_pushvalue(L, -1); + lua_rawseti(L, list, ++idx); + + lua_pop(L, 1); // pop the rawgetfield result + } + + // Dispatch via ll.* global table. + lua_rawgetfield(L, LUA_BASEGLOBALSINDEX, "ll"); + if (has_link) + { + lua_rawgetfield(L, -1, def->apply_link_fn_name.c_str()); + lua_pushinteger(L, link_num); + lua_pushvalue(L, list); + lua_call(L, 2, 0); + } + else + { + lua_rawgetfield(L, -1, def->apply_fn_name.c_str()); + lua_pushvalue(L, list); + lua_call(L, 1, 0); + } + return 0; +} + +// new([table]): create empty table, attach metatable, and optionally bulk-initialize +// from an initializer table by routing each key/value through __newindex. +// Upvalue 1: metatable +static int fluent_builder_new(lua_State* L) +{ + bool has_init = lua_istable(L, 1); + + // Create the new instance and attach metatable. + lua_createtable(L, 0, 0); + lua_pushvalue(L, lua_upvalueindex(1)); + lua_setmetatable(L, -2); + // instance is now on top + + if (!has_init) + return 1; + + int instance_idx = lua_gettop(L); // absolute index of instance + const auto* def = (const FluentBuilderDef*)lua_tolightuserdata(L, lua_upvalueindex(2)); + + // Fetch __newindex from the metatable. + lua_pushvalue(L, lua_upvalueindex(1)); // push mt + lua_getfield(L, -1, "__newindex"); // push __newindex closure + lua_remove(L, -2); // remove mt + + int fn_idx = lua_gettop(L); // absolute index of __newindex closure + + // Iterate the initializer table (arg 1). + lua_pushnil(L); + while (lua_next(L, 1) != 0) + { + // Stack: ..., instance(instance_idx), fn(fn_idx), key(top-1), value(top) + int key_idx = lua_gettop(L) - 1; + int val_idx = lua_gettop(L); + + // Skip unknown keys silently (type errors on known keys still propagate). + if (lua_type(L, key_idx) == LUA_TSTRING) + { + const char* k = lua_tostring(L, key_idx); + bool known = def->flag_name_to_index.count(k) || def->name_to_index.count(k); + if (known) + { + lua_pushvalue(L, fn_idx); + lua_pushvalue(L, instance_idx); + lua_pushvalue(L, key_idx); + lua_pushvalue(L, val_idx); + lua_call(L, 3, 0); + } + } + + // Pop value; leave key on top for lua_next. + lua_pop(L, 1); + } + + // Pop the __newindex closure; leave instance on stack. + lua_pop(L, 1); + return 1; +} + +// __index: check flag properties first, then fall through to the metatable for methods. +// Upvalue 1: metatable (for new/apply fallthrough) +// Upvalue 2: def* (for flag lookup) +static int fluent_builder_index(lua_State* L) +{ + luaL_checktype(L, 1, LUA_TTABLE); + const char* key = luaL_checkstring(L, 2); + + const auto* def = (const FluentBuilderDef*)lua_tolightuserdata(L, lua_upvalueindex(2)); + + auto flag_it = def->flag_name_to_index.find(key); + if (flag_it != def->flag_name_to_index.end()) + { + const FluentFlagDescriptor& fdesc = def->flag_descs[flag_it->second]; + + const char* field_name = nullptr; + for (const auto& d : def->descs) + if (d.tag == fdesc.field_tag) { field_name = d.name; break; } + + int cur = 0; + if (field_name) + { + lua_rawgetfield(L, 1, field_name); + if (lua_isnumber(L, -1)) + cur = (int)lua_tointeger(L, -1); + lua_pop(L, 1); + } + lua_pushboolean(L, (cur & fdesc.mask) != 0); + return 1; + } + + // Fall through to metatable for new(), apply(), etc. + lua_rawgetfield(L, lua_upvalueindex(1), key); + return 1; +} + +void slua_open_fluent_builder( + lua_State* L, + const char* module_name, + const char* type_name, + const FluentBuilderDef* def +) +{ + int top = lua_gettop(L); + + // module table + lua_newtable(L); + int module_idx = lua_gettop(L); + + // type metatable + lua_newtable(L); + int mt = lua_gettop(L); + + // __newindex — def* as light userdata upvalue + lua_pushlightuserdata(L, (void*)def); + lua_pushcclosurek(L, fluent_builder_newindex, "__newindex", 1, nullptr); + lua_setfield(L, mt, "__newindex"); + + // __index closure: flag reads first, then method fallthrough via metatable. + // new() and apply() are set on mt after this, but the closure holds a reference + // to the metatable table object itself (not a snapshot), so it sees them. + lua_pushvalue(L, mt); // upvalue 1: metatable + lua_pushlightuserdata(L, (void*)def); // upvalue 2: def* + lua_pushcclosurek(L, fluent_builder_index, "__index", 2, nullptr); + lua_setfield(L, mt, "__index"); + + // new() — upvalue 1: metatable, upvalue 2: def* (for unknown-key skipping) + lua_pushvalue(L, mt); + lua_pushlightuserdata(L, (void*)def); + lua_pushcclosurek(L, fluent_builder_new, "new", 2, nullptr); + lua_setfield(L, mt, "new"); + + // apply() — def* as light userdata upvalue + lua_pushlightuserdata(L, (void*)def); + lua_pushcclosurek(L, fluent_builder_apply, "apply", 1, nullptr); + lua_setfield(L, mt, "apply"); + + lua_setreadonly(L, mt, true); + + // module_table[type_name] = metatable + lua_setfield(L, module_idx, type_name); + + lua_setreadonly(L, module_idx, true); + lua_setglobal(L, module_name); + + LUAU_ASSERT(lua_gettop(L) == top); +} diff --git a/build-cmd.sh b/build-cmd.sh index 3146ed2f..df076335 100755 --- a/build-cmd.sh +++ b/build-cmd.sh @@ -37,7 +37,7 @@ mkdir -p "$stage/lib/release" pushd "$top" pushd "VM/include" - cp -v lua.h luaconf.h lualib.h llsl.h "$stage/include/luau/" + cp -v lua.h luaconf.h lualib.h llsl.h llfluent_builder.h "$stage/include/luau/" popd pushd "Compiler/include" cp -v luacode.h "$stage/include/luau/" diff --git a/tests/SLConformance.test.cpp b/tests/SLConformance.test.cpp index e52fd325..bc4cddec 100644 --- a/tests/SLConformance.test.cpp +++ b/tests/SLConformance.test.cpp @@ -3,6 +3,7 @@ #include "lualib.h" #include "luacode.h" #include "luacodegen.h" +#include "llfluent_builder.h" #include "Luau/BuiltinDefinitions.h" #include "Luau/DenseHash.h" @@ -1486,4 +1487,57 @@ TEST_CASE("Memory hygiene") runConformance("memory_hygiene.lua"); } +TEST_CASE("llfluentbuilder") +{ + // Minimal descriptors that exercise every semantic type and flag bits, + // with no dependency on particle or any other SL-specific API. + static const FluentParamDescriptor kTestDescs[] = { + {"flags", 'i', 0}, // integer — also backing field for flags + {"amount", 'f', 1}, // float + {"color", 'v', 2}, // vector + {"name", 'k', 3}, // key (string | uuid) + {"count", 'i', 4}, // integer (second int property) + }; + static const FluentFlagDescriptor kTestFlagDescs[] = { + {"active", 0x1, 0}, // bit 0 of flags + {"looping", 0x2, 0}, // bit 1 of flags + }; + + // Build the def once; it has process lifetime so static is fine here. + static FluentBuilderDef* s_def = []() { + auto* d = fluent_builder_def_build( + "TestApply", "LinkTestApply", + kTestDescs, std::size(kTestDescs) + ); + fluent_builder_def_add_flags(d, kTestFlagDescs, std::size(kTestFlagDescs)); + return d; + }(); + + runConformance("llfluentbuilder.lua", nullptr, [](lua_State* L) { + // Mock ll.TestApply / ll.LinkTestApply so apply() can be verified. + static const luaL_Reg test_ll_lib[] = { + {"TestApply", [](lua_State* L) -> int { + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushvalue(L, 1); + lua_setglobal(L, "captured_apply_rules"); + lua_pushnil(L); + lua_setglobal(L, "captured_apply_link"); + return 0; + }}, + {"LinkTestApply", [](lua_State* L) -> int { + luaL_checkinteger(L, 1); + luaL_checktype(L, 2, LUA_TTABLE); + lua_pushvalue(L, 1); + lua_setglobal(L, "captured_apply_link"); + lua_pushvalue(L, 2); + lua_setglobal(L, "captured_apply_rules"); + return 0; + }}, + {nullptr, nullptr} + }; + luaL_register_noclobber(L, LUA_LLLIBNAME, test_ll_lib); + slua_open_fluent_builder(L, "testmodule", "TestObj", s_def); + }); +} + TEST_SUITE_END(); diff --git a/tests/conformance/llfluentbuilder.lua b/tests/conformance/llfluentbuilder.lua new file mode 100644 index 00000000..1314613a --- /dev/null +++ b/tests/conformance/llfluentbuilder.lua @@ -0,0 +1,232 @@ +-- Test suite for the generic fluent builder runtime (llfluent_builder.cpp). +-- +-- Uses a mock "testmodule.TestObj" registered by the SLConformance test harness: +-- fields: flags (i,0), amount (f,1), color (v,2), name (k,3), count (i,4) +-- flags: active (0x1, field 0), looping (0x2, field 0) +-- apply: ll.TestApply(rules) / ll.LinkTestApply(link, rules) +-- +-- No particle system or SL-specific constants anywhere in this file. + +local TestObj = testmodule.TestObj + +-- ── Construction ──────────────────────────────────────────────────────────── + +-- new() returns a fresh table with the metatable attached. +local obj = TestObj.new() +assert(type(obj) == "table") + +-- setmetatable works identically to new() — both are valid. +local obj2 = setmetatable({}, TestObj) +assert(type(obj2) == "table") + +-- ── Property round-trips ───────────────────────────────────────────────────── + +-- float +obj = TestObj.new() +obj.amount = 0.5 +assert(obj.amount == 0.5) + +-- vector +obj = TestObj.new() +obj.color = vector(1, 0.5, 0) +assert(obj.color == vector(1, 0.5, 0)) + +-- integer +obj = TestObj.new() +obj.count = 7 +assert(obj.count == 7) + +-- key (accepts string) +obj = TestObj.new() +obj.name = "00000000-0000-0000-0000-000000000000" +assert(obj.name == "00000000-0000-0000-0000-000000000000") + +-- key (accepts uuid) +obj = TestObj.new() +local id = uuid("00000000-0000-0000-0000-000000000001") +obj.name = id +assert(obj.name == id) + +-- raw integer flags field +obj = TestObj.new() +obj.flags = 42 +assert(obj.flags == 42) + +-- ── Unset properties read as nil ───────────────────────────────────────────── + +obj = TestObj.new() +assert(obj.amount == nil) +assert(obj.color == nil) +assert(obj.count == nil) +assert(obj.name == nil) + +-- ── Type errors on assignment ──────────────────────────────────────────────── + +local function assert_error(fn, snippet) + local ok, err = pcall(fn) + assert(not ok, "expected error for: " .. snippet) +end + +obj = TestObj.new() +assert_error(function() obj.amount = "notanumber" end, "amount = string") +assert_error(function() obj.amount = vector(1,0,0) end, "amount = vector") +assert_error(function() obj.color = 1.0 end, "color = number") +assert_error(function() obj.color = "notavec" end, "color = string") +-- Note: luaL_checkinteger accepts any number (floats are truncated), so only +-- non-number types are rejected for integer fields. +assert_error(function() obj.count = "three" end, "count = string") +assert_error(function() obj.count = vector(0,0,0) end, "count = vector") + +-- ── Unknown property errors ────────────────────────────────────────────────── + +assert_error(function() obj.nonexistent = 1 end, "unknown property") + +-- ── Flag properties: write ─────────────────────────────────────────────────── + +-- Setting a flag true ORs the bit into flags. +obj = TestObj.new() +obj.active = true +assert(obj.flags == 0x1) + +obj = TestObj.new() +obj.looping = true +assert(obj.flags == 0x2) + +-- Both flags together. +obj = TestObj.new() +obj.active = true +obj.looping = true +assert(obj.flags == 0x3) + +-- Setting false clears the bit. +obj = TestObj.new() +obj.flags = 0x3 +obj.active = false +assert(obj.flags == 0x2) + +-- Nil also clears the bit. +obj = TestObj.new() +obj.flags = 0x3 +obj.looping = nil +assert(obj.flags == 0x1) + +-- Integer 0 clears the bit; non-zero sets it. +obj = TestObj.new() +obj.active = 1 +assert(obj.flags == 0x1) +obj.active = 0 +assert(obj.flags == 0x0) + +-- ── Flag properties: read ──────────────────────────────────────────────────── + +obj = TestObj.new() +obj.flags = 0x1 +assert(obj.active == true) +assert(obj.looping == false) + +obj.flags = 0x3 +assert(obj.active == true) +assert(obj.looping == true) + +obj.flags = 0x0 +assert(obj.active == false) +assert(obj.looping == false) + +-- ── Flag + raw flags coexistence ───────────────────────────────────────────── + +-- Writing raw flags and reading back via boolean alias. +obj = TestObj.new() +obj.flags = 0x2 +assert(obj.active == false) +assert(obj.looping == true) + +-- Writing via alias reflects in raw flags. +obj = TestObj.new() +obj.flags = 0x0 +obj.active = true +assert(obj.flags == 0x1) + +-- Flag type error: only boolean or integer accepted. +assert_error(function() obj.active = "yes" end, "active = string") +assert_error(function() obj.active = vector(1,0,0) end, "active = vector") + +-- ── new({...}) initializer table ───────────────────────────────────────────── + +obj = TestObj.new({ + amount = 1.5, + color = vector(0, 1, 0), + count = 3, + active = true, +}) +assert(obj.amount == 1.5) +assert(obj.color == vector(0, 1, 0)) +assert(obj.count == 3) +assert(obj.active == true) +assert(obj.flags == 0x1) + +-- Unknown keys in the initializer are silently ignored (no error). +obj = TestObj.new({ + amount = 2.0, + unknown_key = "ignored", + another_one = 99, +}) +assert(obj.amount == 2.0) + +-- ── apply() dispatch ───────────────────────────────────────────────────────── + +-- apply() with no argument calls ll.TestApply(rules). +captured_apply_link = "sentinel" +captured_apply_rules = nil +obj = TestObj.new() +obj.amount = 0.25 +obj:apply() +assert(captured_apply_link == nil, "no-arg apply should not set link") +assert(captured_apply_rules ~= nil, "rules table should be captured") + +-- apply(linkNum) calls ll.LinkTestApply(linkNum, rules). +captured_apply_link = nil +captured_apply_rules = nil +obj:apply(3) +assert(captured_apply_link == 3, "link number should be captured") +assert(captured_apply_rules ~= nil, "rules table should be captured") + +-- apply() with different link numbers. +obj:apply(0) +assert(captured_apply_link == 0) +obj:apply(-1) +assert(captured_apply_link == -1) + +-- ── Serialisation order follows tag, not insertion order ───────────────────── + +-- Set properties in reverse-tag order; verify the serialized list is tag-sorted. +obj = TestObj.new() +obj.count = 9 -- tag 4 +obj.name = "aa" -- tag 3 +obj.color = vector(1,2,3) -- tag 2 +obj.amount = 0.1 -- tag 1 +-- flags (tag 0) not set, so omitted from output + +obj:apply() +local rules = captured_apply_rules +-- rules is a list: {tag0val, tag1val, tag2val, ...} pairs +-- tags present: 1,2,3,4 → indices 1-8 +assert(rules[1] == 1, "first tag should be 1 (amount)") +assert(rules[2] == 0.1, "amount value") +assert(rules[3] == 2, "second tag should be 2 (color)") +assert(rules[4] == vector(1,2,3), "color value") +assert(rules[5] == 3, "third tag should be 3 (name)") +assert(rules[6] == "aa", "name value") +assert(rules[7] == 4, "fourth tag should be 4 (count)") +assert(rules[8] == 9, "count value") +assert(rules[9] == nil, "no more entries") + +-- Unset properties are omitted entirely. +obj2 = TestObj.new() +obj2.count = 1 +obj2:apply() +rules = captured_apply_rules +assert(rules[1] == 4) -- only tag 4 +assert(rules[2] == 1) +assert(rules[3] == nil) + +return "OK" From 6cda13229d1ee4cb367b4c3b5ad017e796fb1a64 Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Tue, 16 Jun 2026 20:42:50 +0000 Subject: [PATCH 2/2] Rework fluent in simple tables with unwrap when passed to the function --- .gitignore | 1 + VM/include/llfluent_builder.h | 27 ++- VM/src/llfluent_builder.cpp | 317 +++++--------------------- init_debian_buster.sh | 5 + tests/SLConformance.test.cpp | 65 +++--- tests/conformance/llfluentbuilder.lua | 232 ------------------- tests/conformance/llprim_particle.lua | 75 ++++++ 7 files changed, 189 insertions(+), 533 deletions(-) delete mode 100644 tests/conformance/llfluentbuilder.lua create mode 100644 tests/conformance/llprim_particle.lua diff --git a/.gitignore b/.gitignore index 4f34a306..cba1cf26 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ __pycache__ /fuzz/corpus/json/* !/fuzz/corpus/json/*.json *.code-workspace +*.tar.bz2 diff --git a/VM/include/llfluent_builder.h b/VM/include/llfluent_builder.h index 7c8c8dbd..0990b799 100644 --- a/VM/include/llfluent_builder.h +++ b/VM/include/llfluent_builder.h @@ -2,6 +2,10 @@ #include struct lua_State; +typedef int (*lua_CFunction)(lua_State* L); + +// Default link target: apply to the prim running the script. +static const int SLUA_LINK_THIS = -4; struct FluentParamDescriptor { @@ -12,7 +16,7 @@ struct FluentParamDescriptor struct FluentFlagDescriptor { - const char* name; // boolean property name, e.g. "interp_color" + const char* name; // boolean property name, e.g. "color_interp" int mask; // bitmask, e.g. 0x01 int field_tag; // tag of the integer field holding the bits, e.g. 0 for "flags" }; @@ -24,8 +28,6 @@ struct FluentBuilderDef; // Deep-copies all strings. Caller owns the returned pointer (process lifetime expected). // Descriptors are sorted by tag internally; caller order does not matter. FluentBuilderDef* fluent_builder_def_build( - const char* apply_fn_name, // e.g. "ParticleSystem" - const char* apply_link_fn_name, // e.g. "LinkParticleSystem" const FluentParamDescriptor* descs, size_t count ); @@ -39,13 +41,18 @@ void fluent_builder_def_add_flags( size_t count ); -// Register a fluent builder module into L. -// Creates a global table named module_name containing a type table named type_name. -// def must remain valid for the lifetime of L. -// Call this on mConstsState before luaL_sandbox(), alongside luaL_register_noclobber. -void slua_open_fluent_builder( +// Serialize a params table into a flat tag/value rules list and push it onto the stack. +// params_idx is the stack index of the params table (may be nil — emits an empty list). +// Flag boolean properties are merged into their backing integer field before emission. +void slua_fluent_serialize(lua_State* L, int params_idx, const FluentBuilderDef* def); + +// Register fn as module_name.fn_name in L's globals, with def stored as upvalue 1. +// Creates the module table if it does not yet exist; adds to it if it does. +// Sets the module table readonly after each call. +void slua_register_fluent_fn( lua_State* L, - const char* module_name, // e.g. "llparticle" - const char* type_name, // e.g. "ParticleParams" + const char* module_name, + const char* fn_name, + lua_CFunction fn, const FluentBuilderDef* def ); diff --git a/VM/src/llfluent_builder.cpp b/VM/src/llfluent_builder.cpp index 56087fd2..4289a7d0 100644 --- a/VM/src/llfluent_builder.cpp +++ b/VM/src/llfluent_builder.cpp @@ -13,8 +13,6 @@ struct FluentBuilderDef { - std::string apply_fn_name; - std::string apply_link_fn_name; std::vector descs; // sorted by tag std::vector names; // storage for descriptor name strings std::unordered_map name_to_index; @@ -25,15 +23,11 @@ struct FluentBuilderDef }; FluentBuilderDef* fluent_builder_def_build( - const char* apply_fn_name, - const char* apply_link_fn_name, const FluentParamDescriptor* descs, size_t count ) { auto* def = new FluentBuilderDef; - def->apply_fn_name = apply_fn_name; - def->apply_link_fn_name = apply_link_fn_name; // Copy descriptors and deep-copy name strings. def->descs.resize(count); @@ -88,294 +82,101 @@ void fluent_builder_def_add_flags( } } -// __newindex: validate type by semantic char, then rawset. -static int fluent_builder_newindex(lua_State* L) +void slua_fluent_serialize(lua_State* L, int params_idx, const FluentBuilderDef* def) { - luaL_checktype(L, 1, LUA_TTABLE); - const char* key = luaL_checkstring(L, 2); - // value is at index 3 + lua_newtable(L); + int list = lua_gettop(L); + int idx = 0; - const auto* def = (const FluentBuilderDef*)lua_tolightuserdata(L, lua_upvalueindex(1)); + if (lua_isnoneornil(L, params_idx)) + return; // empty list - // Flag boolean properties: virtual aliases over the backing integer field. - auto flag_it = def->flag_name_to_index.find(key); - if (flag_it != def->flag_name_to_index.end()) + // Phase 1: accumulate flag bits per backing field. + std::unordered_map flag_accumulator; // field_tag -> OR'd mask + for (const auto& fdesc : def->flag_descs) { - const FluentFlagDescriptor& fdesc = def->flag_descs[flag_it->second]; - - // Find the backing field name by tag (done once per call; small linear scan). - const char* field_name = nullptr; - for (const auto& d : def->descs) - if (d.tag == fdesc.field_tag) { field_name = d.name; break; } - - lua_rawgetfield(L, 1, field_name); - int cur = lua_isnumber(L, -1) ? (int)lua_tointeger(L, -1) : 0; - lua_pop(L, 1); - - int next; - if (lua_isnoneornil(L, 3)) - { - // nil clears the bit - next = cur & ~fdesc.mask; - } - else + lua_rawgetfield(L, params_idx, fdesc.name); + if (!lua_isnil(L, -1)) { - if (!lua_isboolean(L, 3) && lua_type(L, 3) != LUA_TNUMBER) - luaL_typeerrorL(L, 3, "boolean or integer"); - bool set = lua_isboolean(L, 3) ? (bool)lua_toboolean(L, 3) : (lua_tointeger(L, 3) != 0); - next = set ? (cur | fdesc.mask) : (cur & ~fdesc.mask); + bool set = lua_isboolean(L, -1) ? (bool)lua_toboolean(L, -1) + : (lua_tointeger(L, -1) != 0); + if (set) + flag_accumulator[fdesc.field_tag] |= fdesc.mask; } - lua_pushinteger(L, next); - lua_rawsetfield(L, 1, field_name); - return 0; - } - - auto it = def->name_to_index.find(key); - if (it == def->name_to_index.end()) - luaL_errorL(L, "unknown property '%s'", key); - - // nil clears the property - if (lua_isnoneornil(L, 3)) - { - lua_pushnil(L); - lua_rawsetfield(L, 1, key); - return 0; - } - - char sem = def->descs[it->second].semantic; - switch (sem) - { - case 'i': - luaL_checkinteger(L, 3); - break; - case 'f': - luaL_checknumber(L, 3); - break; - case 's': - if (lua_type(L, 3) != LUA_TSTRING) - luaL_typeerrorL(L, 3, "string"); - break; - case 'v': - luaL_checkvector(L, 3); - break; - case 'r': - luaSL_checkquaternion(L, 3); - break; - case 'b': - if (!lua_isboolean(L, 3) && lua_type(L, 3) != LUA_TNUMBER) - luaL_typeerrorL(L, 3, "boolean or integer"); - break; - case 'a': - case 'k': - { - int t = lua_type(L, 3); - if (t != LUA_TSTRING && !(t == LUA_TUSERDATA && lua_userdatatag(L, 3) == UTAG_UUID)) - luaL_typeerrorL(L, 3, "string or uuid"); - break; - } - default: - luaL_errorL(L, "internal: bad builder semantic char '%c'", sem); + lua_pop(L, 1); } - lua_rawsetfield(L, 1, key); - return 0; -} - -// apply(self [, linkNumber]) -static int fluent_builder_apply(lua_State* L) -{ - luaL_checktype(L, 1, LUA_TTABLE); - - const auto* def = (const FluentBuilderDef*)lua_tolightuserdata(L, lua_upvalueindex(1)); - bool has_link = !lua_isnoneornil(L, 2); - int link_num = 0; - if (has_link) - link_num = luaL_checkinteger(L, 2); - - // Build flattened rules list in tag order. - lua_newtable(L); - int list = lua_gettop(L); - int idx = 0; - + // Phase 2: emit tag/value pairs in tag order. for (const auto& desc : def->descs) { - lua_rawgetfield(L, 1, desc.name); - if (lua_isnil(L, -1)) - { + int raw_int = 0; + bool has_raw = false; + + lua_rawgetfield(L, params_idx, desc.name); + has_raw = !lua_isnil(L, -1); + if (has_raw && lua_isnumber(L, -1)) + raw_int = (int)lua_tointeger(L, -1); + if (!has_raw) lua_pop(L, 1); + + // Integer fields that back flags: merge accumulated bits. + auto flag_it = flag_accumulator.find(desc.tag); + if (desc.semantic == 'i' && flag_it != flag_accumulator.end()) + { + int merged = raw_int | flag_it->second; + if (has_raw) lua_pop(L, 1); + if (merged != 0) + { + lua_pushinteger(L, desc.tag); + lua_rawseti(L, list, ++idx); + lua_pushinteger(L, merged); + lua_rawseti(L, list, ++idx); + } continue; } - // append tag then value + + if (!has_raw) + continue; + + // Append tag then value. lua_pushinteger(L, desc.tag); lua_rawseti(L, list, ++idx); - // For 'b' semantic, booleans must be coerced to integer for the wire format. if (desc.semantic == 'b' && lua_isboolean(L, -1)) lua_pushinteger(L, lua_toboolean(L, -1)); else lua_pushvalue(L, -1); lua_rawseti(L, list, ++idx); - lua_pop(L, 1); // pop the rawgetfield result - } - - // Dispatch via ll.* global table. - lua_rawgetfield(L, LUA_BASEGLOBALSINDEX, "ll"); - if (has_link) - { - lua_rawgetfield(L, -1, def->apply_link_fn_name.c_str()); - lua_pushinteger(L, link_num); - lua_pushvalue(L, list); - lua_call(L, 2, 0); - } - else - { - lua_rawgetfield(L, -1, def->apply_fn_name.c_str()); - lua_pushvalue(L, list); - lua_call(L, 1, 0); - } - return 0; -} - -// new([table]): create empty table, attach metatable, and optionally bulk-initialize -// from an initializer table by routing each key/value through __newindex. -// Upvalue 1: metatable -static int fluent_builder_new(lua_State* L) -{ - bool has_init = lua_istable(L, 1); - - // Create the new instance and attach metatable. - lua_createtable(L, 0, 0); - lua_pushvalue(L, lua_upvalueindex(1)); - lua_setmetatable(L, -2); - // instance is now on top - - if (!has_init) - return 1; - - int instance_idx = lua_gettop(L); // absolute index of instance - const auto* def = (const FluentBuilderDef*)lua_tolightuserdata(L, lua_upvalueindex(2)); - - // Fetch __newindex from the metatable. - lua_pushvalue(L, lua_upvalueindex(1)); // push mt - lua_getfield(L, -1, "__newindex"); // push __newindex closure - lua_remove(L, -2); // remove mt - - int fn_idx = lua_gettop(L); // absolute index of __newindex closure - - // Iterate the initializer table (arg 1). - lua_pushnil(L); - while (lua_next(L, 1) != 0) - { - // Stack: ..., instance(instance_idx), fn(fn_idx), key(top-1), value(top) - int key_idx = lua_gettop(L) - 1; - int val_idx = lua_gettop(L); - - // Skip unknown keys silently (type errors on known keys still propagate). - if (lua_type(L, key_idx) == LUA_TSTRING) - { - const char* k = lua_tostring(L, key_idx); - bool known = def->flag_name_to_index.count(k) || def->name_to_index.count(k); - if (known) - { - lua_pushvalue(L, fn_idx); - lua_pushvalue(L, instance_idx); - lua_pushvalue(L, key_idx); - lua_pushvalue(L, val_idx); - lua_call(L, 3, 0); - } - } - - // Pop value; leave key on top for lua_next. lua_pop(L, 1); } - - // Pop the __newindex closure; leave instance on stack. - lua_pop(L, 1); - return 1; } -// __index: check flag properties first, then fall through to the metatable for methods. -// Upvalue 1: metatable (for new/apply fallthrough) -// Upvalue 2: def* (for flag lookup) -static int fluent_builder_index(lua_State* L) -{ - luaL_checktype(L, 1, LUA_TTABLE); - const char* key = luaL_checkstring(L, 2); - - const auto* def = (const FluentBuilderDef*)lua_tolightuserdata(L, lua_upvalueindex(2)); - - auto flag_it = def->flag_name_to_index.find(key); - if (flag_it != def->flag_name_to_index.end()) - { - const FluentFlagDescriptor& fdesc = def->flag_descs[flag_it->second]; - - const char* field_name = nullptr; - for (const auto& d : def->descs) - if (d.tag == fdesc.field_tag) { field_name = d.name; break; } - - int cur = 0; - if (field_name) - { - lua_rawgetfield(L, 1, field_name); - if (lua_isnumber(L, -1)) - cur = (int)lua_tointeger(L, -1); - lua_pop(L, 1); - } - lua_pushboolean(L, (cur & fdesc.mask) != 0); - return 1; - } - - // Fall through to metatable for new(), apply(), etc. - lua_rawgetfield(L, lua_upvalueindex(1), key); - return 1; -} - -void slua_open_fluent_builder( +void slua_register_fluent_fn( lua_State* L, const char* module_name, - const char* type_name, + const char* fn_name, + lua_CFunction fn, const FluentBuilderDef* def ) { int top = lua_gettop(L); - // module table - lua_newtable(L); + lua_getglobal(L, module_name); + if (!lua_istable(L, -1)) + { + lua_pop(L, 1); + lua_newtable(L); + } + else + { + lua_setreadonly(L, -1, false); + } int module_idx = lua_gettop(L); - // type metatable - lua_newtable(L); - int mt = lua_gettop(L); - - // __newindex — def* as light userdata upvalue lua_pushlightuserdata(L, (void*)def); - lua_pushcclosurek(L, fluent_builder_newindex, "__newindex", 1, nullptr); - lua_setfield(L, mt, "__newindex"); - - // __index closure: flag reads first, then method fallthrough via metatable. - // new() and apply() are set on mt after this, but the closure holds a reference - // to the metatable table object itself (not a snapshot), so it sees them. - lua_pushvalue(L, mt); // upvalue 1: metatable - lua_pushlightuserdata(L, (void*)def); // upvalue 2: def* - lua_pushcclosurek(L, fluent_builder_index, "__index", 2, nullptr); - lua_setfield(L, mt, "__index"); - - // new() — upvalue 1: metatable, upvalue 2: def* (for unknown-key skipping) - lua_pushvalue(L, mt); - lua_pushlightuserdata(L, (void*)def); - lua_pushcclosurek(L, fluent_builder_new, "new", 2, nullptr); - lua_setfield(L, mt, "new"); - - // apply() — def* as light userdata upvalue - lua_pushlightuserdata(L, (void*)def); - lua_pushcclosurek(L, fluent_builder_apply, "apply", 1, nullptr); - lua_setfield(L, mt, "apply"); - - lua_setreadonly(L, mt, true); - - // module_table[type_name] = metatable - lua_setfield(L, module_idx, type_name); + lua_pushcclosurek(L, fn, fn_name, 1, nullptr); + lua_setfield(L, module_idx, fn_name); lua_setreadonly(L, module_idx, true); lua_setglobal(L, module_name); diff --git a/init_debian_buster.sh b/init_debian_buster.sh index fe7736e6..bba6b3b6 100755 --- a/init_debian_buster.sh +++ b/init_debian_buster.sh @@ -29,10 +29,15 @@ apt-get install -y gcc-multilib g++-multilib cmake # Finally, autobuild pip3 --no-cache-dir install pydot==1.4.2 pyzstd==0.15.10 autobuild +update-alternatives --install /usr/bin/clang clang /usr/bin/clang-11 100 +update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-11 100 update-alternatives --install /usr/bin/cc cc /usr/bin/clang-11 100 update-alternatives --install /usr/bin/c++ c++ /usr/bin/clang++-11 100 +update-alternatives --install /usr/bin/python python /usr/bin/python3 100 cd /root git clone https://github.com/secondlife/build-variables.git cd build-variables echo 'export AUTOBUILD_VARIABLES_FILE=/root/build-variables/variables' >> ~/.bashrc + +git config --global --add safe.directory $(pwd) diff --git a/tests/SLConformance.test.cpp b/tests/SLConformance.test.cpp index bc4cddec..e16cde9f 100644 --- a/tests/SLConformance.test.cpp +++ b/tests/SLConformance.test.cpp @@ -1487,56 +1487,55 @@ TEST_CASE("Memory hygiene") runConformance("memory_hygiene.lua"); } -TEST_CASE("llfluentbuilder") +TEST_CASE("llparticle") { - // Minimal descriptors that exercise every semantic type and flag bits, - // with no dependency on particle or any other SL-specific API. - static const FluentParamDescriptor kTestDescs[] = { - {"flags", 'i', 0}, // integer — also backing field for flags - {"amount", 'f', 1}, // float - {"color", 'v', 2}, // vector - {"name", 'k', 3}, // key (string | uuid) - {"count", 'i', 4}, // integer (second int property) + static const FluentParamDescriptor kDescs[] = { + {"flags", 'i', 0}, + {"color_begin", 'v', 1}, + {"alpha_begin", 'f', 2}, + {"burst_rate", 'f', 13}, }; - static const FluentFlagDescriptor kTestFlagDescs[] = { - {"active", 0x1, 0}, // bit 0 of flags - {"looping", 0x2, 0}, // bit 1 of flags + static const FluentFlagDescriptor kFlagDescs[] = { + {"color_interp", 0x1, 0}, + {"scale_interp", 0x2, 0}, + {"bounce", 0x4, 0}, + {"emissive", 0x100, 0}, }; - - // Build the def once; it has process lifetime so static is fine here. static FluentBuilderDef* s_def = []() { - auto* d = fluent_builder_def_build( - "TestApply", "LinkTestApply", - kTestDescs, std::size(kTestDescs) - ); - fluent_builder_def_add_flags(d, kTestFlagDescs, std::size(kTestFlagDescs)); + auto* d = fluent_builder_def_build(kDescs, std::size(kDescs)); + fluent_builder_def_add_flags(d, kFlagDescs, std::size(kFlagDescs)); return d; }(); - runConformance("llfluentbuilder.lua", nullptr, [](lua_State* L) { - // Mock ll.TestApply / ll.LinkTestApply so apply() can be verified. + runConformance("llprim_particle.lua", nullptr, [](lua_State* L) { + // Mock ll.LinkParticleSystem so the dispatch wrapper can be exercised. static const luaL_Reg test_ll_lib[] = { - {"TestApply", [](lua_State* L) -> int { - luaL_checktype(L, 1, LUA_TTABLE); - lua_pushvalue(L, 1); - lua_setglobal(L, "captured_apply_rules"); - lua_pushnil(L); - lua_setglobal(L, "captured_apply_link"); - return 0; - }}, - {"LinkTestApply", [](lua_State* L) -> int { + {"LinkParticleSystem", [](lua_State* L) -> int { luaL_checkinteger(L, 1); luaL_checktype(L, 2, LUA_TTABLE); lua_pushvalue(L, 1); - lua_setglobal(L, "captured_apply_link"); + lua_setglobal(L, "_captured_link"); lua_pushvalue(L, 2); - lua_setglobal(L, "captured_apply_rules"); + lua_setglobal(L, "_captured_rules"); return 0; }}, {nullptr, nullptr} }; luaL_register_noclobber(L, LUA_LLLIBNAME, test_ll_lib); - slua_open_fluent_builder(L, "testmodule", "TestObj", s_def); + + auto particle_system = [](lua_State* L) -> int { + const auto* def = (const FluentBuilderDef*)lua_tolightuserdata(L, lua_upvalueindex(1)); + int link = lua_isnoneornil(L, 2) ? SLUA_LINK_THIS : luaL_checkinteger(L, 2); + slua_fluent_serialize(L, 1, def); + int rules_idx = lua_gettop(L); + lua_rawgetfield(L, LUA_BASEGLOBALSINDEX, "ll"); + lua_rawgetfield(L, -1, "LinkParticleSystem"); + lua_pushinteger(L, link); + lua_pushvalue(L, rules_idx); + lua_call(L, 2, 0); + return 0; + }; + slua_register_fluent_fn(L, "llprim", "ParticleSystem", particle_system, s_def); }); } diff --git a/tests/conformance/llfluentbuilder.lua b/tests/conformance/llfluentbuilder.lua deleted file mode 100644 index 1314613a..00000000 --- a/tests/conformance/llfluentbuilder.lua +++ /dev/null @@ -1,232 +0,0 @@ --- Test suite for the generic fluent builder runtime (llfluent_builder.cpp). --- --- Uses a mock "testmodule.TestObj" registered by the SLConformance test harness: --- fields: flags (i,0), amount (f,1), color (v,2), name (k,3), count (i,4) --- flags: active (0x1, field 0), looping (0x2, field 0) --- apply: ll.TestApply(rules) / ll.LinkTestApply(link, rules) --- --- No particle system or SL-specific constants anywhere in this file. - -local TestObj = testmodule.TestObj - --- ── Construction ──────────────────────────────────────────────────────────── - --- new() returns a fresh table with the metatable attached. -local obj = TestObj.new() -assert(type(obj) == "table") - --- setmetatable works identically to new() — both are valid. -local obj2 = setmetatable({}, TestObj) -assert(type(obj2) == "table") - --- ── Property round-trips ───────────────────────────────────────────────────── - --- float -obj = TestObj.new() -obj.amount = 0.5 -assert(obj.amount == 0.5) - --- vector -obj = TestObj.new() -obj.color = vector(1, 0.5, 0) -assert(obj.color == vector(1, 0.5, 0)) - --- integer -obj = TestObj.new() -obj.count = 7 -assert(obj.count == 7) - --- key (accepts string) -obj = TestObj.new() -obj.name = "00000000-0000-0000-0000-000000000000" -assert(obj.name == "00000000-0000-0000-0000-000000000000") - --- key (accepts uuid) -obj = TestObj.new() -local id = uuid("00000000-0000-0000-0000-000000000001") -obj.name = id -assert(obj.name == id) - --- raw integer flags field -obj = TestObj.new() -obj.flags = 42 -assert(obj.flags == 42) - --- ── Unset properties read as nil ───────────────────────────────────────────── - -obj = TestObj.new() -assert(obj.amount == nil) -assert(obj.color == nil) -assert(obj.count == nil) -assert(obj.name == nil) - --- ── Type errors on assignment ──────────────────────────────────────────────── - -local function assert_error(fn, snippet) - local ok, err = pcall(fn) - assert(not ok, "expected error for: " .. snippet) -end - -obj = TestObj.new() -assert_error(function() obj.amount = "notanumber" end, "amount = string") -assert_error(function() obj.amount = vector(1,0,0) end, "amount = vector") -assert_error(function() obj.color = 1.0 end, "color = number") -assert_error(function() obj.color = "notavec" end, "color = string") --- Note: luaL_checkinteger accepts any number (floats are truncated), so only --- non-number types are rejected for integer fields. -assert_error(function() obj.count = "three" end, "count = string") -assert_error(function() obj.count = vector(0,0,0) end, "count = vector") - --- ── Unknown property errors ────────────────────────────────────────────────── - -assert_error(function() obj.nonexistent = 1 end, "unknown property") - --- ── Flag properties: write ─────────────────────────────────────────────────── - --- Setting a flag true ORs the bit into flags. -obj = TestObj.new() -obj.active = true -assert(obj.flags == 0x1) - -obj = TestObj.new() -obj.looping = true -assert(obj.flags == 0x2) - --- Both flags together. -obj = TestObj.new() -obj.active = true -obj.looping = true -assert(obj.flags == 0x3) - --- Setting false clears the bit. -obj = TestObj.new() -obj.flags = 0x3 -obj.active = false -assert(obj.flags == 0x2) - --- Nil also clears the bit. -obj = TestObj.new() -obj.flags = 0x3 -obj.looping = nil -assert(obj.flags == 0x1) - --- Integer 0 clears the bit; non-zero sets it. -obj = TestObj.new() -obj.active = 1 -assert(obj.flags == 0x1) -obj.active = 0 -assert(obj.flags == 0x0) - --- ── Flag properties: read ──────────────────────────────────────────────────── - -obj = TestObj.new() -obj.flags = 0x1 -assert(obj.active == true) -assert(obj.looping == false) - -obj.flags = 0x3 -assert(obj.active == true) -assert(obj.looping == true) - -obj.flags = 0x0 -assert(obj.active == false) -assert(obj.looping == false) - --- ── Flag + raw flags coexistence ───────────────────────────────────────────── - --- Writing raw flags and reading back via boolean alias. -obj = TestObj.new() -obj.flags = 0x2 -assert(obj.active == false) -assert(obj.looping == true) - --- Writing via alias reflects in raw flags. -obj = TestObj.new() -obj.flags = 0x0 -obj.active = true -assert(obj.flags == 0x1) - --- Flag type error: only boolean or integer accepted. -assert_error(function() obj.active = "yes" end, "active = string") -assert_error(function() obj.active = vector(1,0,0) end, "active = vector") - --- ── new({...}) initializer table ───────────────────────────────────────────── - -obj = TestObj.new({ - amount = 1.5, - color = vector(0, 1, 0), - count = 3, - active = true, -}) -assert(obj.amount == 1.5) -assert(obj.color == vector(0, 1, 0)) -assert(obj.count == 3) -assert(obj.active == true) -assert(obj.flags == 0x1) - --- Unknown keys in the initializer are silently ignored (no error). -obj = TestObj.new({ - amount = 2.0, - unknown_key = "ignored", - another_one = 99, -}) -assert(obj.amount == 2.0) - --- ── apply() dispatch ───────────────────────────────────────────────────────── - --- apply() with no argument calls ll.TestApply(rules). -captured_apply_link = "sentinel" -captured_apply_rules = nil -obj = TestObj.new() -obj.amount = 0.25 -obj:apply() -assert(captured_apply_link == nil, "no-arg apply should not set link") -assert(captured_apply_rules ~= nil, "rules table should be captured") - --- apply(linkNum) calls ll.LinkTestApply(linkNum, rules). -captured_apply_link = nil -captured_apply_rules = nil -obj:apply(3) -assert(captured_apply_link == 3, "link number should be captured") -assert(captured_apply_rules ~= nil, "rules table should be captured") - --- apply() with different link numbers. -obj:apply(0) -assert(captured_apply_link == 0) -obj:apply(-1) -assert(captured_apply_link == -1) - --- ── Serialisation order follows tag, not insertion order ───────────────────── - --- Set properties in reverse-tag order; verify the serialized list is tag-sorted. -obj = TestObj.new() -obj.count = 9 -- tag 4 -obj.name = "aa" -- tag 3 -obj.color = vector(1,2,3) -- tag 2 -obj.amount = 0.1 -- tag 1 --- flags (tag 0) not set, so omitted from output - -obj:apply() -local rules = captured_apply_rules --- rules is a list: {tag0val, tag1val, tag2val, ...} pairs --- tags present: 1,2,3,4 → indices 1-8 -assert(rules[1] == 1, "first tag should be 1 (amount)") -assert(rules[2] == 0.1, "amount value") -assert(rules[3] == 2, "second tag should be 2 (color)") -assert(rules[4] == vector(1,2,3), "color value") -assert(rules[5] == 3, "third tag should be 3 (name)") -assert(rules[6] == "aa", "name value") -assert(rules[7] == 4, "fourth tag should be 4 (count)") -assert(rules[8] == 9, "count value") -assert(rules[9] == nil, "no more entries") - --- Unset properties are omitted entirely. -obj2 = TestObj.new() -obj2.count = 1 -obj2:apply() -rules = captured_apply_rules -assert(rules[1] == 4) -- only tag 4 -assert(rules[2] == 1) -assert(rules[3] == nil) - -return "OK" diff --git a/tests/conformance/llprim_particle.lua b/tests/conformance/llprim_particle.lua new file mode 100644 index 00000000..dbe52d96 --- /dev/null +++ b/tests/conformance/llprim_particle.lua @@ -0,0 +1,75 @@ +-- Conformance test for llprim.ParticleSystem(). +-- The C++ test harness registers a mock ll.LinkParticleSystem that stores +-- captured_link and _captured_rules as globals, and registers llparticle +-- with a subset of particle descriptors: +-- flags 'i' tag 0 (backing field for flag bits) +-- color_begin 'v' tag 1 +-- alpha_begin 'f' tag 2 +-- burst_rate 'f' tag 13 +-- Flag descriptors (all backed by tag 0): +-- color_interp 0x001 +-- scale_interp 0x002 +-- bounce 0x004 +-- emissive 0x100 + +-- Helper: call and return captured state. +local function dispatch(params, link) + if link ~= nil then + llprim.ParticleSystem(params, link) + else + llprim.ParticleSystem(params) + end + return _captured_link, _captured_rules +end + +-- Test 1: nil params emits empty rules list, link defaults to LINK_THIS. +local link, rules = dispatch(nil) +assert(link == LINK_THIS, "nil params: link should be LINK_THIS") +assert(type(rules) == "table", "nil params: rules should be a table") +assert(#rules == 0, "nil params: rules should be empty") + +-- Test 2: explicit link number is forwarded. +link, rules = dispatch(nil, 3) +assert(link == 3, "explicit link=3 should be forwarded") + +-- Test 3: scalar float property is serialized with its tag. +link, rules = dispatch({ burst_rate = 2.5 }) +assert(#rules == 2, "burst_rate: expected 2 elements") +assert(rules[1] == PSYS_SRC_BURST_RATE, "burst_rate: tag should be PSYS_SRC_BURST_RATE") +assert(rules[2] == 2.5, "burst_rate: value should be 2.5") + +-- Test 4: vector property is serialized correctly. +local col = vector(1, 0.5, 0.25) +link, rules = dispatch({ color_begin = col }) +assert(#rules == 2, "color_begin: expected 2 elements") +assert(rules[1] == PSYS_PART_START_COLOR, "color_begin: tag should be PSYS_PART_START_COLOR") +assert(rules[2] == col, "color_begin: value should be the vector") + +-- Test 5: single flag boolean is merged into the flags integer field. +link, rules = dispatch({ color_interp = true }) +assert(#rules == 2, "color_interp: expected 2 elements (flags field only)") +assert(rules[1] == PSYS_PART_FLAGS, "color_interp: tag should be PSYS_PART_FLAGS") +assert(rules[2] == PSYS_PART_INTERP_COLOR_MASK, "color_interp: value should be PSYS_PART_INTERP_COLOR_MASK") + +-- Test 6: multiple flag booleans accumulate into a single flags field entry. +link, rules = dispatch({ color_interp = true, bounce = true }) +assert(#rules == 2, "two flags: should still be one flags field entry") +assert(rules[1] == PSYS_PART_FLAGS, "two flags: tag should be PSYS_PART_FLAGS") +assert(rules[2] == bit32.bor(PSYS_PART_INTERP_COLOR_MASK, PSYS_PART_BOUNCE_MASK), + "two flags: both bits should be set") + +-- Test 7: raw flags integer and boolean flag properties are merged together. +link, rules = dispatch({ flags = PSYS_PART_EMISSIVE_MASK, bounce = true }) +assert(#rules == 2, "raw+boolean flags: should be one flags field entry") +assert(rules[1] == PSYS_PART_FLAGS, "raw+boolean flags: tag should be PSYS_PART_FLAGS") +assert(rules[2] == bit32.bor(PSYS_PART_EMISSIVE_MASK, PSYS_PART_BOUNCE_MASK), + "raw+boolean flags: both bits should be set") + +-- Test 8: multiple params serialize in ascending tag order. +link, rules = dispatch({ burst_rate = 1.0, color_begin = vector(1, 0, 0) }) +-- color_begin tag=1, burst_rate tag=13 — tag 1 must come first. +assert(#rules == 4, "two params: expected 4 elements") +assert(rules[1] == PSYS_PART_START_COLOR, "tag order: color_begin should come before burst_rate") +assert(rules[3] == PSYS_SRC_BURST_RATE, "tag order: burst_rate should be second") + +return "OK"