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/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..3fbf7d97 --- /dev/null +++ b/VM/include/llfluent_builder.h @@ -0,0 +1,60 @@ +#pragma once +#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 +{ + const char* name; // effective property name (pretty alias or strict fallback) + char semantic; // scalar: 'i'=integer 'f'=float 's'=string 'v'=vector 'r'=rotation 'b'=boolean 'a'=asset 'k'=key + // collection: 'C'=string-csv ({string} array → escaped comma-joined string, one tag/value pair) + // 'M'=string-map ({[string]:string} table → one tag/key/value triple per entry) + int tag; // PSYS_* constant integer value +}; + +struct FluentFlagDescriptor +{ + 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" +}; + +// 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 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 +); + +// 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, + const char* fn_name, + lua_CFunction fn, + const FluentBuilderDef* def +); diff --git a/VM/src/llfluent_builder.cpp b/VM/src/llfluent_builder.cpp new file mode 100644 index 00000000..8e33658e --- /dev/null +++ b/VM/src/llfluent_builder.cpp @@ -0,0 +1,301 @@ +#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::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 FluentParamDescriptor* descs, + size_t count +) +{ + auto* def = new FluentBuilderDef; + + // 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; + } +} + +void slua_fluent_serialize(lua_State* L, int params_idx, const FluentBuilderDef* def) +{ + lua_newtable(L); + int list = lua_gettop(L); + int idx = 0; + + if (lua_isnoneornil(L, params_idx)) + return; // empty list + + // 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) + { + lua_rawgetfield(L, params_idx, fdesc.name); + if (!lua_isnil(L, -1)) + { + 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_pop(L, 1); + } + + // Phase 2: emit tag/value pairs in tag order. + for (const auto& desc : def->descs) + { + 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; + } + + // 'C': string-csv — value is a Lua array of strings. + // Encode as backslash-escaped comma-joined string; emit one tag/value pair. + if (desc.semantic == 'C') + { + if (!has_raw || !lua_istable(L, -1)) + { + if (has_raw) lua_pop(L, 1); + continue; + } + int n = (int)lua_objlen(L, -1); + if (n > 0) + { + std::string csv; + for (int j = 1; j <= n; ++j) + { + lua_rawgeti(L, -1, j); + size_t len = 0; + const char* s = lua_tolstring(L, -1, &len); + if (s) + { + if (j > 1) csv += ','; + for (size_t k = 0; k < len; ++k) + { + if (s[k] == '\\' || s[k] == ',') csv += '\\'; + csv += s[k]; + } + } + lua_pop(L, 1); + } + lua_pop(L, 1); // pop the array + lua_pushinteger(L, desc.tag); + lua_rawseti(L, list, ++idx); + lua_pushlstring(L, csv.c_str(), csv.size()); + lua_rawseti(L, list, ++idx); + } + else + { + lua_pop(L, 1); // pop empty array, emit nothing + } + continue; + } + + // 'M': string-map — value is a Lua string-keyed table. + // Emit one tag/key/value triple per entry, in sorted key order. + if (desc.semantic == 'M') + { + if (!has_raw || !lua_istable(L, -1)) + { + if (has_raw) lua_pop(L, 1); + continue; + } + // Collect keys for deterministic ordering. + std::vector keys; + lua_pushnil(L); + while (lua_next(L, -2)) + { + if (lua_type(L, -2) == LUA_TSTRING) + keys.push_back(lua_tostring(L, -2)); + lua_pop(L, 1); // pop value, keep key for next iteration + } + std::sort(keys.begin(), keys.end()); + + for (const auto& key : keys) + { + lua_pushlstring(L, key.c_str(), key.size()); + lua_rawget(L, -2); // push value + int vtype = lua_type(L, -1); + // Skip non-primitive types that have no useful string representation. + if (vtype == LUA_TNIL || vtype == LUA_TTABLE || vtype == LUA_TFUNCTION) + { + lua_pop(L, 1); + continue; + } + // luaL_tolstring handles string, number, boolean, vector, and + // quaternions (via __tostring metamethod). It pushes the result + // on top, leaving the original value beneath it. + luaL_tolstring(L, -1, nullptr); + lua_remove(L, -2); // drop original, keep the string + lua_pushinteger(L, desc.tag); + lua_rawseti(L, list, ++idx); + lua_pushlstring(L, key.c_str(), key.size()); + lua_rawseti(L, list, ++idx); + lua_rawseti(L, list, ++idx); // value + } + lua_pop(L, 1); // pop the table + continue; + } + + // 'N': string-multi — value is a Lua array of strings. + // Emit one tag/value pair per element, preserving order. + if (desc.semantic == 'N') + { + if (!has_raw || !lua_istable(L, -1)) + { + if (has_raw) lua_pop(L, 1); + continue; + } + int n = (int)lua_objlen(L, -1); + for (int j = 1; j <= n; ++j) + { + lua_rawgeti(L, -1, j); + if (lua_type(L, -1) == LUA_TSTRING) + { + lua_pushinteger(L, desc.tag); + lua_rawseti(L, list, ++idx); + lua_rawseti(L, list, ++idx); + } + else + { + lua_pop(L, 1); // skip non-string values + } + } + lua_pop(L, 1); // pop the array + continue; + } + + if (!has_raw) + continue; + + // Append tag then value. + lua_pushinteger(L, desc.tag); + lua_rawseti(L, list, ++idx); + + 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); + } +} + +void slua_register_fluent_fn( + lua_State* L, + const char* module_name, + const char* fn_name, + lua_CFunction fn, + const FluentBuilderDef* def +) +{ + int top = lua_gettop(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); + + lua_pushlightuserdata(L, (void*)def); + 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); + + 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/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 e52fd325..b86df875 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,83 @@ TEST_CASE("Memory hygiene") runConformance("memory_hygiene.lua"); } +TEST_CASE("llparticle") +{ + static const FluentParamDescriptor kDescs[] = { + {"flags", 'i', 0}, + {"color_begin", 'v', 1}, + {"alpha_begin", 'f', 2}, + {"burst_rate", 'f', 13}, + }; + static const FluentFlagDescriptor kFlagDescs[] = { + {"color_interp", 0x1, 0}, + {"scale_interp", 0x2, 0}, + {"bounce", 0x4, 0}, + {"emissive", 0x100, 0}, + }; + static FluentBuilderDef* s_def = []() { + auto* d = fluent_builder_def_build(kDescs, std::size(kDescs)); + fluent_builder_def_add_flags(d, kFlagDescs, std::size(kFlagDescs)); + return d; + }(); + + 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[] = { + {"LinkParticleSystem", [](lua_State* L) -> int { + luaL_checkinteger(L, 1); + luaL_checktype(L, 2, LUA_TTABLE); + lua_pushvalue(L, 1); + lua_setglobal(L, "_captured_link"); + lua_pushvalue(L, 2); + lua_setglobal(L, "_captured_rules"); + return 0; + }}, + {nullptr, nullptr} + }; + luaL_register_noclobber(L, LUA_LLLIBNAME, test_ll_lib); + + 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); + }); +} + +TEST_CASE("fluent_builder_collection") +{ + // Synthetic descriptor table exercising the two collection semantics. + // tag 5: 'C' string-csv (like PRIM_MEDIA_WHITELIST) + // tag 7: 'M' string-map (like HTTP_CUSTOM_HEADER) + // tag 9: 's' plain string (control: must be unaffected) + static const FluentParamDescriptor kDescs[] = { + {"whitelist", 'C', 5}, + {"custom_headers", 'M', 7}, + {"label", 's', 9}, + }; + static FluentBuilderDef* s_def = []() { + return fluent_builder_def_build(kDescs, std::size(kDescs)); + }(); + + runConformance("fluent_builder_collection.lua", nullptr, [](lua_State* L) { + // Mock apply function: serialize params and store result as _captured_rules. + auto apply_fn = [](lua_State* L) -> int { + const auto* def = (const FluentBuilderDef*)lua_tolightuserdata(L, lua_upvalueindex(1)); + slua_fluent_serialize(L, 1, def); + lua_setglobal(L, "_captured_rules"); + return 0; + }; + slua_register_fluent_fn(L, "testbuilder", "apply", apply_fn, s_def); + }); +} + TEST_SUITE_END(); diff --git a/tests/conformance/fluent_builder_collection.lua b/tests/conformance/fluent_builder_collection.lua new file mode 100644 index 00000000..67881cc2 --- /dev/null +++ b/tests/conformance/fluent_builder_collection.lua @@ -0,0 +1,92 @@ +-- Conformance tests for 'C' (string-csv) and 'M' (string-map) fluent builder semantics. +-- +-- The C++ harness registers testbuilder.apply(params) which serializes params +-- using a synthetic descriptor table: +-- tag 5 'C' whitelist +-- tag 7 'M' custom_headers +-- tag 9 's' label + +local function apply(params) + testbuilder.apply(params) + return _captured_rules +end + +-- ─── 'C' string-csv ────────────────────────────────────────────────────────── + +-- Single URL: emits {tag, "url"}. +local r = apply({ whitelist = {"https://example.com"} }) +assert(#r == 2, "csv single: expected 2 slots, got " .. #r) +assert(r[1] == 5, "csv single: tag should be 5") +assert(r[2] == "https://example.com", "csv single: value mismatch") + +-- Multiple URLs: comma-joined. +r = apply({ whitelist = {"https://a.com", "https://b.com"} }) +assert(#r == 2, "csv multi: expected 2 slots") +assert(r[1] == 5, "csv multi: tag should be 5") +assert(r[2] == "https://a.com,https://b.com", "csv multi: value mismatch, got " .. tostring(r[2])) + +-- URL containing a comma: comma must be backslash-escaped. +r = apply({ whitelist = {"https://a.com/p?x=1,y=2"} }) +assert(#r == 2, "csv comma-escape: expected 2 slots") +assert(r[2] == "https://a.com/p?x=1\\,y=2", "csv comma-escape: value mismatch, got " .. tostring(r[2])) + +-- URL containing a backslash: backslash must be doubled. +r = apply({ whitelist = {"https://a.com/a\\b"} }) +assert(#r == 2, "csv backslash-escape: expected 2 slots") +assert(r[2] == "https://a.com/a\\\\b", "csv backslash-escape: value mismatch, got " .. tostring(r[2])) + +-- Empty array: tag must not appear in output. +r = apply({ whitelist = {} }) +assert(#r == 0, "csv empty: output should be empty, got " .. #r) + +-- Nil / unset: tag must not appear in output. +r = apply({}) +assert(#r == 0, "csv nil: output should be empty") + +-- Array index order is preserved in the CSV. +r = apply({ whitelist = {"first", "second", "third"} }) +assert(#r == 2, "csv order: expected 2 slots") +assert(r[2] == "first,second,third", "csv order: insertion order not preserved, got " .. tostring(r[2])) + +-- ─── 'M' string-map ────────────────────────────────────────────────────────── + +-- Single header: emits {tag, key, value}. +r = apply({ custom_headers = {["X-Foo"] = "bar"} }) +assert(#r == 3, "map single: expected 3 slots, got " .. #r) +assert(r[1] == 7, "map single: tag should be 7") +assert(r[2] == "X-Foo", "map single: key mismatch") +assert(r[3] == "bar", "map single: value mismatch") + +-- Multiple headers: tag appears once per pair, keys sorted. +r = apply({ custom_headers = {["Z-Last"] = "z", ["A-First"] = "a"} }) +assert(#r == 6, "map multi: expected 6 slots, got " .. #r) +assert(r[1] == 7 and r[4] == 7, "map multi: both entries must start with tag 7") +assert(r[2] == "A-First", "map multi: first key should be A-First (sorted), got " .. tostring(r[2])) +assert(r[3] == "a", "map multi: first value should be 'a'") +assert(r[5] == "Z-Last", "map multi: second key should be Z-Last, got " .. tostring(r[5])) +assert(r[6] == "z", "map multi: second value should be 'z'") + +-- Empty table: tag must not appear in output. +r = apply({ custom_headers = {} }) +assert(#r == 0, "map empty: output should be empty") + +-- Nil / unset: tag must not appear in output. +r = apply({}) +assert(#r == 0, "map nil: output should be empty") + +-- ─── Co-existence with scalar property ─────────────────────────────────────── + +-- All three descriptor types in one call. +r = apply({ + whitelist = {"https://x.com"}, + custom_headers = {["K"] = "V"}, + label = "hello", +}) +-- Expected output (tag-sorted: 5, 7, 9): +-- 5, "https://x.com", 7, "K", "V", 9, "hello" +assert(#r == 7, "mixed: expected 7 slots, got " .. #r) +assert(r[1] == 5 and r[2] == "https://x.com", "mixed: csv slot mismatch") +assert(r[3] == 7 and r[4] == "K" and r[5] == "V", "mixed: map slot mismatch") +assert(r[6] == 9 and r[7] == "hello", "mixed: string slot mismatch") + +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"