From faf00f6bbc68e92b37eaf8a75e9681bb9a11b663 Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Fri, 10 Jul 2026 19:00:01 +0000 Subject: [PATCH] Allow ruleset tables to be passed to legacy functions. --- VM/include/llruleset_builder.h | 7 ++++ VM/src/llruleset_builder.cpp | 34 +++++++++++++++++ tests/SLConformance.test.cpp | 28 ++++++++++++++ tests/conformance/ruleset_coerce.lua | 57 ++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 tests/conformance/ruleset_coerce.lua diff --git a/VM/include/llruleset_builder.h b/VM/include/llruleset_builder.h index 6d082e92..a85eea1d 100644 --- a/VM/include/llruleset_builder.h +++ b/VM/include/llruleset_builder.h @@ -65,6 +65,13 @@ void ruleset_builder_def_add_flags( // Flag boolean properties are merged into their backing integer field before emission. void slua_ruleset_serialize(lua_State* L, int params_idx, const RulesetBuilderDef* def); +// Coerce a dict-style params table to a flat rules list in-place. +// If the value at params_idx is a dict (table with no integer key 1), serializes it +// using slua_ruleset_serialize and replaces the stack slot with the result. +// If the value is already a sequential list, nil, or non-table, leaves it unchanged. +// Returns true if coercion was performed, false otherwise. +bool slua_ruleset_coerce(lua_State* L, int params_idx, const RulesetBuilderDef* 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. diff --git a/VM/src/llruleset_builder.cpp b/VM/src/llruleset_builder.cpp index 318b89a4..9626806d 100644 --- a/VM/src/llruleset_builder.cpp +++ b/VM/src/llruleset_builder.cpp @@ -296,6 +296,40 @@ void slua_ruleset_serialize(lua_State* L, int params_idx, const RulesetBuilderDe } } +bool slua_ruleset_coerce(lua_State* L, int params_idx, const RulesetBuilderDef* def) +{ + // Convert relative index to absolute + if (params_idx < 0 && params_idx > LUA_REGISTRYINDEX) + params_idx = lua_gettop(L) + params_idx + 1; + + // Not a table? Leave unchanged. + if (!lua_istable(L, params_idx)) + return false; + + // Check if this is a dict (no integer key 1) vs sequential list (has key 1). + lua_rawgeti(L, params_idx, 1); + bool has_first = !lua_isnil(L, -1); + lua_pop(L, 1); + + if (has_first) + return false; // Sequential list, leave unchanged + + // Check if table has any keys at all. + lua_pushnil(L); + bool has_keys = (lua_next(L, params_idx) != 0); + if (!has_keys) + return false; // Empty table, already a valid empty list + lua_pop(L, 2); // Pop key and value + + // Serialize dict to flat list (pushes new table on stack) + slua_ruleset_serialize(L, params_idx, def); + + // Replace original table with serialized list + lua_replace(L, params_idx); + + return true; +} + void slua_register_ruleset_fn( lua_State* L, const char* module_name, diff --git a/tests/SLConformance.test.cpp b/tests/SLConformance.test.cpp index 099a0134..d13afbd6 100644 --- a/tests/SLConformance.test.cpp +++ b/tests/SLConformance.test.cpp @@ -1566,4 +1566,32 @@ TEST_CASE("ruleset_builder_collection") }); } +TEST_CASE("slua_ruleset_coerce") +{ + // Simple descriptor table for testing coercion. + static const RulesetParamDescriptor kDescs[] = { + {"alpha", 'f', 1}, + {"name", 's', 2}, + {"count", 'i', 3}, + }; + static RulesetBuilderDef* s_def = []() { + return ruleset_builder_def_build(kDescs, std::size(kDescs)); + }(); + + runConformance("ruleset_coerce.lua", nullptr, [](lua_State* L) { + // Test function: coerces input, returns (did_coerce, result). + auto coerce_test = [](lua_State* L) -> int { + const auto* def = (const RulesetBuilderDef*)lua_tolightuserdata(L, lua_upvalueindex(1)); + lua_settop(L, 1); // Ensure exactly one argument + bool did_coerce = slua_ruleset_coerce(L, 1, def); + lua_pushboolean(L, did_coerce); + lua_insert(L, 1); // [did_coerce, coerced_or_original] + return 2; + }; + lua_pushlightuserdata(L, (void*)s_def); + lua_pushcclosure(L, coerce_test, nullptr, 1); + lua_setglobal(L, "test_coerce"); + }); +} + TEST_SUITE_END(); diff --git a/tests/conformance/ruleset_coerce.lua b/tests/conformance/ruleset_coerce.lua new file mode 100644 index 00000000..999ffacc --- /dev/null +++ b/tests/conformance/ruleset_coerce.lua @@ -0,0 +1,57 @@ +-- Conformance tests for slua_ruleset_coerce(). +-- +-- The C++ harness registers test_coerce(value) which calls slua_ruleset_coerce +-- on the input and returns (did_coerce, result). +-- Descriptor table: alpha='f'/1, name='s'/2, count='i'/3 + +-- ─── Dict tables should be coerced ─────────────────────────────────────────── + +-- Basic dict with multiple fields +local did, result = test_coerce({alpha = 0.5, name = "test", count = 42}) +assert(did == true, "dict should be coerced") +assert(type(result) == "table", "result should be a table") +-- Verify serialized output: tags in order 1, 2, 3 +assert(result[1] == 1, "first tag should be alpha (1)") +assert(result[2] == 0.5, "alpha value") +assert(result[3] == 2, "second tag should be name (2)") +assert(result[4] == "test", "name value") +assert(result[5] == 3, "third tag should be count (3)") +assert(result[6] == 42, "count value") + +-- Single field dict +local did2, result2 = test_coerce({name = "solo"}) +assert(did2 == true, "single-field dict should be coerced") +assert(result2[1] == 2 and result2[2] == "solo", "single field serialized correctly") + +-- ─── Sequential lists should pass through unchanged ────────────────────────── + +-- Flat rules list (has integer key 1) +local did3, result3 = test_coerce({1, 0.5, 2, "test"}) +assert(did3 == false, "sequential list should not be coerced") +assert(result3[1] == 1, "list unchanged") +assert(result3[2] == 0.5, "list unchanged") +assert(result3[3] == 2, "list unchanged") +assert(result3[4] == "test", "list unchanged") + +-- ─── Empty table should pass through unchanged ─────────────────────────────── + +local did4, result4 = test_coerce({}) +assert(did4 == false, "empty table should not be coerced") +assert(type(result4) == "table", "empty table passed through") +assert(next(result4) == nil, "table is still empty") + +-- ─── Non-table values should pass through unchanged ────────────────────────── + +local did5, result5 = test_coerce(nil) +assert(did5 == false, "nil should not be coerced") +assert(result5 == nil, "nil passed through") + +local did6, result6 = test_coerce(123) +assert(did6 == false, "number should not be coerced") +assert(result6 == 123, "number passed through") + +local did7, result7 = test_coerce("hello") +assert(did7 == false, "string should not be coerced") +assert(result7 == "hello", "string passed through") + +return "OK"