diff --git a/CHANGELOG.md b/CHANGELOG.md index 586594e8..de21a4a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,7 +151,9 @@ The direct C++ parser bridge (docs only, no code change): the host reads objects through `pj.parser_functional.v2` only. - 1.0.0 itself changes two of those layouts: `sdk::Log` (`category`) and `sdk::DepthImage` (`frame_id`). A parser built against 0.x that returns either type must be rebuilt for a 1.0 host. - No parser in pj-official-plugins returns them. + In pj-official-plugins no parser returns `DepthImage`, and `parser_ros` returns `Log` only from + 1.4.0, which is built against the 1.0 layout. Both members are appended at the tail, so a 0.x + host reading an object from a 1.0 parser sees the members it knows at their old offsets. Builtin decoders: @@ -179,6 +181,23 @@ Builtin decoders: the field is rejected. - A NaN colour channel decodes as 0; it used to reach an undefined float-to-integer conversion. +Three changes that could not be made after the freeze: + +- **Breaking (source, scripts):** the timestamp member of four builtin structs is renamed from + `timestamp` to `timestamp_ns`, the name the other sixteen already use: `sdk::ImageAnnotations`, + `sdk::FrameTransform`, `sdk::SceneEntity` and `sdk::SceneEntityDeletion`. Their field-table name + changes with it, so a script reads `timestamp_ns` on these types too. The wire format is + unchanged (the proto field is still `timestamp`); only code and scripts that name the member must + be updated. +- Dialog tree events no longer have to be the only top-level key of their event. `DialogPluginTyped` + dispatched a tree event only when its key was alone, and that check is compiled into each plugin, + so no later host could have added a key to the envelope. A valid tree key now claims the event and + other keys are ignored, as beside a file-picker result. Two tree keys, or a malformed tree + payload, still fail closed. +- `PJ_widget_value_t` gains `uint32_t flags`, naming the 4 bytes that were alignment padding after + `kind`. Size, alignment and every offset are unchanged. No bit is defined: a host built against + 1.0 or later writes 0, and a host built earlier leaves the bytes unset. + ## [0.40.0] Host contract: extended: pj.mcp_endpoint.v1, PJ_mcp_endpoint_host_vtable_t::acquire_endpoint, diff --git a/docs/builtin_type.md b/docs/builtin_type.md index 596642bd..e60e62cf 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -314,7 +314,7 @@ stream. | Field | Type | Notes | |-------|------|-------| -| `timestamp` | `Timestamp` | Timestamp associated with the annotation set. | +| `timestamp_ns` | `Timestamp` | Timestamp associated with the annotation set. | | `image_topic` | `std::string` | Topic of the image these annotations overlay. | | `points` | `std::vector` | Points, line lists, line strips, and line loops. | | `circles` | `std::vector` | Filled or stroked circles in image-pixel space. | @@ -357,7 +357,7 @@ Each `FrameTransform` contains: | Field | Type | Notes | |-------|------|-------| -| `timestamp` | `Timestamp` | Timestamp associated with the transform. | +| `timestamp_ns` | `Timestamp` | Timestamp associated with the transform. | | `parent_frame_id` | `std::string` | Name of the parent reference frame. | | `child_frame_id` | `std::string` | Name of the child reference frame. | | `translation` | `Vector3` | Child-frame origin in parent-frame coordinates. | @@ -503,10 +503,10 @@ axes glyphs, or model (mesh asset) references. | Field on `SceneEntity` | Type | Notes | |------------------------|------|-------| -| `timestamp` | `Timestamp` | Stamp used together with `lifetime_ns` to control expiry. | +| `timestamp_ns` | `Timestamp` | Stamp used together with `lifetime_ns` to control expiry. | | `frame_id` | `std::string` | Frame the entity's primitives are expressed in. | | `id` | `std::string` | Republishing with the same `(topic, id)` replaces the previous entity. | -| `lifetime_ns` | `int64_t` | `0` means persist until replaced; otherwise expire `lifetime_ns` after `timestamp`. | +| `lifetime_ns` | `int64_t` | `0` means persist until replaced; otherwise expire `lifetime_ns` after `timestamp_ns`. | | `frame_locked` | `bool` | When true, track `frame_id` as it moves; when false, stamp into the fixed frame at publish time. | | `arrows` / `cubes` / `spheres` / `cylinders` / `lines` / `triangles` / `texts` / `axes` / `models` | `std::vector<…Primitive>` | Heterogeneous primitive lists. `models` references a mesh asset by `url` or inline `data`. | diff --git a/docs/dialog-sdk-reference.md b/docs/dialog-sdk-reference.md index 2fa1339f..550b5828 100644 --- a/docs/dialog-sdk-reference.md +++ b/docs/dialog-sdk-reference.md @@ -423,9 +423,10 @@ if that channel is malformed, dispatch returns `false` without falling through. an object with a non-empty string `reason` and string `kind`/`topic`/`dataset` when present; otherwise dispatch fails closed. Unknown keys inside it are tolerated. 3. In the absence of picker and scene-view-error keys, any tree event key claims the event. - Exactly one of the four tree keys must be the sole top-level key and its - payload must validate atomically. Multiple tree keys or any tree/other-key - mixture fails closed. + Exactly one of the four tree keys must be present and its payload must + validate atomically; two tree keys, or a malformed payload, fail closed. + Other top-level keys are tolerated and do not receive callbacks, stacked and + legacy keys included. 4. In the absence of picker and tree keys, either stacked key claims the event. Both `stacked_index` and `stacked_page` are required; the index must decode losslessly in `0..INT_MAX` and the page name must be non-empty. Invalid or diff --git a/docs/image_annotations_format.md b/docs/image_annotations_format.md index 458630ac..e85a4b8b 100644 --- a/docs/image_annotations_format.md +++ b/docs/image_annotations_format.md @@ -66,7 +66,7 @@ the renderable annotation fields and ignores the rest: | `ImageAnnotations.circles` | Mapped to `ImageAnnotations::circles`. | | `ImageAnnotations.points` | Mapped to `ImageAnnotations::points`. | | `ImageAnnotations.texts` | Mapped to `ImageAnnotations::texts`. | -| Top-level `timestamp` | Serialized when non-zero, decoded into `ImageAnnotations::timestamp`; a timestamp of 0 is indistinguishable from an absent one. | +| Top-level `timestamp` | Serialized when non-zero, decoded into `ImageAnnotations::timestamp_ns`; a timestamp of 0 is indistinguishable from an absent one. | | `image_topic` (field 6, string) | Serialized when non-empty, decoded into `ImageAnnotations::image_topic`. | | Top-level `metadata` | Not serialized or decoded today. | | Per-annotation `timestamp` | Not serialized or decoded today. | @@ -104,8 +104,8 @@ values to `kPoints`, so incomplete or forward-compatible payloads still decode to a renderable annotation. An annotation value serializes to an empty byte buffer only when it has no circles, -points, or texts, a zero `timestamp`, and an empty `image_topic`; one carrying just a -non-zero `timestamp` and/or an `image_topic` produces a non-empty payload. Decoding an +points, or texts, a zero `timestamp_ns`, and an empty `image_topic`; one carrying just a +non-zero `timestamp_ns` and/or an `image_topic` produces a non-empty payload. Decoding an empty buffer is treated as invalid input by the current reader. The reader accepts protobuf wire types `VARINT`, `I64`, `LEN`, and `I32`. It diff --git a/pj_base/include/pj_base/builtin/frame_transforms.hpp b/pj_base/include/pj_base/builtin/frame_transforms.hpp index 1f42482e..2276647d 100644 --- a/pj_base/include/pj_base/builtin/frame_transforms.hpp +++ b/pj_base/include/pj_base/builtin/frame_transforms.hpp @@ -53,7 +53,7 @@ struct Pose { /// Transform from `parent_frame_id` to `child_frame_id`. struct FrameTransform { - Timestamp timestamp = 0; + Timestamp timestamp_ns = 0; std::string parent_frame_id; std::string child_frame_id; Vector3 translation; diff --git a/pj_base/include/pj_base/builtin/frame_transforms_fields.hpp b/pj_base/include/pj_base/builtin/frame_transforms_fields.hpp index ba2876b5..f9dbf48e 100644 --- a/pj_base/include/pj_base/builtin/frame_transforms_fields.hpp +++ b/pj_base/include/pj_base/builtin/frame_transforms_fields.hpp @@ -63,7 +63,7 @@ struct FieldTable { template <> struct FieldTable { static constexpr std::array fields{ - field<&FrameTransform::timestamp>("timestamp"), + field<&FrameTransform::timestamp_ns>("timestamp_ns"), field<&FrameTransform::parent_frame_id>("parent_frame_id"), field<&FrameTransform::child_frame_id>("child_frame_id"), field<&FrameTransform::translation>("translation"), diff --git a/pj_base/include/pj_base/builtin/image_annotations.hpp b/pj_base/include/pj_base/builtin/image_annotations.hpp index d03fa95c..11d34134 100644 --- a/pj_base/include/pj_base/builtin/image_annotations.hpp +++ b/pj_base/include/pj_base/builtin/image_annotations.hpp @@ -85,7 +85,7 @@ struct TextAnnotation { /// All annotations for one image at one timestamp. References its base image /// explicitly via `image_topic` so the renderer knows which frame to overlay. struct ImageAnnotations { - Timestamp timestamp = 0; + Timestamp timestamp_ns = 0; std::string image_topic; std::vector points; std::vector circles; diff --git a/pj_base/include/pj_base/builtin/image_annotations_fields.hpp b/pj_base/include/pj_base/builtin/image_annotations_fields.hpp index 281c3e84..cddbf7bf 100644 --- a/pj_base/include/pj_base/builtin/image_annotations_fields.hpp +++ b/pj_base/include/pj_base/builtin/image_annotations_fields.hpp @@ -78,8 +78,10 @@ struct FieldTable { template <> struct FieldTable { static constexpr std::array fields{ - field<&ImageAnnotations::timestamp>("timestamp"), field<&ImageAnnotations::image_topic>("image_topic"), - field<&ImageAnnotations::points>("points"), field<&ImageAnnotations::circles>("circles"), + field<&ImageAnnotations::timestamp_ns>("timestamp_ns"), + field<&ImageAnnotations::image_topic>("image_topic"), + field<&ImageAnnotations::points>("points"), + field<&ImageAnnotations::circles>("circles"), field<&ImageAnnotations::texts>("texts"), }; static constexpr FieldTableView view{"ImageAnnotations", Span(fields)}; diff --git a/pj_base/include/pj_base/builtin/scene_entities.hpp b/pj_base/include/pj_base/builtin/scene_entities.hpp index 3e21b165..1f110bf0 100644 --- a/pj_base/include/pj_base/builtin/scene_entities.hpp +++ b/pj_base/include/pj_base/builtin/scene_entities.hpp @@ -151,12 +151,12 @@ struct KeyValuePair { /// same id on the same topic replaces the previous entity. /// /// Lifetime: zero means persist until replaced or deleted; otherwise the -/// entity is removed `lifetime_ns` after `timestamp`. +/// entity is removed `lifetime_ns` after `timestamp_ns`. /// /// frame_locked: when true, the entity tracks `frame_id` as it moves; when /// false, it is stamped into the fixed frame at publish time. struct SceneEntity { - Timestamp timestamp = 0; + Timestamp timestamp_ns = 0; std::string frame_id; std::string id; int64_t lifetime_ns = 0; ///< 0 means persist until replaced. @@ -190,8 +190,8 @@ struct SceneEntityDeletion { kAll = 1, ///< Delete every entity on this topic. }; Type type = Type::kMatchingId; - Timestamp timestamp = 0; ///< Bounds the deletion to entities at or before this time. - std::string id; ///< Entity id to match when `type == kMatchingId`. + Timestamp timestamp_ns = 0; ///< Bounds the deletion to entities at or before this time. + std::string id; ///< Entity id to match when `type == kMatchingId`. bool operator==(const SceneEntityDeletion&) const = default; }; diff --git a/pj_base/include/pj_base/builtin/scene_entities_fields.hpp b/pj_base/include/pj_base/builtin/scene_entities_fields.hpp index 4ffbdccf..f875ee7e 100644 --- a/pj_base/include/pj_base/builtin/scene_entities_fields.hpp +++ b/pj_base/include/pj_base/builtin/scene_entities_fields.hpp @@ -168,7 +168,7 @@ struct FieldTable { template <> struct FieldTable { static constexpr std::array fields{ - field<&SceneEntity::timestamp>("timestamp"), + field<&SceneEntity::timestamp_ns>("timestamp_ns"), field<&SceneEntity::frame_id>("frame_id"), field<&SceneEntity::id>("id"), field<&SceneEntity::lifetime_ns>("lifetime_ns"), @@ -192,7 +192,7 @@ template <> struct FieldTable { static constexpr std::array fields{ field<&SceneEntityDeletion::type>("type"), - field<&SceneEntityDeletion::timestamp>("timestamp"), + field<&SceneEntityDeletion::timestamp_ns>("timestamp_ns"), field<&SceneEntityDeletion::id>("id"), }; static constexpr FieldTableView view{"SceneEntityDeletion", Span(fields)}; diff --git a/pj_base/src/builtin/frame_transforms_codec.cpp b/pj_base/src/builtin/frame_transforms_codec.cpp index 90cab2dc..18413eb3 100644 --- a/pj_base/src/builtin/frame_transforms_codec.cpp +++ b/pj_base/src/builtin/frame_transforms_codec.cpp @@ -23,7 +23,7 @@ using sdk::FrameTransform; using sdk::FrameTransforms; void writeFrameTransform(Writer& writer, const FrameTransform& transform) { - writer.message(1, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, transform.timestamp); }); + writer.message(1, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, transform.timestamp_ns); }); writer.string(2, transform.parent_frame_id); writer.string(3, transform.child_frame_id); writer.message(4, [&](Writer& nested) { builtin_wire::writeVector3(nested, transform.translation); }); @@ -37,7 +37,7 @@ bool decodeFrameTransform(Reader& reader, FrameTransform& out) { } switch (tag.field) { case 1: - return builtin_wire::readTimestampMessage(r, out.timestamp); + return builtin_wire::readTimestampMessage(r, out.timestamp_ns); case 2: return r.readString(out.parent_frame_id); case 3: diff --git a/pj_base/src/builtin/image_annotations_codec.cpp b/pj_base/src/builtin/image_annotations_codec.cpp index bd503bf7..9d0be818 100644 --- a/pj_base/src/builtin/image_annotations_codec.cpp +++ b/pj_base/src/builtin/image_annotations_codec.cpp @@ -343,8 +343,8 @@ std::vector serializeImageAnnotations(const ImageAnnotations& annotatio writer.message(3, [&](Writer& nested) { writeTextAnnotation(nested, text); }); } - if (annotations.timestamp != 0) { - writer.message(5, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, annotations.timestamp); }); + if (annotations.timestamp_ns != 0) { + writer.message(5, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, annotations.timestamp_ns); }); } if (!annotations.image_topic.empty()) { writer.string(6, annotations.image_topic); @@ -418,7 +418,7 @@ Expected deserializeImageAnnotations(const uint8_t* data, break; } case 5: { - if (!builtin_wire::decodeTimestamp(nested, annotations.timestamp)) { + if (!builtin_wire::decodeTimestamp(nested, annotations.timestamp_ns)) { return unexpected(std::string("ImageAnnotations wire: Timestamp decode failed")); } break; diff --git a/pj_base/src/builtin/scene_entities_codec.cpp b/pj_base/src/builtin/scene_entities_codec.cpp index 172d0cf1..2141aa20 100644 --- a/pj_base/src/builtin/scene_entities_codec.cpp +++ b/pj_base/src/builtin/scene_entities_codec.cpp @@ -442,7 +442,7 @@ bool decodeModelPrimitive(Reader& reader, ModelPrimitive& out) { void writeSceneEntityDeletion(Writer& writer, const SceneEntityDeletion& d) { // Duration/Timestamp share the seconds+nanos wire shape. - writer.message(1, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, d.timestamp); }); + writer.message(1, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, d.timestamp_ns); }); writer.varint(2, static_cast(d.type)); writer.string(3, d.id); } @@ -451,7 +451,7 @@ bool decodeSceneEntityDeletion(Reader& reader, SceneEntityDeletion& out) { return parseFields(reader, [&](Tag tag, Reader& r) { switch (tag.field) { case 1: - return tag.type == WireType::kLengthDelimited && builtin_wire::readTimestampMessage(r, out.timestamp); + return tag.type == WireType::kLengthDelimited && builtin_wire::readTimestampMessage(r, out.timestamp_ns); case 2: { if (tag.type != WireType::kVarint) { return false; @@ -513,7 +513,7 @@ bool readPrimitiveIntoVector(Reader& reader, std::vector& out, Decode // ---------- SceneEntity ---------- void writeSceneEntity(Writer& writer, const SceneEntity& e) { - writer.message(1, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, e.timestamp); }); + writer.message(1, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, e.timestamp_ns); }); writer.string(2, e.frame_id); writer.string(3, e.id); // Duration shares the seconds+nanos wire shape with Timestamp. @@ -556,7 +556,7 @@ bool decodeSceneEntity(Reader& reader, SceneEntity& out) { return parseFields(reader, [&](Tag tag, Reader& r) { switch (tag.field) { case 1: - return tag.type == WireType::kLengthDelimited && builtin_wire::readTimestampMessage(r, out.timestamp); + return tag.type == WireType::kLengthDelimited && builtin_wire::readTimestampMessage(r, out.timestamp_ns); case 2: return tag.type == WireType::kLengthDelimited && r.readString(out.frame_id); case 3: diff --git a/pj_base/tests/field_table_test.cpp b/pj_base/tests/field_table_test.cpp index 5ef78b91..9b57b84b 100644 --- a/pj_base/tests/field_table_test.cpp +++ b/pj_base/tests/field_table_test.cpp @@ -191,7 +191,7 @@ FrameTransforms makeSampleFrameTransforms() { FrameTransforms ft; ft.transforms.push_back( FrameTransform{ - .timestamp = 1'234'567'890'123'456, + .timestamp_ns = 1'234'567'890'123'456, .parent_frame_id = "map", .child_frame_id = "base_link", .translation = {.x = 1.0, .y = 2.0, .z = 3.0}, @@ -199,7 +199,7 @@ FrameTransforms makeSampleFrameTransforms() { }); ft.transforms.push_back( FrameTransform{ - .timestamp = -42, + .timestamp_ns = -42, .parent_frame_id = "odom", .child_frame_id = "map", .translation = {.x = -1.5, .y = 2.5, .z = -3.5}, @@ -210,7 +210,7 @@ FrameTransforms makeSampleFrameTransforms() { ImageAnnotations makeSampleImageAnnotations() { ImageAnnotations ia; - ia.timestamp = 9'007'199'254'740'993; // > 2^53: would lose precision as a double. + ia.timestamp_ns = 9'007'199'254'740'993; // > 2^53: would lose precision as a double. ia.image_topic = "/camera/image"; PointsAnnotation pa; @@ -244,7 +244,7 @@ ImageAnnotations makeSampleImageAnnotations() { // metadata; the batch also carries one deletion. SceneEntities makeSampleSceneEntities() { SceneEntity e; - e.timestamp = 1'000'000'000'000'123; // > 2^53. + e.timestamp_ns = 1'000'000'000'000'123; // > 2^53. e.frame_id = "map"; e.id = "entity-1"; e.lifetime_ns = 5'000'000'000; @@ -348,7 +348,7 @@ SceneEntities makeSampleSceneEntities() { entities.deletions.push_back( SceneEntityDeletion{ .type = SceneEntityDeletion::Type::kAll, // non-default (kMatchingId == 0) - .timestamp = 999, + .timestamp_ns = 999, .id = "", }); return entities; @@ -824,7 +824,7 @@ TEST(FieldTableTest, ImageCompressedBufferLayoutHasNoStaticRecordSize) { TEST(FieldTableTest, Int64FieldsAreNotNumbers) { constexpr int64_t kBig = (int64_t{1} << 55) + 12345; // > 2^53: exact only as int64. - const FieldDescriptor* ft_timestamp = findField(FieldTable::view, "timestamp"); + const FieldDescriptor* ft_timestamp = findField(FieldTable::view, "timestamp_ns"); ASSERT_NE(ft_timestamp, nullptr); EXPECT_EQ(ft_timestamp->kind, FieldKind::kInt64); EXPECT_EQ(ft_timestamp->get_number, nullptr); @@ -834,10 +834,10 @@ TEST(FieldTableTest, Int64FieldsAreNotNumbers) { FrameTransform ft; ft_timestamp->set_int64(&ft, kBig); - EXPECT_EQ(ft.timestamp, kBig); + EXPECT_EQ(ft.timestamp_ns, kBig); EXPECT_EQ(ft_timestamp->get_int64(&ft), kBig); - const FieldDescriptor* ia_timestamp = findField(FieldTable::view, "timestamp"); + const FieldDescriptor* ia_timestamp = findField(FieldTable::view, "timestamp_ns"); ASSERT_NE(ia_timestamp, nullptr); EXPECT_EQ(ia_timestamp->kind, FieldKind::kInt64); EXPECT_EQ(ia_timestamp->get_number, nullptr); @@ -845,7 +845,7 @@ TEST(FieldTableTest, Int64FieldsAreNotNumbers) { ImageAnnotations ia; ia_timestamp->set_int64(&ia, kBig); - EXPECT_EQ(ia.timestamp, kBig); + EXPECT_EQ(ia.timestamp_ns, kBig); EXPECT_EQ(ia_timestamp->get_int64(&ia), kBig); } diff --git a/pj_base/tests/frame_transforms_codec_test.cpp b/pj_base/tests/frame_transforms_codec_test.cpp index 34a89e76..217baa1b 100644 --- a/pj_base/tests/frame_transforms_codec_test.cpp +++ b/pj_base/tests/frame_transforms_codec_test.cpp @@ -20,7 +20,7 @@ namespace pb = ::PJ::test_pb; std::vector encodeFrameTransform(const FrameTransform& transform) { std::vector body; pb::appendTag(body, 1, 2); - pb::appendLenDelim(body, pb::encodeTimestamp(transform.timestamp)); + pb::appendLenDelim(body, pb::encodeTimestamp(transform.timestamp_ns)); pb::appendTag(body, 2, 2); pb::appendString(body, transform.parent_frame_id); pb::appendTag(body, 3, 2); @@ -45,7 +45,7 @@ TEST(FrameTransformsCodecTest, GoldenBytesSingleTransform) { FrameTransforms transforms; transforms.transforms.push_back( FrameTransform{ - .timestamp = 1'234'567'890'123, + .timestamp_ns = 1'234'567'890'123, .parent_frame_id = "map", .child_frame_id = "base_link", .translation = {.x = 1.0, .y = 2.0, .z = 3.0}, @@ -63,7 +63,7 @@ TEST(FrameTransformsCodecTest, RoundTripMultipleTransforms) { FrameTransforms input; input.transforms.push_back( FrameTransform{ - .timestamp = 42, + .timestamp_ns = 42, .parent_frame_id = "map", .child_frame_id = "odom", .translation = {.x = 1.0, .y = 0.0, .z = 0.0}, @@ -71,7 +71,7 @@ TEST(FrameTransformsCodecTest, RoundTripMultipleTransforms) { }); input.transforms.push_back( FrameTransform{ - .timestamp = -1, + .timestamp_ns = -1, .parent_frame_id = "odom", .child_frame_id = "base_link", .translation = {.x = -1.5, .y = 2.5, .z = 3.5}, diff --git a/pj_base/tests/image_annotations_codec_test.cpp b/pj_base/tests/image_annotations_codec_test.cpp index aebc15ed..497f4369 100644 --- a/pj_base/tests/image_annotations_codec_test.cpp +++ b/pj_base/tests/image_annotations_codec_test.cpp @@ -303,7 +303,7 @@ TEST(ImageAnnotationCodecTest, RoundTrip_PerVertexColors) { TEST(ImageAnnotationCodecTest, RoundTrip_TimestampAndImageTopic) { sdk::ImageAnnotations in; - in.timestamp = 5'250'000'000; // 5s, 250000000ns + in.timestamp_ns = 5'250'000'000; // 5s, 250000000ns in.image_topic = "/camera/image_raw"; CircleAnnotation ca; @@ -315,7 +315,7 @@ TEST(ImageAnnotationCodecTest, RoundTrip_TimestampAndImageTopic) { in.circles.push_back(std::move(ca)); auto out = roundTrip(in); - EXPECT_EQ(out.timestamp, in.timestamp); + EXPECT_EQ(out.timestamp_ns, in.timestamp_ns); EXPECT_EQ(out.image_topic, in.image_topic); ASSERT_EQ(out.circles.size(), 1u); EXPECT_DOUBLE_EQ(out.circles[0].radius, 3.0); @@ -334,7 +334,7 @@ TEST(ImageAnnotationCodecTest, TimestampZeroAndEmptyTopicEmitNothing) { with_defaults.circles.push_back(ca); sdk::ImageAnnotations explicit_zero = with_defaults; - explicit_zero.timestamp = 0; + explicit_zero.timestamp_ns = 0; explicit_zero.image_topic.clear(); EXPECT_EQ(serializeImageAnnotations(with_defaults), serializeImageAnnotations(explicit_zero)); @@ -356,19 +356,19 @@ TEST(ImageAnnotationCodecTest, OldPayloadWithoutTheFieldsDecodesToDefaults) { auto bytes = serializeImageAnnotations(in); auto result = deserializeImageAnnotations(bytes.data(), bytes.size()); ASSERT_TRUE(result.has_value()); - EXPECT_EQ(result->timestamp, 0); + EXPECT_EQ(result->timestamp_ns, 0); EXPECT_TRUE(result->image_topic.empty()); } TEST(ImageAnnotationCodecTest, GoldenBytes_TimestampAndImageTopic) { sdk::ImageAnnotations ia; - ia.timestamp = 5'250'000'000; // 5s, 250000000ns + ia.timestamp_ns = 5'250'000'000; // 5s, 250000000ns ia.image_topic = "/camera/image_raw"; // Field 5: Timestamp submessage (seconds=5, nanos=250000000). std::vector expected; pb::appendTag(expected, 5, 2); - pb::appendLenDelim(expected, pb::encodeTimestamp(ia.timestamp)); + pb::appendLenDelim(expected, pb::encodeTimestamp(ia.timestamp_ns)); // Field 6: image_topic string. pb::appendTag(expected, 6, 2); @@ -386,8 +386,8 @@ TEST(ImageAnnotationCodecTest, RoundTrip_TimestampExtremes) { std::numeric_limits::max(), std::numeric_limits::max() - 1, Timestamp{-1}, Timestamp{-1'000'000'000}}) { sdk::ImageAnnotations in; - in.timestamp = timestamp; - EXPECT_EQ(roundTrip(in).timestamp, timestamp) << timestamp; + in.timestamp_ns = timestamp; + EXPECT_EQ(roundTrip(in).timestamp_ns, timestamp) << timestamp; } } diff --git a/pj_base/tests/scene_entities_codec_test.cpp b/pj_base/tests/scene_entities_codec_test.cpp index caea5ce5..c3a7040f 100644 --- a/pj_base/tests/scene_entities_codec_test.cpp +++ b/pj_base/tests/scene_entities_codec_test.cpp @@ -68,7 +68,7 @@ TEST(SceneEntitiesCodecTest, EmptyBufferDeserializesAsError) { TEST(SceneEntitiesCodecTest, RoundTripOneEntityWithEachPrimitive) { SceneEntities in; SceneEntity e; - e.timestamp = 100'000'000LL; + e.timestamp_ns = 100'000'000LL; e.frame_id = "world"; e.id = "mixed_demo"; e.lifetime_ns = 5'000'000'000LL; @@ -146,7 +146,7 @@ TEST(SceneEntitiesCodecTest, RoundTripOneEntityWithEachPrimitive) { const auto& src = in.entities.front(); const auto& dst = out->entities.front(); - EXPECT_EQ(dst.timestamp, src.timestamp); + EXPECT_EQ(dst.timestamp_ns, src.timestamp_ns); EXPECT_EQ(dst.frame_id, src.frame_id); EXPECT_EQ(dst.id, src.id); EXPECT_EQ(dst.lifetime_ns, src.lifetime_ns); @@ -316,8 +316,8 @@ TEST(SceneEntitiesCodecTest, RoundTripEntitiesAndDeletions) { .pose = makePose(0.0, 0.0, 0.0), .size = {.x = 1.0, .y = 1.0, .z = 1.0}, .color = {255, 255, 255, 255}}); in.entities.push_back(std::move(e)); in.deletions.push_back( - SceneEntityDeletion{.type = SceneEntityDeletion::Type::kMatchingId, .timestamp = 42, .id = "gone"}); - in.deletions.push_back(SceneEntityDeletion{.type = SceneEntityDeletion::Type::kAll, .timestamp = 99, .id = ""}); + SceneEntityDeletion{.type = SceneEntityDeletion::Type::kMatchingId, .timestamp_ns = 42, .id = "gone"}); + in.deletions.push_back(SceneEntityDeletion{.type = SceneEntityDeletion::Type::kAll, .timestamp_ns = 99, .id = ""}); const auto bytes = serializeSceneEntities(in); auto out = deserializeSceneEntities(bytes.data(), bytes.size()); @@ -325,15 +325,15 @@ TEST(SceneEntitiesCodecTest, RoundTripEntitiesAndDeletions) { ASSERT_EQ(out->entities.size(), 1u); ASSERT_EQ(out->deletions.size(), 2u); EXPECT_EQ(out->deletions[0].type, SceneEntityDeletion::Type::kMatchingId); - EXPECT_EQ(out->deletions[0].timestamp, 42); + EXPECT_EQ(out->deletions[0].timestamp_ns, 42); EXPECT_EQ(out->deletions[0].id, "gone"); EXPECT_EQ(out->deletions[1].type, SceneEntityDeletion::Type::kAll); - EXPECT_EQ(out->deletions[1].timestamp, 99); + EXPECT_EQ(out->deletions[1].timestamp_ns, 99); } TEST(SceneEntitiesCodecTest, DeletionsOnlyBatchRoundTrips) { SceneEntities in; - in.deletions.push_back(SceneEntityDeletion{.type = SceneEntityDeletion::Type::kAll, .timestamp = 7, .id = ""}); + in.deletions.push_back(SceneEntityDeletion{.type = SceneEntityDeletion::Type::kAll, .timestamp_ns = 7, .id = ""}); EXPECT_FALSE(in.empty()); const auto bytes = serializeSceneEntities(in); diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_typed.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_typed.hpp index f8c16537..1bf72167 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_typed.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_typed.hpp @@ -283,7 +283,9 @@ class DialogPluginTyped : public DialogPluginBase { const int tree_channel_count = static_cast(has_tree_selection) + static_cast(has_tree_activation) + static_cast(has_tree_expansion) + static_cast(has_tree_check_state); if (tree_channel_count != 0) { - if (tree_channel_count != 1 || event.raw().size() != 1) { + // Two tree keys are ambiguous and fail closed. Any other top-level key is + // tolerated and ignored, so a later host can add one to the envelope. + if (tree_channel_count != 1) { return false; } if (has_tree_selection) { diff --git a/pj_plugins/dialog_protocol/tests/dialog_plugin_typed_test.cpp b/pj_plugins/dialog_protocol/tests/dialog_plugin_typed_test.cpp index b7e24e4e..87cd8db2 100644 --- a/pj_plugins/dialog_protocol/tests/dialog_plugin_typed_test.cpp +++ b/pj_plugins/dialog_protocol/tests/dialog_plugin_typed_test.cpp @@ -630,9 +630,6 @@ TEST(TypedDispatchTreeFailClosedTest, MalformedAndMixedTreePayloadsInvokeNoOther dispatch(plugin, "tree", R"({"tree_check_state_changed":{"id":"imu","state":"partial"},"checked":true})")); EXPECT_FALSE( dispatch(plugin, "tree", R"({"tree_selection_changed":["imu"],"tree_item_activated":{"id":"imu","column":0}})")); - EXPECT_FALSE(dispatch( - plugin, "tree", - R"({"tree_item_activated":{"id":"imu","column":0},"stacked_index":2,"stacked_page":"advanced"})")); EXPECT_EQ(plugin.legacy_calls, 0); } @@ -646,13 +643,23 @@ TEST_F(TypedDispatchTest, MalformedTreePayloadsInvokeNoTreeCallback) { EXPECT_TRUE(plugin_.last_handler.empty()); } -TEST_F(TypedDispatchTest, CombinedTreeAndStackedPayloadInvokesNeitherCallback) { - EXPECT_FALSE(dispatch( +// A valid tree key claims the event; stacked and legacy keys beside it are +// ignored, as they are beside a picker result. +TEST_F(TypedDispatchTest, ValidTreeKeyClaimsTheEventOverStackedAndLegacyKeys) { + EXPECT_TRUE(dispatch( plugin_, "tree", - R"({"tree_item_activated":{"id":"imu","column":0},"stacked_index":2,"stacked_page":"advanced"})")); - EXPECT_EQ(plugin_.tree_calls, 0); + R"({"tree_item_activated":{"id":"imu","column":0},"stacked_index":2,"stacked_page":"advanced","text":"x"})")); + EXPECT_EQ(plugin_.tree_calls, 1); EXPECT_EQ(plugin_.stacked_calls, 0); - EXPECT_TRUE(plugin_.last_handler.empty()); + EXPECT_EQ(plugin_.last_handler, "tree_item_activated"); +} + +// A later host may add a key to the tree-event envelope. A plugin built today +// must still receive the event. +TEST_F(TypedDispatchTest, TreeEventWithAnUnknownTopLevelKeyIsDispatched) { + EXPECT_TRUE(dispatch(plugin_, "tree", R"({"tree_selection_changed":["imu"],"added_by_a_later_host":{"x":1}})")); + EXPECT_EQ(plugin_.tree_calls, 1); + EXPECT_EQ(plugin_.last_handler, "tree_selection_changed"); } TEST_F(TypedDispatchTest, TabAndComboEventsDoNotTriggerStackedPageHandler) { diff --git a/pj_plugins/widget_protocol/include/pj_plugins/widget_protocol.h b/pj_plugins/widget_protocol/include/pj_plugins/widget_protocol.h index 79323470..7995690a 100644 --- a/pj_plugins/widget_protocol/include/pj_plugins/widget_protocol.h +++ b/pj_plugins/widget_protocol/include/pj_plugins/widget_protocol.h @@ -129,6 +129,8 @@ typedef enum { * * ABI-FROZEN: layout permanent; growth goes through a new tail slot (e.g. * on_update_v2), never by appending a field, because these travel in arrays. + * `flags` names the 4 bytes that were alignment padding after `kind`; it + * changes neither the size nor any offset. * * Every pointer inside (text, bytes, type_name, label) is valid only for the * duration of the call that delivers it (on_update, or the history sink); a @@ -138,6 +140,10 @@ typedef struct { uint32_t input; /* index into the array last returned by inputs() */ uint32_t slot; /* 0 for a non-`multiple` input; stable per wire for `multiple` ones */ PJ_widget_value_kind_t kind; /* selects the payload below */ + uint32_t flags; /* reserved, no bit defined: a host built against SDK 1.0 or later writes + 0. These 4 bytes were padding before 1.0, so an older host leaves them + unset: a widget reads a bit only once a later SDK defines one, together + with the way to tell that the host sets it */ double time; /* time of the sample, in the host's display time axis, seconds */ double number; /* valid when kind == NUMBER */ PJ_string_view_t text; /* valid when kind == TEXT */ diff --git a/pj_plugins/widget_protocol/tests/widget_abi_layout_sentinels_test.cpp b/pj_plugins/widget_protocol/tests/widget_abi_layout_sentinels_test.cpp index b8d45be2..3a503e88 100644 --- a/pj_plugins/widget_protocol/tests/widget_abi_layout_sentinels_test.cpp +++ b/pj_plugins/widget_protocol/tests/widget_abi_layout_sentinels_test.cpp @@ -49,6 +49,7 @@ static_assert(alignof(PJ_widget_input_t) == 8, "ABI-FROZEN alignment: NEVER CHAN static_assert(offsetof(PJ_widget_value_t, input) == 0, "layout pinned"); static_assert(offsetof(PJ_widget_value_t, slot) == 4, "layout pinned"); static_assert(offsetof(PJ_widget_value_t, kind) == 8, "layout pinned"); +static_assert(offsetof(PJ_widget_value_t, flags) == 12, "layout pinned: names the former padding after kind"); static_assert(offsetof(PJ_widget_value_t, time) == 16, "layout pinned"); static_assert(offsetof(PJ_widget_value_t, number) == 24, "layout pinned"); static_assert(offsetof(PJ_widget_value_t, text) == 32, "layout pinned");