Skip to content

Commit 04243a8

Browse files
feat : recover map and arrays resizing (#37)
1 parent 9b2fb71 commit 04243a8

6 files changed

Lines changed: 375 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3737
- **common/schema**: the schema's top-level **`"buckets"` array is now parsed and is the source of truth for the frame bucket layout** -- `SchemaRegistry::GetBucketNames()` returns the declared names in order (`"buckets"[i]` names Frame bucket index `i`), and `PropertyAddressCache` carries them (`bucket_names`) so the reader can use them too. A new `Buckets` validation rule rejects a malformed key when present (non-array, non-string entries, empty or duplicate names); schemas without the key stay valid (legacy)
3838
- **writer/api**: **schema-driven bucket normalization** -- before finalization the writer rearranges every frame's buckets into the schema-declared layout: buckets are reordered to schema order, declared buckets missing from the frame are created empty, and a bucket the schema does not declare rejects the frame with the new **`VtxErrorCode::BucketUnresolved`** (observable via `TryRecordFrame`). Frames built positionally (no `bucket_map`) adopt the schema layout as long as they do not exceed the declared bucket count. Schemas without a `"buckets"` array skip the normalization entirely
3939
- **reader/api**: **bucket names restored on read** -- bucket names never hit the wire (both formats serialize buckets positionally), so deserialized frames used to come back with an empty `bucket_map`. The reader now stamps `bucket_map` from the embedded schema's `"buckets"` array when chunks are deserialized, so by-name lookups (`Frame::GetBucket(name)` const) and `bucket_map` iteration work on read frames. Replays written without a `"buckets"` array keep the old positional-only behavior
40+
- **common/schema**: **array & map pre-sizing** -- extends the existing scalar pre-sizing (`type_max_indices`) so declared *array* and *map* fields are also materialized on load. `SchemaStruct` / `StructSchemaCache` gain `array_max_indices` (max Array-container index per `FieldType`) and `map_max_index` (count of Struct-valued map fields); `Helpers::ResizeContainerToMaxIndices` now pre-creates one empty subarray per declared array field in the matching `FlatArray` (via new `FlatArray::EnsureSubArrayCount`) and pre-sizes `map_properties`. A declared-but-unpopulated array/map field is now present-and-empty instead of absent, symmetric with scalars. Applies wherever scalar pre-sizing already ran (the four frame loaders + `schema_dynamic_loader`); the loader CRTP hook `GetTypeMaxIndices` became `GetStructSizing` (returns the `StructSchemaCache`)
41+
- **reader/api**: **declared-empty arrays restored on read** -- an array with no data is not serialized (nothing to store), so deserialized entities used to come back missing those subarrays even for schema-declared array fields (maps already round-trip their slot count). The reader now re-creates the declared-but-empty subarrays from the embedded schema (`Helpers::EnsureDeclaredArrays`, grow-only, recursing into nested structs / struct-array elements / map values) so a read frame mirrors the array layout of an ingest-loaded frame. Arrays that carry data round-trip unchanged (their `offsets` already encode empty subarrays); scalars and maps are untouched
4042

4143
### Changed
4244

@@ -60,7 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6062
- **writer/reader (Protobuff -> Protobuf rename)** (#28): the misspelled "Protobuff" was removed from the public API -- **breaking, no aliases kept**. `SerializationFormat::Protobuffs` -> **`SerializationFormat::Protobuf`**; `CreateProtobuffWriterFacade` -> **`CreateProtobufWriterFacade`**; `CreateProtobuffNetworkWriterFacade` -> **`CreateProtobufNetworkWriterFacade`**; reader `CreateProtobuffFacade` -> **`CreateProtobufFacade`** (internal `ProtobuffFacadeImpl` -> `ProtobufFacadeImpl`). Migrate call sites with a literal `Protobuff` -> `Protobuf` rename
6163
- **common/types**: `FlatArray::GetSubArray` & `FlatArray::GetMutableSubArray` did not have a check for StartIndex being greater or equal to EndIndex. In exceptional cases, like an empty array this can cause a crash or undefined behaviour in the best case scenario.
6264
- **writer/flatbuffers**: `FlatBuffersVtxPolicy::FromNative` hardcoded exactly two bucket slots -- `buckets[0]` was renamed to `"data"`, `buckets[1]` to `"bone_data"`, and **any bucket at index >= 2 was silently dropped** from the file. It now iterates all buckets generically (same shape as the Protobuf policy, shared `Serialization::SortBucketByTypeId` helper in `bucket_type_sort.h`), preserving every bucket and the frame's `bucket_map`. Bucket naming is schema-driven (see the `"buckets"` entries above), not serializer-driven
63-
- **reader/schema**: the reader-side `PropertyAddressCache` built from the embedded schema (`PopulateCacheFromJsonString`) hand-copied the registry cache and **omitted `type_max_indices`**, so `ValidateEntity` on read frames treated every per-type max as 0 (any populated property would flag `FieldIndexOutOfRange` once frame validation ran). It now reuses `SchemaRegistry::GetPropertyCache()` verbatim. Also makes `ValidateReplay`'s per-frame pass effective: it iterates `frame.bucket_map`, which was always empty on read frames before bucket-name restoration
65+
- **reader/schema**: the reader-side `PropertyAddressCache` built from the embedded schema (`PopulateCacheFromJsonString`) hand-copied the registry cache and **omitted `type_max_indices`**, so `ValidateEntity` on read frames treated every per-type max as 0 (any populated property would flag `FieldIndexOutOfRange` once frame validation ran). It now reuses `SchemaRegistry::GetPropertyCache()` verbatim. This also makes `ValidateReplay`'s per-frame pass effective **for replays whose schema declares a `"buckets"` array** -- it iterates `frame.bucket_map`, which was always empty on read frames before bucket-name restoration. Replays whose schema has no `"buckets"` array still read back with an empty `bucket_map`, so their per-frame pass remains a no-op (unchanged from before)
6466
- **samples/benchmarks**: `basic_write.cpp` and `bench_writer.cpp` created a `"Players"` bucket while writing against the arena schema, which declares `"buckets": ["entity"]` -- under schema-driven normalization those frames would now be rejected. Both use `"entity"`
6567

6668

sdk/include/vtx/reader/core/vtx_reader.h

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,7 @@ namespace VTX {
667667
SerializerPolicy::ProcessChunkData(idx, compressed_blob, stop_token, cc.native_frames,
668668
cc.decompressed_blob, cc.raw_frames_spans);
669669
RestoreBucketNames(cc.native_frames);
670+
RestoreDeclaredArrays(cc.native_frames);
670671
return cc;
671672
} catch (const std::exception& e) {
672673
VTX_ERROR("[READER] Chunk {} deserialization failed: {}", idx, e.what());
@@ -696,6 +697,43 @@ namespace VTX {
696697
}
697698
}
698699

700+
// A declared array field with no data is not serialized (an empty array has
701+
// nothing to store), so deserialized entities come back missing those
702+
// subarrays. Re-create the declared-but-empty subarrays from the schema so a
703+
// read frame mirrors the same array layout an ingest-loaded frame has (maps
704+
// already round-trip their slot count, so only arrays need this). Recurses
705+
// into nested structs / struct-array elements / map values, matching the
706+
// loader's recursive PrepareContainer. Grow-only: never touches populated
707+
// arrays, scalars, or maps.
708+
void RestoreDeclaredArrays(std::vector<VTX::Frame>& frames) const {
709+
if (property_address_cache_.structs.empty())
710+
return;
711+
for (auto& frame : frames) {
712+
for (auto& bucket : frame.GetMutableBuckets()) {
713+
for (auto& entity : bucket.entities) {
714+
PreSizeDeclaredArraysRecursive(entity);
715+
}
716+
}
717+
}
718+
}
719+
720+
void PreSizeDeclaredArraysRecursive(VTX::PropertyContainer& container) const {
721+
auto it = property_address_cache_.structs.find(container.entity_type_id);
722+
if (it != property_address_cache_.structs.end()) {
723+
Helpers::EnsureDeclaredArrays(container, it->second.array_max_indices);
724+
}
725+
for (auto& nested : container.any_struct_properties)
726+
PreSizeDeclaredArraysRecursive(nested);
727+
for (auto& nested : container.any_struct_arrays.data)
728+
PreSizeDeclaredArraysRecursive(nested);
729+
for (auto& map_item : container.map_properties)
730+
for (auto& value : map_item.values)
731+
PreSizeDeclaredArraysRecursive(value);
732+
for (auto& map_item : container.map_arrays.data)
733+
for (auto& value : map_item.values)
734+
PreSizeDeclaredArraysRecursive(value);
735+
}
736+
699737
private:
700738
std::string filepath_;
701739

sdk/src/vtx_writer/src/vtx/writer/formatters/flatbuffers_vtx_policy.cpp

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,7 @@ std::unique_ptr<VTX::FlatBuffersVtxPolicy::FrameType> VTX::FlatBuffersVtxPolicy:
2121
sorted_frame->GetMutableBuckets().resize(native_buckets.size());
2222

2323
for (size_t b_idx = 0; b_idx < native_buckets.size(); ++b_idx) {
24-
const auto& src_bucket = native_buckets[b_idx];
25-
auto& dst_bucket = sorted_frame->GetBucket(static_cast<int32_t>(b_idx));
26-
27-
if (b_idx == 0) {
28-
Serialization::SortBucketByTypeId(src_bucket, dst_bucket);
29-
} else {
30-
dst_bucket = src_bucket;
31-
}
24+
Serialization::SortBucketByTypeId(native_buckets[b_idx], sorted_frame->GetBucket(static_cast<int32_t>(b_idx)));
3225
}
3326

3427
return sorted_frame;

sdk/src/vtx_writer/src/vtx/writer/formatters/protobuff_vtx_policy.cpp

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,7 @@ std::unique_ptr<VTX::ProtobufVtxPolicy::FrameType> VTX::ProtobufVtxPolicy::FromN
1616
sorted_native.GetMutableBuckets().resize(native_buckets.size());
1717

1818
for (size_t b_idx = 0; b_idx < native_buckets.size(); ++b_idx) {
19-
const auto& src_bucket = native_buckets[b_idx];
20-
auto& dst_bucket = sorted_native.GetBucket(static_cast<int>(b_idx));
21-
22-
if (b_idx == 0) {
23-
Serialization::SortBucketByTypeId(src_bucket, dst_bucket);
24-
} else {
25-
dst_bucket = src_bucket;
26-
}
19+
Serialization::SortBucketByTypeId(native_buckets[b_idx], sorted_native.GetBucket(static_cast<int>(b_idx)));
2720
}
2821

2922
auto proto = std::make_unique<cppvtx::Frame>();

tests/common/test_schema_registry.cpp

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,92 @@ TEST(SchemaRegistry, ReloadReplacesBucketNames) {
184184
ASSERT_TRUE(schema.LoadFromRawString(raw));
185185
EXPECT_TRUE(schema.GetBucketNames().empty());
186186
}
187+
188+
// ---------------------------------------------------------------------------
189+
// Array / Map pre-sizing (symmetric with scalar pre-sizing)
190+
// ---------------------------------------------------------------------------
191+
192+
namespace {
193+
// Player declares: 2 int32 scalars, 1 float array, 2 string arrays,
194+
// 1 struct array (Inventory of Item) and 1 struct map (AmmoByWeapon of Ammo).
195+
constexpr const char* kArrayMapSchema = R"({
196+
"version": "1.0.0",
197+
"buckets": ["entity"],
198+
"property_mapping": [
199+
{ "struct": "Item", "values": [
200+
{"name":"Id","typeId":"Int32","containerType":"None","keyId":"None","structType":"","meta":{"defaultValue":"0","fixedArrayDim":1}}
201+
]},
202+
{ "struct": "Ammo", "values": [
203+
{"name":"Count","typeId":"Int32","containerType":"None","keyId":"None","structType":"","meta":{"defaultValue":"0","fixedArrayDim":1}}
204+
]},
205+
{ "struct": "Player", "values": [
206+
{"name":"Score","typeId":"Int32","containerType":"None","keyId":"None","structType":"","meta":{"defaultValue":"0","fixedArrayDim":1}},
207+
{"name":"Level","typeId":"Int32","containerType":"None","keyId":"None","structType":"","meta":{"defaultValue":"0","fixedArrayDim":1}},
208+
{"name":"Cooldowns","typeId":"Float","containerType":"Array","keyId":"None","structType":"","meta":{"defaultValue":"","fixedArrayDim":0}},
209+
{"name":"Tags","typeId":"String","containerType":"Array","keyId":"None","structType":"","meta":{"defaultValue":"","fixedArrayDim":0}},
210+
{"name":"Names","typeId":"String","containerType":"Array","keyId":"None","structType":"","meta":{"defaultValue":"","fixedArrayDim":0}},
211+
{"name":"Inventory","typeId":"Struct","containerType":"Array","keyId":"None","structType":"Item","meta":{"defaultValue":"","fixedArrayDim":0}},
212+
{"name":"AmmoByWeapon","typeId":"Struct","containerType":"Map","keyId":"String","structType":"Ammo","meta":{"defaultValue":"","fixedArrayDim":1}}
213+
]}
214+
]
215+
})";
216+
} // namespace
217+
218+
TEST(SchemaRegistry, PreSizesArraysAndMapsFromSchema) {
219+
VTX::SchemaRegistry schema;
220+
ASSERT_TRUE(schema.LoadFromRawString(kArrayMapSchema));
221+
222+
const VTX::SchemaStruct* player = schema.GetStruct("Player");
223+
ASSERT_NE(player, nullptr);
224+
225+
VTX::PropertyContainer c;
226+
VTX::Helpers::PreparePropertyContainer(c, *player);
227+
228+
// Scalars: unchanged (two int32 fields).
229+
EXPECT_EQ(c.int32_properties.size(), 2u);
230+
231+
// Arrays: one empty subarray per declared array field, grouped per type.
232+
EXPECT_EQ(c.float_arrays.SubArrayCount(), 1u);
233+
EXPECT_EQ(c.string_arrays.SubArrayCount(), 2u);
234+
EXPECT_EQ(c.any_struct_arrays.SubArrayCount(), 1u);
235+
EXPECT_TRUE(c.float_arrays.GetSubArray(0).empty());
236+
EXPECT_TRUE(c.string_arrays.GetSubArray(1).empty());
237+
238+
// Array types with no declared fields stay untouched.
239+
EXPECT_EQ(c.int32_arrays.SubArrayCount(), 0u);
240+
EXPECT_EQ(c.vector_arrays.SubArrayCount(), 0u);
241+
242+
// Map: one empty, Struct-valued map slot.
243+
ASSERT_EQ(c.map_properties.size(), 1u);
244+
EXPECT_TRUE(c.map_properties[0].keys.empty());
245+
EXPECT_TRUE(c.map_properties[0].values.empty());
246+
}
247+
248+
TEST(SchemaRegistry, ArrayAndMapSizingLandInPropertyCache) {
249+
VTX::SchemaRegistry schema;
250+
ASSERT_TRUE(schema.LoadFromRawString(kArrayMapSchema));
251+
252+
const int32_t player_id = schema.GetStructTypeId("Player");
253+
ASSERT_GE(player_id, 0);
254+
255+
const auto& sc = schema.GetPropertyCache().structs.at(player_id);
256+
const auto string_idx = static_cast<size_t>(VTX::FieldType::String);
257+
const auto float_idx = static_cast<size_t>(VTX::FieldType::Float);
258+
ASSERT_GT(sc.array_max_indices.size(), string_idx);
259+
EXPECT_EQ(sc.array_max_indices[string_idx], 2);
260+
EXPECT_EQ(sc.array_max_indices[float_idx], 1);
261+
EXPECT_EQ(sc.map_max_index, 1);
262+
}
263+
264+
// A schema without array/map fields leaves those sizes empty/zero.
265+
TEST(SchemaRegistry, ScalarOnlySchemaHasNoArrayOrMapSizing) {
266+
const std::string raw = std::string(R"({ "version": "1.0.0", )") + kTinyMapping + "}";
267+
VTX::SchemaRegistry schema;
268+
ASSERT_TRUE(schema.LoadFromRawString(raw));
269+
270+
const int32_t id = schema.GetStructTypeId("Tiny");
271+
ASSERT_GE(id, 0);
272+
const auto& sc = schema.GetPropertyCache().structs.at(id);
273+
EXPECT_TRUE(sc.array_max_indices.empty());
274+
EXPECT_EQ(sc.map_max_index, 0);
275+
}

0 commit comments

Comments
 (0)