From 91397f9f06f719ba185cc7b48aff4763d6529eea Mon Sep 17 00:00:00 2001 From: Benoit Blanchon Date: Wed, 9 Apr 2025 08:55:08 +0200 Subject: [PATCH] Optimize storage of tiny strings (up to 3 characters) --- CHANGELOG.md | 5 ++ extras/tests/JsonDeserializer/object.cpp | 19 +++--- extras/tests/JsonObject/set.cpp | 6 +- extras/tests/JsonVariant/as.cpp | 14 ++++- extras/tests/JsonVariant/set.cpp | 12 ++++ .../MsgPackDeserializer/deserializeArray.cpp | 23 +++++++- .../deserializeVariant.cpp | 33 ++++++----- extras/tests/ResourceManager/CMakeLists.txt | 1 + extras/tests/ResourceManager/StringBuffer.cpp | 50 ++++++++++++++++ .../tests/ResourceManager/StringBuilder.cpp | 58 ++++++++++++------- src/ArduinoJson/Memory/StringBuffer.hpp | 8 ++- src/ArduinoJson/Memory/StringBuilder.hpp | 11 +++- .../MsgPack/MsgPackDeserializer.hpp | 4 +- src/ArduinoJson/Variant/VariantContent.hpp | 4 ++ src/ArduinoJson/Variant/VariantData.hpp | 33 ++++++++++- src/ArduinoJson/Variant/VariantImpl.hpp | 5 ++ 16 files changed, 229 insertions(+), 57 deletions(-) create mode 100644 extras/tests/ResourceManager/StringBuffer.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fdb7dcf..ff2d14ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ ArduinoJson: change log ======================= +HEAD +---- + +* Optimize storage of tiny strings (up to 3 characters) + v7.3.1 (2025-02-27) ------ diff --git a/extras/tests/JsonDeserializer/object.cpp b/extras/tests/JsonDeserializer/object.cpp index ffae69ae..34b357ab 100644 --- a/extras/tests/JsonDeserializer/object.cpp +++ b/extras/tests/JsonDeserializer/object.cpp @@ -292,22 +292,23 @@ TEST_CASE("deserialize JSON object") { } SECTION("Repeated key") { - DeserializationError err = deserializeJson(doc, "{a:{b:{c:1}},a:2}"); + DeserializationError err = + deserializeJson(doc, "{alfa:{bravo:{charlie:1}},alfa:2}"); REQUIRE(err == DeserializationError::Ok); - REQUIRE(doc.as() == "{\"a\":2}"); + REQUIRE(doc.as() == "{\"alfa\":2}"); REQUIRE(spy.log() == AllocatorLog{ Allocate(sizeofStringBuffer()), Allocate(sizeofPool()), - Reallocate(sizeofStringBuffer(), sizeofString("a")), + Reallocate(sizeofStringBuffer(), sizeofString("alfa")), Allocate(sizeofStringBuffer()), - Reallocate(sizeofStringBuffer(), sizeofString("b")), + Reallocate(sizeofStringBuffer(), sizeofString("bravo")), Allocate(sizeofStringBuffer()), - Reallocate(sizeofStringBuffer(), sizeofString("c")), + Reallocate(sizeofStringBuffer(), sizeofString("charlie")), Allocate(sizeofStringBuffer()), - Deallocate(sizeofString("b")), - Deallocate(sizeofString("c")), + Deallocate(sizeofString("bravo")), + Deallocate(sizeofString("charlie")), Deallocate(sizeofStringBuffer()), Reallocate(sizeofPool(), sizeofObject(2) + sizeofObject(1)), }); @@ -389,11 +390,11 @@ TEST_CASE("deserialize JSON object under memory constraints") { SECTION("string allocation fails") { timebomb.setCountdown(3); - char input[] = "{\"a\":\"b\"}"; + char input[] = "{\"alfa\":\"bravo\"}"; DeserializationError err = deserializeJson(doc, input); REQUIRE(err == DeserializationError::NoMemory); - REQUIRE(doc.as() == "{\"a\":null}"); + REQUIRE(doc.as() == "{\"alfa\":null}"); } } diff --git a/extras/tests/JsonObject/set.cpp b/extras/tests/JsonObject/set.cpp index f58dae06..e5c3044d 100644 --- a/extras/tests/JsonObject/set.cpp +++ b/extras/tests/JsonObject/set.cpp @@ -100,13 +100,13 @@ TEST_CASE("JsonObject::set()") { JsonDocument doc3(&timebomb); JsonObject obj3 = doc3.to(); - obj1["a"_s] = 1; - obj1["b"_s] = 2; + obj1["alpha"_s] = 1; + obj1["beta"_s] = 2; bool success = obj3.set(obj1); REQUIRE(success == false); - REQUIRE(doc3.as() == "{\"a\":1}"); + REQUIRE(doc3.as() == "{\"alpha\":1}"); } SECTION("copy fails in the middle of an array") { diff --git a/extras/tests/JsonVariant/as.cpp b/extras/tests/JsonVariant/as.cpp index 65d5d6cf..b1235725 100644 --- a/extras/tests/JsonVariant/as.cpp +++ b/extras/tests/JsonVariant/as.cpp @@ -199,7 +199,7 @@ TEST_CASE("JsonVariant::as()") { REQUIRE(variant.as() == "hello"); } - SECTION("set(std::string(\"4.2\"))") { + SECTION("set(std::string(\"4.2\")) (tiny string optimization)") { variant.set("4.2"_s); REQUIRE(variant.as() == true); @@ -211,6 +211,18 @@ TEST_CASE("JsonVariant::as()") { REQUIRE(variant.as().isStatic() == false); } + SECTION("set(std::string(\"123.45\"))") { + variant.set("123.45"_s); + + REQUIRE(variant.as() == true); + REQUIRE(variant.as() == 123L); + REQUIRE(variant.as() == Approx(123.45)); + REQUIRE(variant.as() == "123.45"_s); + REQUIRE(variant.as() == "123.45"_s); + REQUIRE(variant.as() == "123.45"); + REQUIRE(variant.as().isStatic() == false); + } + SECTION("set(\"true\")") { variant.set("true"); diff --git a/extras/tests/JsonVariant/set.cpp b/extras/tests/JsonVariant/set.cpp index a8eafe6c..7d56818b 100644 --- a/extras/tests/JsonVariant/set.cpp +++ b/extras/tests/JsonVariant/set.cpp @@ -63,6 +63,18 @@ TEST_CASE("JsonVariant::set() when there is enough memory") { }); } + SECTION("char* (tiny string optimization)") { + char str[16]; + + strcpy(str, "abc"); + bool result = variant.set(str); + strcpy(str, "def"); + + REQUIRE(result == true); + REQUIRE(variant == "abc"); // stores by copy + REQUIRE(spy.log() == AllocatorLog{}); + } + SECTION("(char*)0") { bool result = variant.set(static_cast(0)); diff --git a/extras/tests/MsgPackDeserializer/deserializeArray.cpp b/extras/tests/MsgPackDeserializer/deserializeArray.cpp index 9aef088c..03409a97 100644 --- a/extras/tests/MsgPackDeserializer/deserializeArray.cpp +++ b/extras/tests/MsgPackDeserializer/deserializeArray.cpp @@ -5,8 +5,11 @@ #include #include +#include "Allocators.hpp" + TEST_CASE("deserialize MsgPack array") { - JsonDocument doc; + SpyingAllocator spy; + JsonDocument doc(&spy); SECTION("fixarray") { SECTION("empty") { @@ -30,6 +33,24 @@ TEST_CASE("deserialize MsgPack array") { REQUIRE(array[0] == 1); REQUIRE(array[1] == 2); } + + SECTION("tiny strings") { + DeserializationError error = + deserializeMsgPack(doc, "\x92\xA3xxx\xA3yyy"); + + REQUIRE(error == DeserializationError::Ok); + REQUIRE(doc.is()); + REQUIRE(doc.size() == 2); + REQUIRE(doc[0] == "xxx"); + REQUIRE(doc[1] == "yyy"); + REQUIRE(spy.log() == AllocatorLog{ + Allocate(sizeofPool()), + Allocate(sizeofString("xxx")), + // Buffer is reused for the next string + Deallocate(sizeofString("xxx")), + Reallocate(sizeofPool(), sizeofPool(2)), + }); + } } SECTION("array 16") { diff --git a/extras/tests/MsgPackDeserializer/deserializeVariant.cpp b/extras/tests/MsgPackDeserializer/deserializeVariant.cpp index 24f36c41..bbe88ed1 100644 --- a/extras/tests/MsgPackDeserializer/deserializeVariant.cpp +++ b/extras/tests/MsgPackDeserializer/deserializeVariant.cpp @@ -348,13 +348,14 @@ TEST_CASE("deserializeMsgPack() under memory constaints") { SECTION("{}") { checkError(0, "\x80", DeserializationError::Ok); } - SECTION("{H:1}") { - checkError(1, "\x81\xA1H\x01", DeserializationError::NoMemory); - checkError(2, "\x81\xA1H\x01", DeserializationError::Ok); + SECTION("{Hello:1}") { + checkError(1, "\x81\xA5Hello\x01", DeserializationError::NoMemory); + checkError(2, "\x81\xA5Hello\x01", DeserializationError::Ok); } - SECTION("{H:1,W:2}") { - checkError(2, "\x82\xA1H\x01\xA1W\x02", DeserializationError::NoMemory); - checkError(3, "\x82\xA1H\x01\xA1W\x02", DeserializationError::Ok); + SECTION("{Hello:1,World:2}") { + checkError(2, "\x82\xA5Hello\x01\xA5World\x02", + DeserializationError::NoMemory); + checkError(3, "\x82\xA5Hello\x01\xA5World\x02", DeserializationError::Ok); } } @@ -362,14 +363,16 @@ TEST_CASE("deserializeMsgPack() under memory constaints") { SECTION("{}") { checkError(0, "\xDE\x00\x00", DeserializationError::Ok); } - SECTION("{H:1}") { - checkError(1, "\xDE\x00\x01\xA1H\x01", DeserializationError::NoMemory); - checkError(2, "\xDE\x00\x01\xA1H\x01", DeserializationError::Ok); - } - SECTION("{H:1,W:2}") { - checkError(2, "\xDE\x00\x02\xA1H\x01\xA1W\x02", + SECTION("{Hello:1}") { + checkError(1, "\xDE\x00\x01\xA5Hello\x01", DeserializationError::NoMemory); - checkError(3, "\xDE\x00\x02\xA1H\x01\xA1W\x02", DeserializationError::Ok); + checkError(2, "\xDE\x00\x01\xA5Hello\x01", DeserializationError::Ok); + } + SECTION("{Hello:1,World:2}") { + checkError(2, "\xDE\x00\x02\xA5Hello\x01\xA5World\x02", + DeserializationError::NoMemory); + checkError(3, "\xDE\x00\x02\xA5Hello\x01\xA5World\x02", + DeserializationError::Ok); } } @@ -382,8 +385,8 @@ TEST_CASE("deserializeMsgPack() under memory constaints") { DeserializationError::NoMemory); checkError(2, "\xDF\x00\x00\x00\x01\xA1H\x01", DeserializationError::Ok); } - SECTION("{H:1,W:2}") { - checkError(2, "\xDF\x00\x00\x00\x02\xA1H\x01\xA1W\x02", + SECTION("{Hello:1,World:2}") { + checkError(2, "\xDF\x00\x00\x00\x02\xA5Hello\x01\xA5World\x02", DeserializationError::NoMemory); checkError(3, "\xDF\x00\x00\x00\x02\xA1H\x01\xA1W\x02", DeserializationError::Ok); diff --git a/extras/tests/ResourceManager/CMakeLists.txt b/extras/tests/ResourceManager/CMakeLists.txt index 27ff0e90..3a0908af 100644 --- a/extras/tests/ResourceManager/CMakeLists.txt +++ b/extras/tests/ResourceManager/CMakeLists.txt @@ -8,6 +8,7 @@ add_executable(ResourceManagerTests saveString.cpp shrinkToFit.cpp size.cpp + StringBuffer.cpp StringBuilder.cpp swap.cpp ) diff --git a/extras/tests/ResourceManager/StringBuffer.cpp b/extras/tests/ResourceManager/StringBuffer.cpp new file mode 100644 index 00000000..1fb2b01b --- /dev/null +++ b/extras/tests/ResourceManager/StringBuffer.cpp @@ -0,0 +1,50 @@ +// ArduinoJson - https://arduinojson.org +// Copyright © 2014-2025, Benoit BLANCHON +// MIT License + +#include +#include + +#include "Allocators.hpp" +#include "Literals.hpp" + +using namespace ArduinoJson::detail; + +TEST_CASE("StringBuffer") { + SpyingAllocator spy; + ResourceManager resources(&spy); + StringBuffer sb(&resources); + VariantData variant; + + SECTION("Tiny string") { + auto ptr = sb.reserve(3); + strcpy(ptr, "hi!"); + sb.save(&variant); + + REQUIRE(variant.type() == VariantType::TinyString); + REQUIRE(variant.asString() == "hi!"); + } + + SECTION("Tiny string can't contain NUL") { + auto ptr = sb.reserve(3); + memcpy(ptr, "a\0b", 3); + sb.save(&variant); + + REQUIRE(variant.type() == VariantType::OwnedString); + + auto str = variant.asString(); + REQUIRE(str.size() == 3); + REQUIRE(str.c_str()[0] == 'a'); + REQUIRE(str.c_str()[1] == 0); + REQUIRE(str.c_str()[2] == 'b'); + } + + SECTION("Tiny string can't have 4 characters") { + auto ptr = sb.reserve(4); + strcpy(ptr, "alfa"); + sb.save(&variant); + + REQUIRE(variant.type() == VariantType::OwnedString); + REQUIRE(variant.asString() == "alfa"); + } +} diff --git a/extras/tests/ResourceManager/StringBuilder.cpp b/extras/tests/ResourceManager/StringBuilder.cpp index 9a122619..a63661bd 100644 --- a/extras/tests/ResourceManager/StringBuilder.cpp +++ b/extras/tests/ResourceManager/StringBuilder.cpp @@ -6,8 +6,8 @@ #include #include "Allocators.hpp" -#include "Literals.hpp" +using namespace ArduinoJson; using namespace ArduinoJson::detail; TEST_CASE("StringBuilder") { @@ -22,13 +22,31 @@ TEST_CASE("StringBuilder") { str.startString(); str.save(&data); - REQUIRE(resources.size() == sizeofString("")); REQUIRE(resources.overflowed() == false); - REQUIRE(spyingAllocator.log() == - AllocatorLog{ - Allocate(sizeofStringBuffer()), - Reallocate(sizeofStringBuffer(), sizeofString("")), - }); + REQUIRE(spyingAllocator.log() == AllocatorLog{ + Allocate(sizeofStringBuffer()), + }); + REQUIRE(data.type() == VariantType::TinyString); + } + + SECTION("Tiny string") { + StringBuilder str(&resources); + + str.startString(); + str.append("url"); + + REQUIRE(str.isValid() == true); + REQUIRE(str.str() == "url"); + REQUIRE(spyingAllocator.log() == AllocatorLog{ + Allocate(sizeofStringBuffer()), + }); + + VariantData data; + str.save(&data); + + REQUIRE(resources.overflowed() == false); + REQUIRE(data.type() == VariantType::TinyString); + REQUIRE(data.asString() == "url"); } SECTION("Short string fits in first allocation") { @@ -98,12 +116,12 @@ TEST_CASE("StringBuilder") { } } -static const char* saveString(StringBuilder& builder, const char* s) { +static JsonString saveString(StringBuilder& builder, const char* s) { VariantData data; builder.startString(); builder.append(s); builder.save(&data); - return data.asString().c_str(); + return data.asString(); } TEST_CASE("StringBuilder::save() deduplicates strings") { @@ -116,9 +134,9 @@ TEST_CASE("StringBuilder::save() deduplicates strings") { auto s2 = saveString(builder, "world"); auto s3 = saveString(builder, "hello"); - REQUIRE(s1 == "hello"_s); - REQUIRE(s2 == "world"_s); - REQUIRE(+s1 == +s3); // same address + REQUIRE(s1 == "hello"); + REQUIRE(s2 == "world"); + REQUIRE(+s1.c_str() == +s3.c_str()); // same address REQUIRE(spy.log() == AllocatorLog{ @@ -134,9 +152,9 @@ TEST_CASE("StringBuilder::save() deduplicates strings") { auto s1 = saveString(builder, "hello world"); auto s2 = saveString(builder, "hello"); - REQUIRE(s1 == "hello world"_s); - REQUIRE(s2 == "hello"_s); - REQUIRE(+s2 != +s1); // different address + REQUIRE(s1 == "hello world"); + REQUIRE(s2 == "hello"); + REQUIRE(+s2.c_str() != +s1.c_str()); // different address REQUIRE(spy.log() == AllocatorLog{ @@ -149,18 +167,18 @@ TEST_CASE("StringBuilder::save() deduplicates strings") { SECTION("Don't overrun") { auto s1 = saveString(builder, "hello world"); - auto s2 = saveString(builder, "wor"); + auto s2 = saveString(builder, "worl"); - REQUIRE(s1 == "hello world"_s); - REQUIRE(s2 == "wor"_s); - REQUIRE(s2 != s1); + REQUIRE(s1 == "hello world"); + REQUIRE(s2 == "worl"); + REQUIRE(s2.c_str() != s1.c_str()); // different address REQUIRE(spy.log() == AllocatorLog{ Allocate(sizeofStringBuffer()), Reallocate(sizeofStringBuffer(), sizeofString("hello world")), Allocate(sizeofStringBuffer()), - Reallocate(sizeofStringBuffer(), sizeofString("wor")), + Reallocate(sizeofStringBuffer(), sizeofString("worl")), }); } } diff --git a/src/ArduinoJson/Memory/StringBuffer.hpp b/src/ArduinoJson/Memory/StringBuffer.hpp index 43297a15..91a1cd15 100644 --- a/src/ArduinoJson/Memory/StringBuffer.hpp +++ b/src/ArduinoJson/Memory/StringBuffer.hpp @@ -34,12 +34,16 @@ class StringBuffer { JsonString str() const { ARDUINOJSON_ASSERT(node_ != nullptr); - return JsonString(node_->data, node_->length); } void save(VariantData* data) { - data->setOwnedString(commitStringNode()); + ARDUINOJSON_ASSERT(node_ != nullptr); + const char* s = node_->data; + if (isTinyString(s, size_)) + data->setTinyString(s, static_cast(size_)); + else + data->setOwnedString(commitStringNode()); } void saveRaw(VariantData* data) { diff --git a/src/ArduinoJson/Memory/StringBuilder.hpp b/src/ArduinoJson/Memory/StringBuilder.hpp index 05c9a920..7ed77a7d 100644 --- a/src/ArduinoJson/Memory/StringBuilder.hpp +++ b/src/ArduinoJson/Memory/StringBuilder.hpp @@ -28,8 +28,15 @@ class StringBuilder { void save(VariantData* variant) { ARDUINOJSON_ASSERT(variant != nullptr); ARDUINOJSON_ASSERT(node_ != nullptr); - node_->data[size_] = 0; - StringNode* node = resources_->getString(adaptString(node_->data, size_)); + + char* p = node_->data; + if (isTinyString(p, size_)) { + variant->setTinyString(p, static_cast(size_)); + return; + } + + p[size_] = 0; + StringNode* node = resources_->getString(adaptString(p, size_)); if (!node) { node = resources_->resizeString(node_, size_); ARDUINOJSON_ASSERT(node != nullptr); // realloc to smaller can't fail diff --git a/src/ArduinoJson/MsgPack/MsgPackDeserializer.hpp b/src/ArduinoJson/MsgPack/MsgPackDeserializer.hpp index c532ce70..904da179 100644 --- a/src/ArduinoJson/MsgPack/MsgPackDeserializer.hpp +++ b/src/ArduinoJson/MsgPack/MsgPackDeserializer.hpp @@ -403,7 +403,7 @@ class MsgPackDeserializer { JsonString key = stringBuffer_.str(); TFilter memberFilter = filter[key.c_str()]; - VariantData* member; + VariantData* member = 0; if (memberFilter.allow()) { ARDUINOJSON_ASSERT(object != 0); @@ -413,8 +413,6 @@ class MsgPackDeserializer { return DeserializationError::NoMemory; stringBuffer_.save(keyVariant); - } else { - member = 0; } err = parseVariant(member, memberFilter, nestingLimit.decrement()); diff --git a/src/ArduinoJson/Variant/VariantContent.hpp b/src/ArduinoJson/Variant/VariantContent.hpp index ac59172b..d6ee6c78 100644 --- a/src/ArduinoJson/Variant/VariantContent.hpp +++ b/src/ArduinoJson/Variant/VariantContent.hpp @@ -24,6 +24,7 @@ enum class VariantTypeBits : uint8_t { enum class VariantType : uint8_t { Null = 0, // 0000 0000 + TinyString = 0x02, // 0000 0010 RawString = 0x03, // 0000 0011 LinkedString = 0x04, // 0000 0100 OwnedString = 0x05, // 0000 0101 @@ -46,6 +47,8 @@ inline bool operator&(VariantType type, VariantTypeBits bit) { return (uint8_t(type) & uint8_t(bit)) != 0; } +const size_t tinyStringMaxLength = 3; + union VariantContent { VariantContent() {} @@ -61,6 +64,7 @@ union VariantContent { CollectionData asCollection; const char* asLinkedString; struct StringNode* asOwnedString; + char asTinyString[tinyStringMaxLength + 1]; }; #if ARDUINOJSON_USE_EXTENSIONS diff --git a/src/ArduinoJson/Variant/VariantData.hpp b/src/ArduinoJson/Variant/VariantData.hpp index ab00b42d..a790bfaf 100644 --- a/src/ArduinoJson/Variant/VariantData.hpp +++ b/src/ArduinoJson/Variant/VariantData.hpp @@ -17,6 +17,16 @@ ARDUINOJSON_BEGIN_PRIVATE_NAMESPACE template T parseNumber(const char* s); +template +static bool isTinyString(const T& s, size_t n) { + if (n > tinyStringMaxLength) + return false; + bool containsNul = false; + for (uint8_t i = 0; i < uint8_t(n); i++) + containsNul |= !s[i]; + return !containsNul; +} + class VariantData { VariantContent content_; // must be first to allow cast from array to variant VariantType type_; @@ -63,6 +73,9 @@ class VariantData { case VariantType::Object: return visit.visit(content_.asObject); + case VariantType::TinyString: + return visit.visit(JsonString(content_.asTinyString)); + case VariantType::LinkedString: return visit.visit(JsonString(content_.asLinkedString, true)); @@ -199,6 +212,9 @@ class VariantData { case VariantType::Int64: return static_cast(extension->asInt64); #endif + case VariantType::TinyString: + str = content_.asTinyString; + break; case VariantType::LinkedString: str = content_.asLinkedString; break; @@ -241,6 +257,9 @@ class VariantData { case VariantType::Int64: return convertNumber(extension->asInt64); #endif + case VariantType::TinyString: + str = content_.asTinyString; + break; case VariantType::LinkedString: str = content_.asLinkedString; break; @@ -281,6 +300,8 @@ class VariantData { JsonString asString() const { switch (type_) { + case VariantType::TinyString: + return JsonString(content_.asTinyString); case VariantType::LinkedString: return JsonString(content_.asLinkedString, true); case VariantType::OwnedString: @@ -395,7 +416,8 @@ class VariantData { bool isString() const { return type_ == VariantType::LinkedString || - type_ == VariantType::OwnedString; + type_ == VariantType::OwnedString || + type_ == VariantType::TinyString; } size_t nesting(const ResourceManager* resources) const { @@ -504,6 +526,15 @@ class VariantData { content_.asLinkedString = s; } + void setTinyString(const char* s, uint8_t n) { + ARDUINOJSON_ASSERT(type_ == VariantType::Null); // must call clear() first + ARDUINOJSON_ASSERT(s); + type_ = VariantType::TinyString; + for (uint8_t i = 0; i < n; i++) + content_.asTinyString[i] = s[i]; + content_.asTinyString[n] = 0; + } + void setOwnedString(StringNode* s) { ARDUINOJSON_ASSERT(type_ == VariantType::Null); // must call clear() first ARDUINOJSON_ASSERT(s); diff --git a/src/ArduinoJson/Variant/VariantImpl.hpp b/src/ArduinoJson/Variant/VariantImpl.hpp index cc5fd527..812624ff 100644 --- a/src/ArduinoJson/Variant/VariantImpl.hpp +++ b/src/ArduinoJson/Variant/VariantImpl.hpp @@ -31,6 +31,11 @@ inline bool VariantData::setString(TAdaptedString value, return true; } + if (isTinyString(value, value.size())) { + setTinyString(value.data(), uint8_t(value.size())); + return true; + } + auto dup = resources->saveString(value); if (dup) { setOwnedString(dup);