From 83a28e97f95eac22d3841418d7c7e1631a9287b6 Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Thu, 4 Jun 2026 17:08:40 +0000 Subject: [PATCH] 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"