All notable changes to the VTX SDK will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- tools/cli: the CLI now exposes the file's timing data, which was previously unreachable from the command surface:
- new
times [start] [end]command -- dumps the footer per-frame time table: counts,first_created_utc_*/last_created_utc_*,wall_duration_seconds,game_duration_seconds, the median wall-clock frame step, a discontinuity scan (anomalies= frames whosecreated_utcdelta is negative or more than twice the median step, capped at 100 withanomaly_count/anomalies_truncated), rawgaps/segments, and -- with a frame range -- a per-frameframesslice (game_time_ticks,created_utc_ticks,created_utc_iso,delta_ticks) framenow reports the current frame'sgame_time_ticks/game_time_secondsandcreated_utc_ticks/created_utc_iso(null when the file did not record them)headeraddsrecorded_utc_isoalongside the rawrecorded_utc_timestamp;infoaddsrecorded_utc_ticks/recorded_utc_isofooteraddsgame_time_count/created_utc_count/gap_count/segment_countchunksentries addchecksum(xxHash64 of the on-disk chunk payload, 0 = not set)eventsentries add derivedutc_ticks/utc_iso(recording start +game_time)- absolute wall-clock values follow one convention everywhere:
<name>_ticks(the stamp exactly as stored in the file, so raw deltas stay diagnosable) +<name>_iso(ISO-8601 UTC derived after unit normalization), both null when not recorded;events.utc_ticksis synthetic and always UE-tick-based
- new
- common/time:
TimeUtils::FormatUtcTicksIso8601(ticks)-- machine-readable ISO-8601 sibling ofFormatUtcTicks - common/time:
TimeUtils::NormalizeUtcToUeTicks(value)-- maps an absolute UTC stamp of unknown legacy unit (unix seconds / unix milliseconds / unix-relative ticks / UE ticks) onto the UE tick timeline. Needed because the writer stamps the header'srecorded_utc_timestampin unix seconds while the footer's per-framecreated_utcuses UE ticks -- the same file carries two units. The CLI derives every*_isostring and the event UTC through it while passing stored*_ticksvalues through raw. (The writer-side unit inconsistency itself is left as is in this change -- fixing it alters what new files contain and deserves its own decision.) - writer/sinks: PRISM sink timing refactored to an injectable perf observer -- new
IFileSinkPerfObserver(OnSerialize/OnCompress/OnDiskWrite, called synchronously from the writer thread) plus a default thread-safe collectorFileSinkAtomicPerfObserver(Snapshot()->FileSinkPerformanceStats {serialization_us, compression_us, disk_write_us},Reset()). Attach viaChunkedFileSink::Config::perf_observeror the newWriterFacadeConfig::perf_observer; leave null for a zero-cost no-op. Covered byRoundtripTest.PerfObserverReceivesSinkTimingson both backends - tools/inspector: File > Repair Replay -- repairs a crashed
.vtxfrom its.recoverysidecar viaVTX::RepairReplayFile, from inside the GUI. Floating window with pickers for the.vtxand the sidecar (auto-filled to the adjacent<file>.vtx.recovery; a sidecar picked from elsewhere is staged next to the.vtxsafely, never clobbering an existing one), the repair runs on a worker thread so the UI stays responsive, and the panel reports live progress then success (recovered chunks/frames) or failure (error detail) -- or "nothing to repair" for an already-complete file. Always available, independent of the loaded replay;vtx_inspectornow linksvtx_writerfor the repair entry point - tools/inspector: File > Cut Replay -- write a sub-range of the loaded replay as a new
.vtx(#46).ReplayCutServiceplans the cut and writes: header + kept chunks copied verbatim, footer rebuilt with frame numbering rebased to 0, the time table sliced (tick values stay absolute), gaps/segments filtered + rebased, duration recomputed from the sliced UTC stamps. The window accepts the range as elapsed time, frames, or absolute UTC (ISO-8601 or unix seconds/ms/100ns ticks), previews the resolved chunks/frames/time/size live, and runs the copy on a worker thread with a Save As dialog. Exact-frame cuts: when a bound falls inside a chunk, that edge chunk is re-serialized with only the kept frames (SaveChunk framing mirrored: length prefix, zstd-if-beneficial, xxHash64 checksum) and its seek-table entry regenerated; whole interior chunks still copy verbatim. A "Chunk (whole chunks)" mode keeps snap-to-chunk behavior. Timeline events are not carried over - tools/inspector: recording-gap visualization + wall-clock navigation (#45) --
BuildDroppedFrameMapflags every frame whose wall-clock delta from the previous stamped frame (footercreated_utc,game_timefallback) exceeds 1.5x the expected interval and estimates missing frames per gap. The timeline strip paints flagged frames red with a gap/missing summary and hover detail; the main slider paints the same gaps as red bands sized by wall-clock proportion, and now scrubs wall-clock time instead of frame index (grab at the current frame's footer-table time; dragging resolves the frame by binary search over the time table; inside a gap the grab snaps to the gap's start), so equal slider distance means equal recorded time. The expected rate is an editable "Drop FPS" input, derated by 0.75 (captures never sustain nominal rate; a 30 entry detects at 22.5 effective) with the factor shown in the label. Files without a time table keep the previous linear behavior
- reader/flatbuffers: struct-array subarray boundaries lost on read -- the FlatBuffers reader's
unpackStructArrayrestored a struct-array'sdatabut silently dropped itsoffsets, so any struct-array field (Vector/Quat/Transform/FloatRange) with two or more subarrays lost its partitioning on load. Regression-tested byRoundtripTest.StructArraySubArrayOffsetsRoundtrip(three subarrays with mixed sizes including an empty one, on both backends) - tools/inspector: timeline time drifted on captures with recording stalls (#45) -- frame time was derived as
frame_index / avg_fps, so stalls skewed every displayed time by the total stalled duration (observed: a frame stamped 13:54 wall clock displayed as ~6:47 on a capture with 643s of stalls). Frame -> elapsed seconds now maps through the footerReplayTimeDatatable (created_utcpreferred,game_timefallback, walking back to the nearest stamped frame); applies to the current-time readout and the strip hover tooltip. Files without a time table keep the old linear mapping - writer/sinks:
disk_write_usmeasured only the bufferedfwrite, not the durability flush -- sub-microsecond buffered writes round to 0 on a fast disk, so the metric was misleading (it excluded the fsync, i.e. the actual disk write) andPerfObserverReceivesSinkTimingsflaked on fast CI runners. A newTimedSyncOrFlush()counts thefsync/fflushtowarddisk_write_usat the three sink sites (header / chunk / footer); same syncs as before, now timed - repo: repaired a fused
.gitignoreentry (.DS_Storebuild-inspector/, from an append without a trailing newline) that left both.DS_Storeandbuild-inspector/effectively un-ignored
-
common/diagnostics: unified structured-diagnostics model -- new header
sdk/include/vtx/common/vtx_diagnostics.h. Replaces the SDK's previous mix ofnullptr/false/ thrown / logged / silently-ignored failures with one model that automation (PRISM v2) can act on without parsing logs:VtxDiagnostic-- one structured error/warning record carrying everything a strict failure should expose:code(VtxErrorCode),severity(Severity),message, and the location/context it concerns (frame_index,bucket,unique_id,entity_type,field_path,expected_type/expected_container,provided_type/provided_container,source_api). Aggregate type -- call sites fill only the fields they know.ToString()+operator<<for logging/gtest streaming.VtxError/VtxWarning-- aliases ofVtxDiagnostic(severity distinguishes).VtxResult<T>-- a produced value plus a (possibly empty)errorandwarnings;VtxStatus=VtxResult<Unit>for status-only results. Factory helpersSuccess/Failure.ValidationReport-- aggregated diagnostics from a validation pass (HasErrors/ok/ErrorCount/WarningCount/Errors/Warnings/ToString), warnings carried as structured data rather than only log lines.
-
common/validation: independently-callable validation passes -- new header
sdk/include/vtx/common/vtx_validation.h(impl invtx_validation.cpp).ValidateSchema(json)(wraps the rule-basedSchemaValidator; JSON parse failure ->SchemaParseError, rule violations ->SchemaInvalid),ValidateEntity(entity, schema)(entity type resolves to a schema struct; no per-type property array exceeds the schema-declared max), andValidateFrame(frame, schema)(duplicateunique_iddetection per bucket + per-entity validation with full frame/bucket/id context). Each takes the schema either as a resolvedPropertyAddressCacheor aSchemaRegistry(convenience overload) -
reader/validation: whole-replay validation -- new header
sdk/include/vtx/reader/core/vtx_replay_validation.h.ValidateReplay(IVtxReaderFacade&)validates an already-open replay (no file I/O -- the entry point for callers that already hold a reader, e.g. PRISM);ValidateReplayFile(path)is a thin wrapper that opens then delegates. Validates the embedded schema document and runsValidateFrameover every (or up tomax_frames) frame -
common/accessor: strict accessors that return
VtxResultalongside the existing tolerantGet/Set(which stay unchanged).FrameAccessor::TryResolve<T>(struct, field)resolves a scalar key or reportsNotFound/ContainerMismatch/TypeMismatch(the last withexpected/providedtype populated);EntityView::TryGet<T>(key)(read) andEntityMutator::TrySet<T>(key, value)(write, respects the frame freeze) reportFieldIndexOutOfRange/InvalidArgument -
writer/api: strict frame recording + finalization.
ReplayWriter::TryRecordFrameandIVtxWriterFacade::TryRecordFramereturn aRecordResultso a rejected frame is observable (a bad game-time registry previously rolled back and returnedvoidsilently);RecordFramestays as the back-compatible fire-and-forget wrapper. A new privateFinalizeFrameruns before a frame enters the chunk pipeline: it validates that every entity type resolves to a schema struct and recomputes each entity'scontent_hashAFTER all schema fields and post-processor overrides (a stale build-time hash would otherwise be persisted). The frame index is assigned monotonically by the writer. New outcome types insdk/include/vtx/writer/core/vtx_writer_result.h(RecordResult,PipelineReport) -
writer/api: frame freeze on finalization -- once
FinalizeFrameruns, every mutation handle derived from that frame'sFrameMutationView(including ones a post-processor stashed) is revoked: mutating ops become no-ops andvalid()returns false. Implemented via a sharedfrozenflag threaded throughFrameMutationView/BucketMutator/EntityMutator(Freeze()) -
writer/api: opt-in last-finalized snapshot --
ReplayWriter::Config::retain_finalized_snapshot(andWriterFacadeConfig/NetworkWriterFacadeConfig, default off). When enabled, the writer keeps the last finalized frame as a read-only snapshot queryable byGetLastFinalizedFrame()andFindEntity(bucket, unique_id). Off by default because it costs a full-frame copy per recorded frame -
writer/pipeline:
RecordPipeline::Runnow returns aPipelineReport(written / rejected / skipped frames, with rejections split intovalidation_errorsvstimer_errors, plus the per-frameVtxErrors) instead of a barebool -
common/schema: rule-based schema validator --
SchemaValidator(sdk/include/vtx/common/readers/schema_reader/schema_validator.h) runs a set of independentISchemaValidationRules over a schema BEFORE the registry resolves it into indices, so malformed schemas are rejected up front instead of degrading at runtime. Produces aSchemaValidationResult(list ofSchemaIssue).SchemaRegistry::LoadFromJson/LoadFromRawStringreject schemas with validation errors -
common/schema: per-type pre-sizing -- on load,
PropertyContainerarrays are pre-sized to the schema's per-type max index (type_max_indices), so field writes by index never reallocate or go out of bounds for schema-declared fields -
samples (arena): the arena pipeline now exercises the full container model end-to-end.
Playergains scalar arrays (Abilitiesstring[],AbilityCooldownsfloat[]), a nested struct (Loadout), a variable-length array-of-structs inventory (InventoryofInventoryItem-- the armor plate drops out while a player is dead and returns on respawn, so the array length changes frame-to-frame), and a map (AmmoByWeapon=map<weapon, AmmoEntry>). These all mutate over the match (ammo spent on fire + reload from reserve, ability cooldowns tick down, medkits consumed on respawn). All four data-source formats -- JSON, Protobuf, FlatBuffers and raw binary -- emit and ingest the new containers identically, verified by equal per-entitycontent_hashacross formats. New schema structsLoadout/InventoryItem/AmmoEntryadded tocontent/writer/arena/arena_schema.json,schemas/arena_data.proto,schemas/arena_data.fbs;arena_generated.hregenerated -
common/loaders: the Protobuf and native (struct-mapping) loaders now build
map_propertiesautomatically forcontainerType: Mapstruct fields, matching the FlatBuffers loader -- avector<KVStruct>/ repeated message mapped to a Map field lands inmap_propertiesinstead ofany_struct_arrays. Shared via a newGenericLoaderBase::PushToMaphelper (key = the entry's first non-empty string property, else first int32, elseKey_N), which the FlatBuffers loader now uses too. Callers no longer need to hand-populate maps for these formats -
samples: new helper
samples/arena_container_helpers.h--ArenaHelpers::AppendMapEntry(loader, dest, schema, field, value)populates amap_propertiesfield with the same key convention. Now used only by the raw-binary arena binding, whoseGenericBinaryLoaderhand-reads bytes and has noLoadArrayauto path; the JSON / Protobuf / FlatBuffers bindings build the map through the loader directly -
tests (differ):
DiffEdges.DiffWithMapPropertiesreactivated (was skipped pending a Map-field fixture) and a Protobuf counterpartDiffEdges.DiffWithMapPropertiesProtobufadded -- both run the same scenario (change a value under an existing key + add a new key) throughDiffRawFrameson their respective backend. New dedicated fixturetests/fixtures/test_schema_map.json(the arena schema with the Map field) keeps the sharedtest_schema.jsonlayout that the other diff/reader/writer tests depend on untouched -
common/accessor: typed map read API -- new
VTX::MapView(read-only view over aMapContainer:Size/Empty/Keys/Contains(key)/At(key) -> EntityView/ ordinalKeyAt/ValueAt),EntityView::GetMap(PropertyKey<MapView>), andFrameAccessor::GetMapKey(struct, field)(resolvesStruct + Mapfields). Completes the runtime read surface for maps alongsideGetView(nested struct) andGetViewArray(struct array). Covered bytests/common/test_frame_accessor.cpp -
scripts/codegen:
vtx_codegen.pynow emits a real map accessor forcontainerType: Mapfields --XView/XMutatorgetVTX::MapView GetField() const(built onaccessor.GetMapKey+EntityView::GetMap) instead of the previous incorrect single-EntityViewgetter (no setter -- read-only).arena_generated.hregenerated (Player::GetAmmoByWeapon()now returnsVTX::MapView) -
writer/api: one-call
WriteReplaypipeline (#30) -- new headersdk/include/vtx/writer/core/vtx_write_replay.h.WriteReplay(config, source, format)runs the whole recording pipeline in one call: creates the writer (schema missing/invalid ->SchemaInvalid), initializes theIFrameDataSource(failure ->Internal), drains every frame throughTryRecordFrame, finalizes, and returns aWriteReplayResult(ok,error,warnings,frames_written,frames_dropped,total_frames,output_path,elapsed_seconds). Frames rejected by finalization/timer are counted inframes_droppedand surfaced one-per-VtxWarning; the call still produces a valid replay of the accepted frames.SerializationFormatselects FlatBuffers (default) or Protobuf -
writer/api: automatic output-directory creation + destructor finalize (#29) --
WriterFacadeConfig::create_output_dirs(default on) creates any missing parent directories ofoutput_filepathbefore the sink opens the file (setfalseto require the directory to pre-exist and keep the previous throw-on-missing behavior). The writer facade destructor now finalizes the file as a best-effort fallback (Stop()wrapped intry/catch) when the caller forgot to callStop(), so a dropped writer still yields a readable.vtx -
writer/api: in-memory schema sources --
WriterFacadeConfig/NetworkWriterFacadeConfig(and the internalReplayWriter::Config) gainschema_json_content(a raw JSON string) andschema_registry(a pre-builtstd::shared_ptr<VTX::SchemaRegistry>, copied in with no re-parse) alongsideschema_json_path. Source precedence is registry > content > path, applied consistently in the create-time schema probe and inReplayWriter. The writer no longer requires the schema to live on disk -
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 indexi), andPropertyAddressCachecarries them (bucket_names) so the reader can use them too. A newBucketsvalidation rule rejects a malformed key when present (non-array, non-string entries, empty or duplicate names); schemas without the key stay valid (legacy) -
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 viaTryRecordFrame). Frames built positionally (nobucket_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 -
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 stampsbucket_mapfrom the embedded schema's"buckets"array when chunks are deserialized, so by-name lookups (Frame::GetBucket(name)const) andbucket_mapiteration work on read frames. Replays written without a"buckets"array keep the old positional-only behavior -
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/StructSchemaCachegainarray_max_indices(max Array-container index perFieldType) andmap_max_index(count of Struct-valued map fields);Helpers::ResizeContainerToMaxIndicesnow pre-creates one empty subarray per declared array field in the matchingFlatArray(via newFlatArray::EnsureSubArrayCount) and pre-sizesmap_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 hookGetTypeMaxIndicesbecameGetStructSizing(returns theStructSchemaCache) -
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 (theiroffsetsalready encode empty subarrays); scalars and maps are untouched -
writer/durability: crash recovery for the file sink -- a recording that dies before
Stop()(process crash or power loss, mid-chunk or mid-frame, before the body/footer are closed) can now be recovered up to the last recorded frame instead of losing the whole session. Three cooperating pieces, all file-sink only (the network sink accepts the per-frame hook as a no-op):- per-chunk integrity + durability --
ChunkedFileSink::Configgainsdurable_writes(default on:fsync/_commiteach chunk and journal record to physical media via the newDurableFileFILE* wrapper; set off to onlyfflushto the OS -- survives a process crash but not power loss) andenable_recovery_journal(default on). Every chunk carries an xxHash64checksumof its on-disk payload, added toChunkIndexEntry/ChunkIndexDataand to both the FlatBuffers (vtx_schema.fbs) and Protobuf (vtx_schema.proto, field 6) footer schemas, so a torn or corrupt chunk is detectable on recovery. The reader parses and exposes it; FlatBuffers header/footer parsing gained aflatbuffers::Verifierguard so a truncated footer is rejected cleanly instead of reading out of bounds - recovery journal -- a single write-ahead sidecar
<file>.recovery(sdk/include/vtx/writer/policies/sinks/recovery_journal.h) holds a typed, self-validating record stream (each record[u8 type][u32 len][payload][u64 xxHash64], so a torn last record from a mid-write crash is detected and dropped): anSrecord (written once) carries the writer's timing parameters{fps, is_increasing},Crecords commit a durable chunk's index entry,Trecords carry each committed frame's exact{game_time, created_utc}, andFrecords carry each in-flight (un-flushed) frame's times + serialized payload. The log is strictly append-only in the crash-critical path -- a frame appends anFrecord; a flush appends the chunk'sC+Trecords -- so at every instant the file is a valid prefix and there is no window in which a fsync'd chunk is described by neither itsFrecords nor itsCrecord. Ordering is data-before-journal: a chunk is fsync'd into the.vtxfirst, then itsC/Trecords are appended and fsync'd. A committed chunk's now-redundantFrecords (repair dedups them by frame index) are reclaimed by periodic compaction -- the log is rewritten (allC/T+ only the still-pendingF) into a temp file that atomically replaces the sidecar, so a crash during compaction leaves either the old or the new complete journal. The writer journals every frame as it is recorded (ReplayWriter::TryRecordFrame-> newSinkPolicy::JournalFramehook; timing viaSinkPolicy::JournalTiming), so a crash between flushes still recovers the pending batch. If the journal cannot be opened cleanly, journaling is disabled and the torn sidecar is removed (a half-written journal would otherwise block repair). On a cleanClose()the footer is written and the.recoveryis deleted -- its presence at open time signals an unclean shutdown - repair --
VTX::RepairReplayFile(path)(sdk/include/vtx/writer/core/vtx_replay_recovery.h) reconstructs a valid, readable file from a footerless.vtx+ its journal: it verifies each committed chunk's checksum (dropping a torn/corrupt tail), re-appends the in-flight frames as chunks, rebuilds the seek table and the exact per-frame times, and synthesizes the footer -- including the derived time data (duration_seconds, timeline gaps, game segments), reconstructed from the journaled timing (Srecord) with the same expressionsVTXGameTimesuses, so a recovered footer matches a cleanStop()exactly (manual segment marks are the one thing not journaled). Recovery is deliberately not automatic on open -- the user-driven flow isReplayNeedsRecovery(path)(cheap sidecar check) /RecoveryJournalPath(path)(locate the sidecar) thenRepairReplayFile(path). A file that already ends with a valid footer plus a leftover journal (a crash between the footer fsync and the journal delete) is detected and preserved untouched; a journal whose recorded format magic disagrees with the file, or whose header is unreadable, is refused rather than applied; a recording still being written is refused without touching it (on Windows the writer holds a deny-write handle, so repair's truncate fails cleanly). Returns aRepairResult(was_clean/repaired/recovered_chunks/recovered_frames/error). Both FlatBuffers and Protobuf files are supported
- per-chunk integrity + durability --
-
tests:
tests/writer/test_crash_recovery.cpp-- 65 cases covering the full crash-window matrix: recovery between chunks (single- and multi-frame), between frames (in-flight batch, and nothing-flushed-yet), a torn tail chunk, a partial chunk written before its journal record, a checksum-detected corrupt chunk, a torn pending-frame record, a torn chunk-commit record (the batch falls back to its still-presentFrecords -- the append-only guarantee), a crash mid-footer-write, a leftover-journal-over-valid-footer, a stale compaction temp, a mismatched journal format, a clean file (no-op), a header-only crash (0-frame result), the lingering-F-record dedup guard, compaction reclaim, the recovery helpers, exact per-frame time preservation, a live-recording repair refusal (Windows), and the FlatBuffers + Protobuf repair paths. Crash states are fabricated byte-faithfully through the sameRecoveryJournalAPI the sink uses, plus end-to-end tests through the real writer (a rawReplayWriterdropped withoutStop()) for both formats that require the recovered footer to match a cleanly-stopped control exactly: per-framegame_time+created_utc,duration_seconds, timeline gaps, game segments, and per-entitycontent_hash-- and require the recovered file to pass whole-replayValidateReplayFileand cache-hostile out-of-order seeks across the committed-chunk/recovered-chunk boundary. Also covered: the sink's full config matrix (durable_writesxb_use_compression) with frames large enough that zstd compression genuinely engages (committed chunks and journaledFpayloads take the compressed path), a repair interrupted mid-run and re-run (idempotent), a second repair on an already-repaired file (clean no-op, byte-identical), a journal-opted-out crash (repair reports clean and leaves the footerless file byte-identical; the reader rejects it gracefully), a session-start crash (tornSrecord + header-only.vtx-> valid 0-frame file), tornTrecords (frame times fall back to the still-presentFrecords -- exact, not zeroed), a 0-byte journal (refused, main file untouched), compaction through the real sink under crash (newConfig::journal_compact_threshold_bytes, 0 = default), map-container frames recovered intact fromFrecords, a non-ASCII path through the full sidecar flow, hostile checksummed journal records (aCoffset inside the header region, aCsize at or below its own length prefix, anFwith an empty payload -- each dropped safely, degrading to a valid file), a torn main-file header alongside a valid journal (refused, bytes untouched), and a decreasing-time recording (is_increasing = false-- the other branch of the segment reconstruction) matching its clean control. Scale + pipeline coverage: a stress run (2030 multi-entity frames, 40 committed chunks + a 30-frame in-flight batch -- every timestamp exact, whole-replay validation clean), rejected frames interleaved mid-recording (timer rejections leave no trace and do not desync the journal's frame indexing), and a post-processor recording (journaledFrecords capture the frame as it would hit disk -- post-mutation -- proven by a pending frame that only ever existed in the journal). Lifecycle + hygiene: a double-crash lifecycle over the same path (crash -> repair -> re-record -> crash -> repair, second recovery reflects only the second session), a stale sidecar under journaling opt-out (a session withenable_recovery_journal = falseremoves any leftover.recoveryat start so it cannot masquerade as recovery state), a foreign.recoveryfile (noVTXRmagic -- never deleted by repair, even on the clean-file path), a hostile out-of-rangeTrecord (ignored by the bounds check), the map crash-recovery path on Protobuf as well as FlatBuffers, a stale journal from a different recording over a replaced file (per-chunk checksums reject the foreign chunks -- graceful degradation to a valid empty file), a read-only crashed file (repair refuses without consuming the journal; succeeds untouched once the file is writable), a no-time-registry recording (EGameTimeType::None, fully FPS-synthesized timeline recovered exactly), and byte-budget chunk splitting (max_bytes, the otherThresholdChunkPolicybranch) through crash + recovery. The maximal guarantee is pinned byBoundaryCrashRecoversByteIdenticalFile(FlatBuffers and Protobuf, plus a 120-frame large-footer variant that exercises the compressed-footer path): a crash exactly at a chunk boundary recovers to a file that is bit-for-bit identical to a cleanStop()with the same inputs -- repair passes the footer's time vectors exactly asStop()does (present-but-empty rather than absent) and compresses the synthesized footer exactly as the sink'sClose()would (theSrecord now also journals{use_compression, compression_level}; journal version 4). A journal with an incompatible version field is refused outright, main file untouched. On top of the hand-picked windows, a brute-force sweep suite (CrashRecoverySweep) proves the "any crash point" claim literally: the journal truncated at every byte length, the main.vtxtruncated at every byte length, and every journal byte flipped one at a time -- thousands of repair runs, none may crash, and every claimed repair must open and agree with its own reported frame count and footer times.ReadValidnow bounds each record's payload allocation by the journal's actual file size (a corrupt length field can no longer trigger a giant transient allocation), pinned byHostileRecordLengthIsBoundedByFileSize; the truncation sweep also runs over the post-compaction journal layout. Finally,CrashRecoveryProcess(Windows) performs a genuine process kill: vtx_tests spawns itself as a child in a hidden endless-writer mode andTerminateProcesses it mid-recording -- no destructors, no fclose, handles abandoned to the kernel -- then repairs and verifies frame content and exact footer times, in both durability modes (this is the honest validation of the flush-only claim that data handed to the OS survives a process death) -- plus a kill under an aggressive compaction cadence (a full close/rewrite/atomic-rename cycle per commit), so real process death lands amid compaction traffic and the atomic-rename guarantee holds, and a varied-progress kill soak (kills landing just after the first journaled frames, around the first flush, amid compaction cycles, and several chunks deep -- alternating durability modes). The recovered output was also smoke-checked through the end-user toolchain:vtx_cliopens a repaired file and servesinfo/footer/framenormally. The whole suite (including the brute-force sweeps and real process kills) also ran clean under AddressSanitizer (MSVC/fsanitize=address) -- zero memory errors in the SDK; the one report is a known protobuf-internalDynamicMessagesized-delete artifact, documented intests/sanitizer_suppressions/asan.supp(ASAN_OPTIONS=new_delete_type_mismatch=0).RecoveredFileTranscodesToCleanChunkspins the normalization workflow for salvaged files (open -> drain frames with footer times -> re-record): the one-frame recovery chunks become proper chunks,created_utcround-trips exactly, andgame_timedrifts at most 1 tick (100 ns) per frame through the float-seconds register -- the documented transcode caveat.BM_ReaderRecoveredTail(benchmarks) measures the salvage cost that motivates it: a 200-frame one-frame-chunk tail reads sequentially ~3x slower than clean chunking -
benchmarks: new
benchmarks/bench_crash_recovery.cpp(BM_WriterDurabilityTier) quantifies the writer hot-path cost of the recovery defaults across the three durability tiers (journal off / journal + flush-only / journal + fsync). Reference numbers on an NVMe dev machine, 200 small frames per iteration: ~19 us/frame with no journal, ~30 us/frame flush-only (process-crash safe -- effectively free), ~289 us/frame with the default per-operation fsync (power-loss safe; ~1.7% of a 60 fps frame budget on NVMe -- on spinning disks preferdurable_writes = false). The byte-identity tests now compare from the end of the header on (the header embeds a second-granularity recording timestamp, so two separately recorded sessions may legitimately differ there; repair never rewrites the header) -
docs:
docs/SDK_API.md"Writing Replays" gains a "Crash recovery" section -- the.recoverysidecar model, the user-drivenReplayNeedsRecovery/RepairReplayFile/RecoveryJournalPathflow withRepairResultsemantics, the exact-times guarantee, the refusal cases, and the sink durability knob table (durable_writes/enable_recovery_journal/journal_compact_threshold_bytes).README.mdfeature list gains a "Crash-safe recording" bullet.DurableFilehardening:Seek/SeekEndfailures now latchGood()false (a write after an unnoticed seek failure would land at the wrong offset), and the deadTruncateprimitive left over from the pre-append-only journal design was removed -
reader/api: side-effect-free frame peek --
IVtxReaderFacade::GetResidentFrame(frame_index)(ReplayReader::GetResidentFramePtr) returns the frame ONLY if its chunk is already resident, without moving the cache window, triggering loads, or cancelling in-flight ones. Intended for incidental reads (e.g. drawing a stale frame while the target frame streams in);GetFrameremains the sole window-driving read. The inspector's stale-frame fallback now uses it (see Fixed) -
reader/api: new
ReplayReaderEvents::OnChunkLoadCancelled(int32_t)-- fired when a load that already emittedOnChunkLoadStartedends without the chunk becoming resident (cancelled by a window shift, or the worker failed).ReaderChunkStatehandles it (clears the chunk from the loading set without marking it loaded) andOpenReplayFilewires it automatically, so the started/finished pair always balances
- reader/perf: chunk teardown moved off the caller thread. Evicting a chunk destroys up to ~1000 frames x hundreds of entities (measured hundreds of ms to >1s for real captures);
UpdateCacheWindownow only unlinks evicted chunks and hands the owned data to a detached background task, so cross-range jumps no longer freeze the calling (UI) thread. The reader destructor does the same with the resident cache (closing a large replay returns at once) -- it still waits for in-flight load workers (they touch the reader), but not for the frees (chunk data is self-contained). Cancelled prefetch futures are parked and reaped viawait_for(0)instead of being destructed inline (astd::asyncfuture blocks in its destructor until the worker unwinds) - sdk-wide: error/warning handling unified onto the diagnostics model -- there is now a single
Severityenum, a singleVtxErrorCodevocabulary, and a single diagnostic/result type family across the SDK:- The schema validator's
SchemaIssueSeverityis now an alias ofSeverity, andSchemaValidationResult::ToReport()is the single canonical bridge fromSchemaIssuetoVtxDiagnostic(used byValidateSchema). - The writer's frame-rejection reason (previously a writer-private
FrameRejectReasonenum) was removed;RecordResultnow carries aVtxError. - The reader's ready-state and open-failure now use
VtxError:ReplayReader/IVtxReaderFacade::GetReadyError()andReaderContext::GetError()returnVtxError(wasstd::string), andReplayReaderEvents::OnReadyFaileddelivers aconst VtxError&.ValidateReplaysurfaces these structured errors directly.
- The schema validator's
- writer/reader: strict cancel on invalid schema -- creating a writer or opening a reader against a schema that fails validation is refused up front rather than proceeding with a degraded schema
- writer/api (entity-type resolution): entity type names must resolve to schema structs. Unknown types now fail at entity creation (in the loader's
EnsureResolvedType) and are rejected during finalization, instead of surfacing later during serialization - writer/api (unique-id dedup): duplicate
unique_ids within the same bucket are rejected.BucketMutator::AddEntity(unique_id)refuses a duplicate id,BucketMutator::SetUniqueIdenforces uniqueness at assignment, and the loader's actor-append path deduplicates within a bucket - build: module
CMakeLists.txtfiles usePROJECT_SOURCE_DIR/VTX_SDK_SOURCE_DIRinstead ofCMAKE_SOURCE_DIRfor source paths, so projects that consume VTX viaFetchContent/add_subdirectoryno longer need a patch - ci: Windows matrix jobs pinned to
windows-2022(waswindows-latest). Thewindows-latestimage moved to a newer default CMake generator (Visual Studio 18 2026) that mismatches theVisual Studio 17 2022generator baked into the cachedbuild/_depsFetchContent subbuild trees, failing the flatbuffers populate step (CMake step for flatbuffers_src failed). Pinning restores a stable default generator (matching local dev) and changes the cache-key prefix so the mismatched cache is no longer restored
- reader/async: cancelled or failed async chunk loads corrupted the chunk-state tracking.
AsyncLoadTaskfiredOnChunkLoadFinishedfor ANY surviving worker -- including cancelled ones whose data was discarded -- soReaderChunkStatemarked chunks "loaded" that were never resident; eviction (which iterates the cache) never fired for them and the loaded set grew without bound. A failed (non-cancelled) load also inserted an emptyCachedChunkinto the cache, permanently blocking retries for that chunk (reads returned null forever). Now a chunk is inserted only when the load actually succeeded, and non-resident outcomes emit the newOnChunkLoadCancelled. Regression test:AsyncRandomJumpsDoNotLeakLoadedSet - reader/async: the async view path could wedge on "loading" forever.
trigger(current_idx)ran subject to themax_concurrent_loadscap and AFTER the range-equality short-circuit -- a cross-range jump with the slots saturated by not-yet-reaped cancelled loads skipped the viewed chunk, committed the new range, and every subsequent (unchanged-range) call returned early, so the chunk was never queued. The viewed chunk now triggers first, uncapped (priority=true), and before the short-circuit, making the path self-healing. Regression test:AsyncViewPathResolvesAfterCrossRangeJump - tools/inspector: scrubbing could ping-pong the cache window and never finish loading. While the target frame streamed in, the stale-frame fallback called
GetFrame(last_drawn)-- a window-driving read that moved the window back to the stale chunk and cancelled the target's load; the next tick reversed it, indefinitely. The fallback now uses the side-effect-freeGetResidentFrame. Regression tests:ResidentFramePeekDoesNotCancelTargetLoad,ResidentFramePeekNeverTriggersLoad - common/hash:
Helpers::CalculateContainerHashused a single sharedthread_localXXH3 state while recursing into nested structs / struct-arrays / maps -- each recursiveresetwiped the parent's in-progress accumulation, so once a container had any nested/map field its scalars / strings / vectors no longer affected the hash. Now each call uses its own state. Latent until the arena gained nested/map fields; it made distinct entities collide (e.g. two players sharing acontent_hash), which in turn made the content-hash-based diff short-circuit treat changed frames as identical. Regression tests added intests/common/test_content_hash_edges.cpp(NestedStruct/Map/StructArrayDoesNotMaskPreRecursionFields) - tools/cli: the interactive
diff <a> <b>command always reported "frames identical" (0 ops) --CliSession::DiffFramescomputed the patch but discarded it (returned{}) and passed the possibly-evictedspan_ainstead of thebytes_acopy it had made. Now returns the real patch computed over the copy (compounded by thecontent_hashbug above; both fixed) - common/loaders:
GenericFlatBufferLoader::LoadArraycould not handlevector<string>-- the pointer-element branch assumed table structs and tried toLoadaflatbuffers::String(noFlatBufferBinding, a hard compile error if instantiated). It now detects the string element (vias->str()) and fills the scalar string array, removing the manual workaround in the arena FlatBuffers binding - reader/proto: the Protobuf reader silently dropped map data on read.
proto_to_vtx.cppdeserialized every container exceptmap_properties/map_arrays, so a map written into a.vtxcame back empty from the Protobuf reader (FlatBuffers was unaffected). Added aFromProto(MapContainer)overload and wired bothmap_propertiesandmap_arraysintoFromProto(PropertyContainer). The Protobuf schema (vtx_schema.proto) and writer (vtx_to_proto.h) already serialized maps -- only the read path was missing - differ: map fields were never diffed correctly on either backend. The FlatBuffers and Protobuf view adapters'
GetMapSize/GetMapKey/GetMapValueAsStructtreated the outermap_propertiesvector (oneMapContainerper map-field slot) as if each element were a single key/value pair -- so a multi-entry map reported size 1, and added / removed keys were never seen. They now iterate the entries within the field'sMapContainer(slot 0, matching the writer/loader convention), soDefaultTreeDiff::DiffMapContainersemits correct add / remove / value-change operations. The Protobuf adapter's "Case 2" also looked for capitalisedKeys/Valuesfields on the wrong message; it now descends intomap_properties->MapContainer.keys/.values. A single map field per struct is assumed (documented in both adapters) - 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; readerCreateProtobuffFacade->CreateProtobufFacade(internalProtobuffFacadeImpl->ProtobufFacadeImpl). Migrate call sites with a literalProtobuff->Protobufrename - common/types:
FlatArray::GetSubArray&FlatArray::GetMutableSubArraydid 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. - writer/flatbuffers:
FlatBuffersVtxPolicy::FromNativehardcoded 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, sharedSerialization::SortBucketByTypeIdhelper inbucket_type_sort.h), preserving every bucket and the frame'sbucket_map. Bucket naming is schema-driven (see the"buckets"entries above), not serializer-driven - reader/schema: the reader-side
PropertyAddressCachebuilt from the embedded schema (PopulateCacheFromJsonString) hand-copied the registry cache and omittedtype_max_indices, soValidateEntityon read frames treated every per-type max as 0 (any populated property would flagFieldIndexOutOfRangeonce frame validation ran). It now reusesSchemaRegistry::GetPropertyCache()verbatim. This also makesValidateReplay's per-frame pass effective for replays whose schema declares a"buckets"array -- it iteratesframe.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 emptybucket_map, so their per-frame pass remains a no-op (unchanged from before) - samples/benchmarks:
basic_write.cppandbench_writer.cppcreated 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"
- writer/sources -- shared-memory input is experimental / WIP and not functional yet. A set of shared-memory headers (
SharedMemoryFrameDataSource,ISharedMemoryTransport,SpscRingTransport,detail/shm_ring.h) landed unintentionally as part of #22's squash (the commit was titled as a CMake fix). The shared-memory frame source is work in progress and not a supported input path -- do not depend on it. The two functional streaming sources remain pipe and WebSocket.
-
tests: integration coverage for both new streaming
IFrameDataSourceimplementations -- runs in the existing GoogleTest suite and so on every GitHub Actions matrix entry (Windows + Linux).tests/writer/test_pipe_source.cppdrivesPipeFrameDataSource<TestAdapter>in server mode with VTX on the receive end and a test-spawned client thread as the producer (WindowsCreateFileA+WriteFile, POSIXopen+write); covers the happy path (50 frames + sentinel round-trip viaOpenReplayFile), sentinel-only empty stream, adapter-false-stops-stream after N valid frames, and theGetExpectedTotalFrames() == 0streaming contract.tests/writer/test_websocket_source.cppdrivesWebSocketFrameDataSource<TestWsAdapter>against an in-processix::WebSocketServerloopback on an OS-assigned port -- covers the happy path (25 messages + parseable.vtx),Initialize()returns false on a refused connection, and the streaming-total contract. Both files share aDrainSourceIntoWriterhelper that takes the writer byunique_ptrand destroys it before reading back the file -- theChunkedFileSinkonly flushes itsstd::ofstreamin its destructor, so an in-scope writer would race the reader.vtx_testslinks$<BUILD_INTERFACE:VTX::deps::ixwebsocket>directly becausevtx_writerkeeps the dep PRIVATE behind its PIMPL boundary. WebSocket happy-path uses the per-client message callback (setOnClientMessageCallback, v12 path) which gives aWebSocket&directly and avoids the weak_ptr-vs-handshake race that the v11-stylesetOnConnectionCallbacktriggers; the bound port is pre-allocated with a rawbind+getsocknamedance becauseix::WebSocketServer::getPort()returns the constructor argument, not the OS-assigned port -
writer/sinks:
ChunkedNetworkSink<Policy>-- second sink alongsideChunkedFileSink, streams the identical binary.vtxbyte stream over a TCP socket instead of writing to disk. Receiver concatenates the bytes into a file and gets a valid.vtx; no custom wire framing beyond the on-disk format's own magic + size prefixes. New public headersdk/include/vtx/writer/policies/sinks/network_sink.h-- self-contained Win32/POSIX socket portability layer (getaddrinfo+socket+connect), throwsstd::runtime_erroron connect failure (parallel toChunkedFileSinkthrowing on open failure),SendAllretries on partialsend, chunk index entries recordfile_offsetvia a runningbytes_sent_counter (thetellp()analogue for a stream). New facade entry points:NetworkWriterFacadeConfig(host + port + the usual replay/chunking/compression knobs, nooutput_filepath) plusCreateFlatBuffersNetworkWriterFacade/CreateProtobuffNetworkWriterFacade-- mirror of the file-sink factories, behind the sameIVtxWriterFacadeabstraction so existing user code is sink-agnostic.vtx_writerpropagatesws2_32PUBLIC on Windows so consumers that instantiateChunkedNetworkSinkget the linkage automatically. Six integration tests intests/writer/test_network_sink.cppspin up a loopback TCP server on a free ephemeral port (server thread signals readiness viastd::promiseso the client cannot connect beforelisten()returns), drain bytes into a vector, and prove the received stream parses cleanly viaVTX::OpenReplayFile(FlatBuffers + Protobuf), the constructor throws on a refused connection, zero-frame sessions produce a valid stream, and chunked sessions produce the expected seek-table entries -
writer/sources:
WebSocketFrameDataSource<Adapter>-- streamingIFrameDataSourceover WebSocket (RFC 6455), connects as a client tows://host:port/pathorwss://host:port/path(TLS). Each WebSocket message is one serialized frame; ping/pong + close + fragment reassembly are handled transparently by the underlying transport. New public headersdk/include/vtx/writer/sources/websocket_frame_source.hplus a PIMPL facade insdk/include/vtx/writer/sources/detail/websocket_client.h-- the implementation insdk/src/vtx_writer/.../websocket_client.cppwraps IXWebSocket and bridges its async callbacks to a blockingReadMessagevia a thread-safe queue (std::mutex+std::condition_variable), so the writer's pull-basedGetNextFrameslots in cleanly. IXWebSocket stays fully hidden behind the PIMPL boundary -- the public SDK headers do not see it.wss://verifies the server certificate against the OS trust store by default (tls.caFile = "SYSTEM"). Auto-reconnect is disabled: a dropped connection surfaces as end-of-stream so the consumer finalises the.vtxinstead of silently resuming mid-file. Format-agnostic: theAdapter(constrained by theIFramePayloadAdapterconcept) is the only piece that knows the on-wire payload format -- the sample'sJsonWebSocketAdapterparses each message as JSON vianlohmann::json+JsonMapping<T>+UniversalDeserializer(declarative -- same pattern asarena_mappings.h) -
writer/sources:
PipeFrameDataSource<Adapter>-- streamingIFrameDataSourceover OS pipes, format-agnostic via a caller-supplied adapter. New public headersdk/include/vtx/writer/sources/pipe_frame_source.h-- length-prefixed framing on the wire ([uint32 LE size][payload], zero-size sentinel = clean EOF) with the payload format determined by theAdapter(sample uses JSON). Three transport modes selected byConfig:- stdin (
pipe_pathempty) -- for shell pipelinesproducer | vtx. Sets_O_BINARYon Windows so CRLF translation doesn't corrupt the wire. - client (
pipe_pathset,as_server = false) -- VTX connects to a pipe / FIFO a producer already created (fopen("rb")). - server (
pipe_pathset,as_server = true) -- VTX creates the pipe and blocks waiting for a producer to connect. Windows:CreateNamedPipeA(PIPE_ACCESS_INBOUND, PIPE_TYPE_BYTE)thenConnectNamedPipe, with the resultingHANDLEwrapped in aFILE*via_open_osfhandle+_fdopensoReadExact/GetNextFramestay platform-agnostic. POSIX:mkfifo(0666)(toleratesEEXIST) thenfopen-- blocks until a writer opens the other end. The FIFO isunlinked on destruction.
Server mode is the mode for an external, independent producer -- e.g. a game injector that opens
\\.\pipe\vtxas a client when its game launches. The injector needs nothing from the VTX SDK: it speaks the[uint32][payload]framing and writes a zero-size sentinel when the session ends. Self-contained contract -- any language - stdin (
-
writer/sources:
IFramePayloadAdapterconcept (sdk/include/vtx/writer/sources/frame_payload_adapter.h) -- shared compile-time contract for adapters used by streaming sources (PipeFrameDataSource,WebSocketFrameDataSource). Single requirement:bool ParseFrame(std::span<const std::byte> payload, VTX::Frame&, GameTime::GameTimeRegister&). The source owns the transport + framing; the adapter is the only place that knows the wire format (JSON / Protobuf / custom binary). Applied as the template constraint onWebSocketFrameDataSource -
samples:
vtx_sample_websocket_consumer(samples/websocket_consumer.cpp) -- connects to a WebSocket server as a client and records the stream into a.vtx. Uses aJsonWebSocketAdapterbuilt onVTX::UniversalDeserializer<>::Load<WsFrame>(JsonAdapter)-- the JSON->struct step is fully declarative (JsonMapping<WsFrame>+JsonMapping<WsEntity>), only the struct->VTX::Framemapping is spelled out. Companionsamples/websocket_server.py-- minimal Python WebSocket server using thewebsocketslibrary; streams JSON frames continuously (~20 entities/frame) until the client disconnects or the user presses Ctrl+C. CLI:vtx_sample_websocket_consumer ws://127.0.0.1:8765/ out.vtx schema.json -
samples:
vtx_sample_pipe_producer(samples/pipe_producer.cpp) +vtx_sample_pipe_consumer(samples/pipe_consumer.cpp) -- pair of CLI tools exercising the pipe data source. Producer emits length-prefixed JSON frames to stdout; consumer reads either stdin (-), a connected pipe (<path>), or creates one (serve:<path>). Producer supports two modes: bounded (producer N) sends exactly N frames then a sentinel; continuous (producer 0) streams indefinitely at ~60 fps until the user presses Enter in its terminal -- a detached stdin watcher thread sets anstd::atomic<bool>stop flag, which makes the loop emit the sentinel cleanly and exit, so the consumer finalises a valid.vtx. Three demo.batscripts:samples/pipe_demo.bat-- anonymous-pipe demo (producer | consumer, single launcher, bounded run).samples/named_pipe_vtx.bat+samples/named_pipe_producer.bat-- two-terminal demo of independent processes meeting over a Windows named pipe. VTX bat runs the consumer in server mode (serve:\\.\pipe\vtx); producer bat retry-connects (the redirect to a not-yet-existing pipe fails, so the retry loop IS the "wait for VTX" handshake -- mirrors how a real named-pipe client like a game injector waits for its server)
-
dependencies: IXWebSocket v12.0.0 + mbedTLS v3.6.2 via FetchContent (
cmake/VtxDependencies.cmake). Same pattern as FlatBuffers / zstd: one pinned version on every platform, no system packages, nothing shipped at runtime. IXWebSocket consumed through a newVTX::deps::ixwebsocketINTERFACE target; linked PRIVATE intovtx_writer(and so never reaches the public SDK headers thanks to the websocket-client PIMPL). mbedTLS detection inside IXWebSocket is fragile across versions, soMBEDTLS_INCLUDE_DIRS+MBEDTLS_LIBRARY+MBEDTLS_X509_LIBRARY+MBEDTLS_CRYPTO_LIBRARYare pre-seeded in the CMake cache beforeFetchContent_MakeAvailable(ixwebsocket); IXWebSocket's bundledFindMbedTLS.cmake'sfind_path/find_libraryshort-circuit on the already-set cache entries and resolve to our FetchContent targets. IXWebSocket's bundledMbedTLS 3.xversion detection misses the header reorganisation in mbedTLS 3.x, so we forceIXWEBSOCKET_USE_MBED_TLS_MIN_VERSION_3on theixwebsockettarget unconditionally (we pin mbedTLS 3.6, so we know). IXWebSocket'sUSE_ZLIBis disabled (nopermessage-deflateextension -- avoids pulling in zlib for a feature we do not need) -
docs:
docs/SDK_API.md"Writing Replays" gains a "Streaming sinks" subsection coveringNetworkWriterFacadeConfig+ the twoCreateXNetworkWriterFacadefactories. "Integration Primitives" gains subsections forPipeFrameDataSource,WebSocketFrameDataSource, andIFramePayloadAdapter.docs/SAMPLES.mdupdated with the three new sample targets + the demo scripts.docs/ARCHITECTURE.mdmodule overview notes the new sinks + sources and lists IXWebSocket / mbedTLS undervtx_writerdependencies.docs/BUILD.mddependency-resolution section gains IXWebSocket and mbedTLS rows + the FetchContent narrative updated accordingly.README.mdfeature list gets a "Live streaming transports" bullet, "Write a replay" gains a#### Optional: stream frames livesubsection with aPipeFrameDataSourcesnippet and a note on external-producer ergonomics; the Requirements blurb is corrected to reflect that FlatBuffers / zstd / IXWebSocket / mbedTLS all come from CMakeFetchContentrather than system packages -
legal:
NOTICEandTHIRD_PARTY_LICENSES.mdextended with the two new third-party components added under Apache-2.0 §4(d). IXWebSocket (BSD-3-Clause, Machine Zone, Inc.) and mbedTLS (Apache-2.0, The Mbed TLS Contributors) each get their own section with the upstream URL, pinned version, license name, and the relevant license /NOTICEtext -- mbedTLS's Apache-2.0 text shares VTX's ownLICENSEper the existing FlatBuffers pattern; IXWebSocket carries the full BSD-3-Clause text inline
-
writer/api: writer-side frame post-processor pipeline. A new hook fires inside
ReplayWriter::RecordFrameafter timer validation and beforeSerializer::FromNativeconsumes the nativeFrame, so whatever the processor mutates is what gets serialised to the on-disk.vtx. Three new public headers and three new facade methods materialise the feature:sdk/include/vtx/writer/core/vtx_frame_post_processor.h--IFramePostProcessorinterface (Init/Process/Clear/PrintInfo),FramePostProcessorChaincomposable container,FramePostProcessorInitContext(frame_accessor + total_frames + schema/format version) andFramePostProcessContext(per-frame: global_frame_index, schema_version, frame_accessor) carriers. Chain execution:Init/Process/PrintInfoin registration order;Clearin reverse (destructor-like teardown); last writer wins on shared property mutations.sdk/include/vtx/writer/core/vtx_frame_mutation_view.h-- write-side mirror ofEntityView/FrameAccessor.EntityMutator(non-owning wrapper overPropertyContainer*withGet<T>+Set<T>+GetMutableView+GetMutableArray<T>);BucketMutator(mutable iteration + structural mutation:AddEntity/RemoveEntity/RemoveIf/Clear);FrameMutationView(entry point the processor receives -- wrapsFrame&+ borrows aFrameAccessor*so processors can resolve schema names without coupling to reader internals). Hot-path cost is identical toEntityView::Get-- single non-owning pointer indirection, fully inlinable.IVtxWriterFacade::SetPostProcessor(std::shared_ptr<IFramePostProcessor>)/GetPostProcessor()/ClearPostProcessor()-- registration API on the writer facade, forwarded to bothFlatBuffersWriterFacadeImplandProtobuffWriterFacadeImpl.Init()runs synchronously insideSetPostProcessorBEFORE the new processor becomes visible to anyRecordFrame()-- this is the right place to resolve everyPropertyKey<T>upfront since the schema is constant for the recording session. The writer is single-threaded by design (RecordFramecalled sequentially from the capture loop) so no mutex is needed onpost_processor_. The destructor invokesClear()on whatever is currently registered.SetPostProcessordoes NOT callClearon the previously-registered processor; the caller keeps theshared_ptrand callsClearexplicitly if they need outgoing teardown -- useClearPostProcessor()for the common case of explicit pre-destruction reset
-
scripts/codegen:
scripts/vtx_codegen.pyextended to emit, per schema struct, in addition to the existingXViewread-only wrapper:XMutator-- write-capable wrapper aroundEntityMutator. AllGet*methods identical to the View; addsSet*(value)for scalars andGetMutable*()returningstd::span<T>(arrays) orEntityMutator(nested structs).PropertyKey<T>resolution stays cached instaticlocals per-method on first use, so registering a processor doesn't trigger a one-time hash sweep.ForEachX(BucketMutator&, FrameAccessor&, Fn)-- template helper that filters a bucket byentity_type_id(matchingEntityType::X) and invokes the lambda with anXMutator&. Read-only counterpartForEachXView(const Bucket&, FrameAccessor&, Fn)paralleled. Result: processors operate on strongly-typed views (p.SetHealth(...)) with zero hardcoded schema strings, zeroPropertyKey<T>members on the processor, and no manualentity_type_idgating -- if the schema changes, regenerating the header makes new properties available; if a property is renamed or removed, code fails to compile early instead of silently mismatching at runtime
-
samples:
vtx_sample_post_process_writetarget (samples/post_process_write.cpp) -- minimum end-to-end demo of the writer-side post-processor. Builds synthetic frames with intentionally out-of-range Health values via the codegen-generatedPlayerMutator, registers aPlayerHealthProcessor(clamp[0, 100], deriveIsAlive=falsewhenHealth<=0, cross-frame low-health counter, lifecycle hooks), records 30 frames, then re-opens the.vtxwithOpenReplayFileand usesForEachPlayerView(also codegen-generated) to print the persisted values -- proving the on-disk bytes contain the post-processed state, not the raw input -
samples:
samples/advance_write.cppextended to register anArenaConsistencyProcessoron each of the three pipelines (JSON / Protobuf / FlatBuffers source). Same processor instance per pipeline usingVTX::ArenaSchema::ForEachPlayer-- demonstrates that frame post-processing is orthogonal to the source format: the same logic runs on the canonicalVTX::Frameregardless of whether it came from JSON, Protobuf, or FlatBuffers -
tests:
tests/writer/test_frame_post_processor.cppwith 10 cases:WriterPostProcessor_MutationViewUnit.SetThenGetRoundTripsandWriterPostProcessor_ChainUnit.OrderAndRemove-- standalone unit smokes for the mutation view + chain primitives.WriterPostProcessorTest.NoProcessorBaselineUnchanged-- behaviour identical when no processor is registered.WriterPostProcessorTest.DoubleHealthIsPersistedToDisk--Initresolves the Health key, processor doubles values pre-serialise, readback confirms 200.0f on disk.WriterPostProcessorTest.ChainLastWriterWinsOnDiskand.ChainRemoveDropsAndOtherStillFires-- chain ordering +Removesemantics from disk.WriterPostProcessorTest.GhostInjectorEntityIsOnDisk--BucketMutator::AddEntityinjects a synthetic entity withentity_type_idset explicitly, readback confirms it persisted.WriterPostProcessorTest.TeamTwoFilterDropsEntitiesFromDisk--BucketMutator::RemoveIffilters entities pre-serialise.WriterPostProcessorTest.GlobalFrameIndexIsMonotonic--ctx.global_frame_indexmonotonically increments acrossRecordFramecalls.WriterPostProcessorTest.ClearPostProcessorCallsClearAndUnregisters-- explicit teardown invokesClearand subsequentRecordFramecalls bypass the processor entirely
-
docs: new
docs/POST_PROCESSING.md-- dedicated reference covering the feature pipeline diagram, lifecycle (Initsynchronous before firstProcess,Clearon destructor / explicit teardown), threading model (single-threaded writer, no mutex needed), two ways to write a processor (generic with rawPropertyKey<T>vs codegen-driven strongly-typedXMutator/ForEachX), patterns for cross-frame state / chains / replay-level metadata / schema-version branching / structural mutation, error handling (exceptions swallowed at hook boundary), performance characteristics (zero overhead when unused; same hot path asEntityViewwhen active), gotchas (the FlatBuffers serialiser drops entities withentity_type_id < 0, the writer renames bucket[0] to "data" / bucket[1] to "bone_data" / drops bucket[2+] silently,type_rangesinvalidated afterRemoveIfbut rebuilt by the serializer), and pointers to the runnable demos -
docs:
docs/SDK_API.mdnew "Frame Post-Processor" section between "Writing Replays" and "Diffing Frames" -- API cheat-sheet covering processor implementation, registration on the writer, chain composition, the strongly-typed codegen alternative, and the mutation view API surface. Links toPOST_PROCESSING.mdfor the full reference -
docs:
docs/SAMPLES.mdupdated for the two new sample targets (vtx_sample_post_process_writeand the post-processor addition invtx_sample_advance_write) plus the extendedarena_generated.hcodegen output (now includes*Mutatorclasses +ForEachXhelpers). "What each sample teaches" table gains four new rows covering the post-processor and codegen-driven typed accessor patterns -
docs:
README.md"Write a replay" snippet gains a sub-section showing a minimalIFramePostProcessorimplementation andwriter->SetPostProcessorregistration. In-tree docs list updated to includePOST_PROCESSING.md -
scripts:
scripts/check_clang_format.py(+.shand.batwrappers) -- local mirror of the CI clang-format diff-gate. Validates only the lines you've modified vs a base ref (defaultorigin/main), matching the CI's exclusion list (thirdparty/,*generated/,arena_generated.h,portable-file-dialogs.h) and scope (.cpp/.cc/.h/.hpp). Three modes via--fix/--base <ref>: read-only check, apply fixes in place, or check against a different ref. Auto-detectsclang-format-diff.pyunderProgram Files\LLVM\share\clang\on Windows when it's not onPATH. Cross-platform wrappers delegate to the Python implementation. Exit codes match CI semantics: 0 clean, 1 violations, 2 tooling missing -
scripts:
scripts/git-hooks/pre-push-- versioned pre-push hook that runscheck_clang_format.pyand aborts the push on violation. Opt-in per clone viagit config core.hooksPath scripts/git-hooks(built-in to git ≥ 2.9, no Husky / pre-commit dependency). Bypass for a one-off push withgit push --no-verify -
docs:
docs/BUILD.md"Formatting gate" subsection extended with the local helper script usage (read-only check,--fix,--basearg) and the pre-push hook activation one-liner. Same coverage inCONTRIBUTING.mdunder "Validate formatting before pushing" + "Pre-push hook" so contributors landing on either doc find the workflow
- sdk/include layout:
vtx_frame_accessor.hmoved fromsdk/include/vtx/reader/core/tosdk/include/vtx/common/. The header is fundamentally a schema utility (FrameAccessorresolves names againstPropertyAddressCache,EntityViewis a generic read-only wrapper overPropertyContainer); pre-move it lived underreader/for historical reasons, which forced the new writer-side post-processor headers (vtx_frame_mutation_view.h,vtx_frame_post_processor.h) to either re-implement the schema-name resolution or include fromreader/(creating a writer→reader cross-dependency). Post-movewriter/core/andreader/core/sharecommon/vtx_frame_accessor.hdirectly and have zero include-path edges between each other. 11 include sites updated to the new path (benchmarks, tests, samples, the codegen script,vtx_reader.h, the two new writer-side headers, andwriter.h); the codegen template emits the new path so regeneratingarena_generated.hproduces a correct include
- scripts:
scripts/release_sdk.sh-- Linux/macOS counterpart toscripts/release_sdk.bat. Builds the SDK libs +vtx_cliin Release mode and installs into./dist. Removes the build/release script asymmetry between Windows and Linux - reader/api:
ReaderContext::IsReady(),IsReadyFailed(),GetReadyError(),WaitUntilReady()+WaitUntilReady(std::chrono::milliseconds)for explicit "first chunk in RAM" signalling, plus newReplayReaderEvents::OnReady/OnReadyFailedcallbacks. PreviouslyReaderContext::Loaded()flipped totruethe instantOpenReplayFile()returned -- header and footer parsed, property-address cache built, seek table ready, but zero chunks decompressed in RAM. The firstGetFrameSync()call still paid the full ZSTD + deserialise cost synchronously, and the Inspector already carried a redundantis_file_loaded_flag alongsideLoaded()to paper over the gap (tools/inspector/include/inspector_session.h:25). NowOpenReplayFile()eagerly kicks off an async load of chunk 0 as part of opening (via the existingWarmAt(0)/UpdateCacheWindowpipeline; empty 0-frame replays flip the flag vacuously through a newMarkReadyVacuous()facade hook so waiters never hang). Callers consume the signal in whichever style they prefer: poll (while (!ctx.IsReady()) ...), block (ctx.WaitUntilReady(2s)), or register a callback (OnReady/OnReadyFailedfire exactly once each, single-shot guarded underready_mutex_so racing async + sync load paths cannot double-fire). Failure semantics: a corrupt or unreadable chunk 0 does NOT failOpenReplayFile()itself -- the reader is still constructed,IsReadyFailed()returnstrue,GetReadyError()carries the message, and downstreamGetFrame*()calls behave as before (returnnullptr/ empty). The header-parsed-ok-but-chunk-zero-broken state stays useful to inspector-style tools that want to show partial file info. Destructor best-effort unblocks any waiter by flippingready_failed_+ notifying the condition variable underready_mutex_; callers remain responsible for joining their waiter threads before destroying theReaderContext(C++ standard requires no blocked waiters at condition-variable destruction time) - tests: six new cases in
tests/reader/test_reader_context.cppunder "§READY: chunk-0 ready signalling".ReaderContextHappy.ReadyFlipsWithinTimeoutOnValidReplayassertsWaitUntilReady(5s)returnstrueon a well-formed replay;ReadyIsStableAcrossRepeatedQueriespins the terminal-state stability guarantee;WaitUntilReadyIsIdempotentasserts repeated calls after ready return immediately;ReaderContextReady.OnReadyFiresOnDirectFacadeWithPreWiredEventsusesCreateFlatBuffersFacade()directly, wires events beforeWarmAt(0), and polls an atomic counter to verify single-shot firing;ReadyIsVacuousForZeroFrameReplayexercises theMarkReadyVacuouspath with aGTEST_SKIPfallback if the writer refuses a 0-frame replay;ReadyFailsOnCorruptChunkZerowrites a valid file then overwrites its middle third with0xFFbytes and verifiesWaitUntilReadyreturnsfalse+IsReadyFailed()+ non-emptyGetReadyError(). No destruction-race test: destroyingstd::condition_variable/std::mutexwhile waiters are blocked is UB per the standard, so the API contract is "join waiters before destroying" and the dtor'snotify_allis best-effort only
- repo layout: all five build/clean/release wrappers moved from the repo root into
scripts/(build_sdk.bat,build_sdk.sh,clean.bat,clean.sh,release_sdk.bat). Each script nowcds to the repo root internally so invocations like./scripts/build_sdk.shorscripts\build_sdk.batwork from any working directory. Documentation references (README, CONTRIBUTING, docs/BUILD.md) updated accordingly - repo layout:
reports/benchmarks/renamed todocs/benchmarks/to signal that the committed baseline outputs are reference documentation (co-located withdocs/PERFORMANCE.mdwhich narrates them) rather than stray CI artefacts.reports/directory removed. References indocs/PERFORMANCE.md,docs/BUILD.md, and the benchmark write-ups updated - reader/api:
OpenReplayFile()now triggers an eager prefetch of chunk 0 via the existing async pipeline before returning. Open latency on the calling thread is unchanged because the load runs on the same background threadWarmAt/UpdateCacheWindowalready dispatches to; the prior "firstGetFrame*()is slow" cost is moved off the first access onto the open-time spawn path (same total work, just overlapped with caller init). Only chunk 0 is warmed -- the facade temporarily narrows the cache window to(0, 0)around the warm call and restores it to the default(2, 2)immediately after, so callers that set a narrow window right afterOpenReplayFile()(memory-constrained tools, tests that isolate a single chunk) observe exactly the cache contents they asked for.ReaderContext::Loaded()semantics are unchanged: still means "reader object exists". New concept isIsReady()== "chunk 0 decompressed and deserialised in RAM"
-
reader:
IVtxReaderFacade::WarmAt(int32_t frame_index)(§3.A) -- explicit prefetch hint. If the enclosing chunk is cached or in flight, this is a no-op; otherwise it kicks off an asynchronous load and returns immediately. Intended use is to callWarmAt(target_frame)at the end of a seek gesture so the ZSTD decompress overlaps with any UI teardown, eliminating the "first frame after seek is slow" stutter. Implemented by routing throughUpdateCacheWindow, which means WarmAt also updates the §1.B EWMA -- from the reader's perspective it is indistinguishable from a "virtual" access -
tests:
ReaderApiFlatBuffers.CancelledPrefetchReEntersWindow-- focused regression for theUpdateCacheWindowcancel + re-enter bug (see Fixed). Runs the cancel + re-enter pattern (prime chunks 0..2, jump to chunk 10 to cancel, jump to chunk 2 to revive) 50 times against a fresh reader each iteration. Pre-fix this fails ~every run under TSan and flakes at single-digit-% on stock release; post-fix it is deterministic green on both -
tests: two new regression tests in
tests/reader/test_reader_api.cpp.ReaderApiFlatBuffers.RandomAccessSkipsLateralPrefetches-- writes a 20-chunk replay, opens withSetCacheWindow(2, 2), performs 10 far-apart jumps (distance 10 chunks each, well above window=2). Asserts that the total distinct chunks loaded is<= 2 * jump_count; pre-§1.B this would be ~5x. The bound is conservative enough to tolerate the first two EWMA bootstrap samples still triggering laterals, tight enough to catch a regressionReaderApiFlatBuffers.WarmAtTriggersAsyncLoadWithoutReading-- opens a 5-chunk replay withSetCacheWindow(0, 0), callsWarmAt(30), pollsReaderChunkState::GetSnapshot()with a 5s deadline, asserts chunk 3 is present. Pins the WarmAt contract: load happens asynchronously, no GetFrame required
-
tests: 47 new tests across 8 new files, driven by a targeted SDK audit. Test suite total: 89 -> 187 passing + 1 intentionally skipped (awaiting a fixture schema with a Map field).
tests/common/test_flat_array_edges.cpp(12 tests) -- repeated OOBPushBack, empty-spanReplaceSubArrayat non-zero indices, insert-at-end-equals-PushBack, erase-last-remaining-subarray, zero-lengthEraseRange,CreateEmptySubArrayinteractions, SSO-boundary operations onFlatArray<std::string>,FlatBoolArray = FlatArray<uint8_t>pintests/reader/test_corrupt_files.cpp(8 tests) -- empty file, file smaller than magic bytes, valid magic but truncated header, truncated before footer, corruptfooter_size, chunk offset beyond EOF, negative / out-of-range frame indices (A1 / A3 regression tests)tests/writer/test_writer_edges.cpp(6 tests) -- zero-frame replay, single-frame replay,chunk_max_frames = 1,RecordFrameafterStop, double-Stopidempotency (A5 regression), frame larger thanchunk_max_bytestests/differ/test_diff_edges.cpp(6 tests) -- empty buckets, differing bucket contents, byte arrays, nestedany_struct_properties, map properties (skipped pending schema fixture), entity replaced under same unique_idtests/common/test_schema_registry_errors.cpp(5 tests) -- empty / malformed / missing-required / duplicate-struct / unknown-typeId JSON inputs must not crashtests/common/test_vtx_game_times_state.cpp(6 tests) -- rollback without prior snapshot, zero-frame resolve, setup-after-data documents contract, clear-then-reuse, snapshot+rollback roundtrip,InsertLiveChunkTimesmonotonicitytests/common/test_content_hash_edges.cpp(5 tests) -- NaN determinism, distinct NaN bit patterns, empty-vs-default equivalence, signed zero distinguishing, move stabilitytests/reader/test_open_replay_edges.cpp(4 tests) -- directory path, missing file size_in_mb zeroing, relative paths resolving against cwd, non-ASCII filenames
-
tests/sanitizer_suppressions/: four suppression files (
asan.supp,lsan.supp,ubsan.supp,tsan.supp) with header comments documenting the respective rule formats. Committed empty; populated on demand when a specific finding is judged to be third-party noise -
benchmarks:
BM_FrameAccessor_Creation(isolatedreader->CreateAccessor()cost, ~6.77 µs/iter) andBM_EntityView_SingleGet(isolatedEntityView view(entity); view.Get(key)cost, 1.70 ns/iter = 585 M reads/s ceiling). Fills the "what does each accessor layer cost on its own?" framing gap -- previously every accessor number was bundled with entity iteration overhead, so customers asking "how fast is your property read?" only had the mixed number. Paired with the existingBM_AccessorKeyResolution(74 ns/key), the three new benchmarks decompose the public property-access API (FrameAccessor->PropertyKey<T>->EntityView) into three independently measurable layers -
reports/benchmarks/: raw output of the 2026-04-23 16:20 canonical run committed (
bench_20260423_162008.{txt,json}) for baseline tracking and future regression comparison. First run to separately measure the three accessor layers (FrameAccessorcreation,PropertyKeyresolution,EntityView::Get). Narrative interpretation lives indocs/PERFORMANCE.mdrather than in a per-run markdown snapshot -- evergreen doc, one source of truth -
samples:
vtx_sample_generatetarget -- simulates a 5v5 arena match (3600 frames @ 60 FPS) and exports three data-source files (arena_replay_data.{json,proto.bin,fbs.bin}) representing raw game telemetry -
samples:
vtx_sample_advance_writetarget -- demonstrates the full data-source pipeline with threeIFrameDataSourceimplementations (JSON / Protobuf / FlatBuffers) driving the writer through SDK-native mapping primitives -
samples:
arena_mappings.h(JSON data model +VTX::JsonMapping<T>specialisations),arena_generated.h(autogenerated schema-field constants + typed views),schemas/arena_data.proto+schemas/arena_data.fbs(arena game-side schemas inarena_pb::/arena_fb::) -
samples: CMake codegen rules for
protoc+flatc --gen-object-api, wired intosamples/CMakeLists.txtso schema edits rebuild automatically -
samples/basic_diff.cpp:
--fail-on-emptyflag used by the sample smoke test registered intests/CMakeLists.txt. Guards against regressions where the differ silently returns empty patches -
docs: new
docs/PERFORMANCE.mdlanding page -- visual health-check table, headline numbers, three-layer accessor breakdown, cache-window finding with its 59 %-slower trap, format-choice caveat, honest "what these numbers do not prove" section. Linked fromREADME.mdanddocs/BUILD.md -
docs:
README.mdnow has a "Performance at a glance" section up top with a headline table and the traffic-light health check. Non-engineering readers landing on the GitHub page get a truthful one-screen summary before the build instructions -
docs/BUILD.md: new "Running benchmarks locally" section mirroring the sanitizer section -- covers configure, full-suite run, filtered run, fixture requirements
-
docs/BUILD.md: "Running sanitizers locally" section covering the ASan+UBsan and TSan invocations, the
vm.mmap_rnd_bitsworkaround, and the fix-vs-suppress decision for findings -
docs: new
docs/SAMPLES.md-- per-sample walkthrough, folder layout, mapping-strategy comparison, codegen explanation -
docs/SDK_API.md: new "Integration Primitives" section documenting
IFrameDataSource,JsonMapping<T>,ProtoBinding<T>,FlatBufferBinding<T> -
docs: README gets a CI status badge;
docs/BUILD.mdgets a "Continuous Integration" section describing the matrix and failure-artefact workflow -
build:
VTX_SANITIZECMake option enables gcc/clang runtime sanitizers (OFF,address,undefined,address,undefined,thread). Gated toNOT MSVC. When enabled, appends-fsanitize=<mode>+-fno-omit-frame-pointer+-gto compile and link options. Documented indocs/BUILD.mdunder "Running sanitizers locally" -
build: cross-platform support for Linux and macOS. Core SDK (vtx_common, vtx_writer, vtx_reader, vtx_differ), CLI tool (vtx_cli), all five sample programs, and the full test suite now build and run on Linux. macOS builds the SDK + CLI; GUI tools (inspector, schema_creator) remain Windows-only until their INI-based settings persistence is ported to XDG
-
build:
cmake/VtxDependencies.cmake-- central dependency resolution module exposingVTX::deps::protobuf,VTX::deps::flatbuffers,VTX::deps::zstdimported targets plusVTX_PROTOC_EXE/VTX_FLATC_EXEcache variables and thevtx_copy_runtime_deps()helper. On Windows,VTX_DEPENDENCY_SOURCE(AUTO/PACKAGE_MANAGER/BUNDLED) picks between the vcpkg manifest and the legacythirdparty/protobuf/bundle; on Linux/macOS, Protobuf comes from the system package manager (with a CONFIG-then-MODULE fallback -- Ubuntu 22.04'slibprotobuf-devdoesn't shipProtobufConfig.cmake). FlatBuffers + zstd are unconditionally fetched from pinned source (FetchContent:v24.12.23andv1.5.6) so the wire format version and compression library are identical on every platform -
build:
VTX_BUILD_SHAREDoption (defaultOFF) -- when enabled the four SDK libraries build as shared libraries (.dll/.so/.dylib) instead of static. The generated protobuf/flatbuffers sources live in an OBJECT library (vtx_generated_code) that each Windows DLL embeds, becauseCMAKE_WINDOWS_EXPORT_ALL_SYMBOLScannot re-export protobuf's_default_instance_data globals -- letting each SDK DLL compile its own copy makes each a self-contained linking unit. IncludesRUNTIME DESTINATION binin the install export and a static-vs-shared comparison indocs/BUILD.md -
build:
vcpkg.jsonmanifest for Windows package-manager builds. Currently lists onlyprotobuf; FlatBuffers and zstd never need vcpkg because FetchContent covers them -
build:
build_sdk.sh-- Linux/macOS counterpart tobuild_sdk.bat. HonoursBUILD_TYPE,CLEAN,SKIP_TESTS,JOBS,INSTALL_PREFIXenv overrides. Runs the full pipeline: configure -> build -> ctest -> install -
build:
clean.sh-- Linux/macOS counterpart toclean.bat -
docs/BUILD.md: Linux / macOS dependency lists per distro (Ubuntu/Debian/Fedora/macOS), environment-variable-driven script usage,
VTX_DEPENDENCY_SOURCEdocumentation, and platform-specific troubleshooting -
cmake: root
CMakeLists.txtwires the newtests/directory through anadd_subdirectory(tests)block guarded byVTX_BUILD_TESTS(defaultON).sdk/src/vtx_common/CMakeLists.txtexposes its generated-code directory as the cache variableVTX_COMMON_GENERATED_DIRso the test target can include the same protobuf / flatbuffers headers vtx_common compiles -
ci: dedicated
clang-formatjob in.github/workflows/build.ymlthat runsclang-format --dry-run --Werroragainst every C++ file added or modified by a PR / push (vs base branch on PR, vsHEAD~1on push). Pre-existing files are not checked -- about 90% of the codebase predates.clang-format, so a full-repo strict check would be permanently red. This "diff gate" catches new formatting regressions without requiring a blame-destroying style sweep. Usesclang-format-15pinned for reproducibility.docs/BUILD.mddocuments the one-liner for running a full-tree sweep locally when the team is ready -
ci:
.github/workflows/build.ymlexpanded from a single Windows build (no tests) into a six-job matrix: Windows and Linux, each in Release-static / Release-shared / Debug-static. Every push and every pull request runs the full ctest suite plus the sample smoke test on all six configurations. Concurrency group cancels superseded runs; failed jobs uploadbuild/Testing/and sampletest_output/artefacts for inspection. GUI tools (vtx_inspector, vtx_schema_creator) are disabled in CI for speed -- the SDK, vtx_cli, and samples are fully covered -
ci: two new Linux matrix jobs in
.github/workflows/build.yml--Linux / ASan+UBsan / DebugandLinux / TSan / Debug. Both run the full ctest suite with sanitizer-specific runtime options (ASAN_OPTIONS,UBSAN_OPTIONS,TSAN_OPTIONS,LSAN_OPTIONS). Test timeout 120 -> 180 seconds; matrixtimeout-minutes30 -> 45. Sanitizer jobs runsudo sysctl -w vm.mmap_rnd_bits=28before the test step to work around TSan's shadow-memory-layout limit on recent Linux kernels (upstream: google/sanitizers#1716) -
ci: sanitizer jobs run
sudo sysctl -w vm.mmap_rnd_bits=28before the test step to work around TSan's shadow-memory-layout limit on recent Linux kernels (vm.mmap_rnd_bits=32by default on Ubuntu 24.04+ / WSL2 on Win11 -- TSan aborts at startup withFATAL: ThreadSanitizer: unexpected memory mappingotherwise). Upstream tracker: google/sanitizers#1716 -
legal:
NOTICEfile (Apache 2.0 §4(d) compliance) andTHIRD_PARTY_LICENSES.mdenumerating every third-party component with its version, upstream URL, license name, and the full text of each license (MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, zlib/libpng). Each bundled dependency underthirdparty/now ships a localLICENSEfile as well (thirdparty/jsonlohmann/LICENSE.MIT,thirdparty/xxhash/LICENSE,thirdparty/protobuf/LICENSE). Fixes the missing attribution for nlohmann/json, xxHash, Protocol Buffers, FlatBuffers, zstd, GoogleTest, Dear ImGui, and GLFW -
docs:
README.mdLicense section expanded with pointers to the newNOTICEandTHIRD_PARTY_LICENSES.md
-
reader/perf:
ReplayReader::UpdateCacheWindownow cancels stale prefetches (§1.A) and skips lateral prefetches under random-access patterns (§1.B). Each in-flight chunk load gets its ownstd::stop_sourcein the newPendingLoadstruct, replacing the single globalstop_source_; when the window shifts, prefetches whose target chunk fell outside the new range haverequest_stop()called on them soAsyncLoadTask/ProcessChunkDatashort-circuit at their next stop-token checkpoint instead of running to completion and evicting immediately afterwards. A second layer (§1.B) tracks an EWMA (α=0.3) of chunk-index distance between consecutive accesses; when the moving average exceedscache_backward_ + cache_forward_the trigger loop only loadscurrent_idxand skips the[start..end]lateral sweep, because random seeks repopulate the full window on every call and the laterals are pure waste. Measured on the cache-sweep benchmark (10k-frame FlatBuffers replay, 50 random accesses per iter, single-iter per config, seed=42):cache_window CPU baseline CPU after Δ CPU items/s baseline items/s after 0 5.2M us 5.4M us +4% 9.5 9.3 2 24.6M us 14.5M us -41% 2.0 3.4 5 22.8M us 16.3M us -29% 2.2 3.1 10 5.5M us 5.2M us -5% 9.3 9.6 20 5.5M us 5.5M us 0% 9.2 9.1 The pathological regime (small symmetric windows under random access, where every jump triggered a prefetch storm that evicted itself before use) sees the largest gain: CPU is cut by 29-41% and random-access throughput improves 40-70%. Wall time moves less (-7% to -20%) because ZSTD decompress dominates the sync path and the cancellation only short-circuits post-decompress work; §1.B is what moves the CPU needle. Cache windows of 0 and >=10 are essentially unchanged, as expected -- no storm to cancel in those regimes
-
build:
find_package(VTX)is now self-contained.VTXConfig.cmakecallsfind_dependency(Protobuf)so consumers automatically pull a matching protobuf runtime, and the zstd static library is installed alongside VTX (exported asVTX::libzstd_static) and hooked intovtx_commonvia$<INSTALL_INTERFACE:...>. A downstream project can linkVTX::vtx_readerwith nothing more thanfind_package(VTX REQUIRED)+ a reachable Protobuf (vcpkg / apt / brew) -
build:
thirdparty/zstd/andthirdparty/flatbuffers/deleted from the repo -- both libraries now come exclusively from FetchContent (pinnedv1.5.6andv24.12.23respectively).thirdparty/protobuf/stays as the Windows CI / local-dev fast path; Protobuf from vcpkg / system package managers continues to work on any platform via the existingVTX_DEPENDENCY_SOURCE=PACKAGE_MANAGER/AUTOcode path -
build: root
CMakeLists.txtis now platform-aware. MSVC-specific settings (CMAKE_MSVC_RUNTIME_LIBRARY,PROTOBUF_STATIC_LIBRARY) are gated byif(MSVC)+VTX_NEEDS_PROTOBUF_STATIC_DEFINE. Non-MSVC builds enableCMAKE_POSITION_INDEPENDENT_CODEand set$ORIGIN/../lib(Linux) /@loader_path/../lib(macOS) RPATH so installed binaries find bundled shared libraries -
build: per-target hardcoded
thirdparty/protobuf/include/thirdparty/flatbuffers/includeentries removed -- headers now reach every consumer transitively throughvtx_common's PUBLIC interface andVTX::deps::*imported targets -
build: generated protobuf / FlatBuffers C++ code now lives under
${CMAKE_CURRENT_BINARY_DIR}/generated(build tree) instead ofsdk/src/vtx_common/src/generated(source tree). The old location caused cross-platform crashes when the source tree got shared between machines with differentflatcversions -- sharing between Windows (flatc 25.x) and Linux (flatc 2.xfrom apt) produced headers that hardcodedFLATBUFFERS_VERSION_MAJOR == 25and static-assert-failed against libflatbuffers 2.x. Consumers (vtx_reader,vtx_writer,vtx_differ,samples,tests,tools/cli,tools/shared) reference theVTX_COMMON_GENERATED_DIRINTERNAL cache variable instead of hardcoding the old path -
build: FlatBuffers is now consumed header-only via FetchContent (pinned
v24.12.23) on every platform. Previously Windows linked a staticflatbuffers.libfromthirdparty/flatbuffers/, while Linux triedfind_package(flatbuffers)againstlibflatbuffers-dev(v2.x on Ubuntu 22.04) -- a version mismatch that produced "Non-compatible flatbuffers version included" static-assert failures.flatcis built from source as part of the CMake graph ($<TARGET_FILE:flatc>). Removedthirdparty/flatbuffers/bin/andthirdparty/flatbuffers/lib/(dead code). Ubuntu no longer needsflatbuffers-compilerorlibflatbuffers-devapt packages -
build: zstd is consumed via FetchContent (pinned
v1.5.6,facebook/zstd), built as a static library and linked into the SDK modules. No runtime DLL /.sodependency on any platform; nolibzstd-dev/ Homebrewzstdrequirement.thirdparty/zstd/stays in the repo for now as an unused fallback that a later cleanup commit will delete -
build:
target_link_libraries(vtx_common ... $<BUILD_INTERFACE:VTX::deps::...>)keeps bundled thirdparty imports out of the install export, sofind_package(VTX)consumers are expected to bring their own protobuf (documented indocs/BUILD.md) -
build: per-target zstd DLL copy commands replaced with the central
vtx_copy_runtime_deps(target)helper. With zstd now statically linked from FetchContent, the helper is effectively a no-op stub -- kept at all call sites so reintroducing a runtime dep is a one-line change inVtxDependencies.cmake -
tools:
BUILD_VTX_INSPECTORandBUILD_VTX_SCHEMA_CREATORdefault toOFFon Linux/macOS (Windows-only glue around INI settings persistence and dialog integration).BUILD_VTX_CLIstaysONeverywhere -
tools:
tools/CMakeLists.txtonly fetches ImGui + GLFW (and only addstools/shared) when at least one GUI tool is enabled. Headless Linux builds no longer pull in X11 as a build requirement --docs/BUILD.mdlists the X11 packages needed if you opt the GUI tools back in -
tools/shared: platform link libraries --
opengl32on Windows,GL/dl/pthreadon Linux,-framework OpenGLon macOS.NOMINMAX/WIN32_LEAN_AND_MEANnow gated to Windows -
tools/shared/src/gui/gui_app.cpp: previously-unguarded
#define GLFW_EXPOSE_NATIVE_WIN32+#include <GLFW/glfw3native.h>wrapped in#if defined(_WIN32)so the file compiles on Linux -
ci:
.github/workflows/build.ymlLinux jobs (Release static, Release shared, Debug static) are now active -- they were scaffolded but commented out in the previous push because the VTX sources didn't yet build on Linux. Workflow apt install now covers justcmake g++ ninja-build protobuf-compiler libprotobuf-dev(FlatBuffers + zstd via FetchContent) -
README.md: requirements row lists Windows + Linux + macOS; adds a "Using vcpkg on Windows" quick-start block;
thirdparty/described as "Header-only deps + legacy Windows binary fallback" -
samples/basic_read.cpp, samples/basic_diff.cpp: default replay path updated to
content/reader/arena/arena_from_fbs_ds.vtx -
samples/basic_diff.cpp: rewritten to exercise
VtxDiff::IVtxDifferFacade::DiffRawFramesinstead of manually hash-comparing entities (the target named "diff" previously never touched the differ module it links against) -
samples/generate_replay.cpp: removed the
+30sUTC workaround now that the underlying SDK bug is fixed; replaced with a fixed historical timestamp (2025-04-19) for reproducible output -
content/ layout: arena schema + three data sources live under
content/writer/arena/; generated.vtxreplays land undercontent/reader/arena/ -
sdk/include/vtx/differ/core/vtx_default_tree_diff.h: standardised xxhash include to
<xxh3.h>-- now consistent withvtx_types_helpers.h -
docs/ARCHITECTURE.md, docs/BUILD.md:
IFrameDataSourcedocumented in the writer section; build outputs list all five sample executables; cross-reference toSAMPLES.md
- reader:
ReplayReader::UpdateCacheWindowno longer leaves a chunk permanently stuck when it is cancelled by a window shift and then immediately re-requested before the worker thread has started running. The §1.A cancellation path (PR #4) flags out-of-window prefetches viarequest_stop()on their per-PendingLoadstop_source but leaves the map entry in place. If the chunk re-entered the window before its worker was scheduled, thetrigger()lambda saw the stale entry, assumed a load was already in flight, and skipped spawning a new task. The original worker then ran, observedstop_requested() == trueat its entry check inPerformHeavyLoading, returned an emptyCachedChunk, andAsyncLoadTaskskipped the cache write (correctly, because the stop was still requested). The future resolved cleanly, but the cache stayed empty. A synchronous caller waiting on that future inGetFramePtrSyncthen read the empty cache and returnednullptr-- manifesting as a spurious load failure under random-seek patterns. Fix:trigger()now detects a pending entry whose stop is already requested and replaces it with a freshPendingLoad; the orphaned worker exits on its own and itsstop_requested()-gated cache write still cannot pollute anything. Exposed by the TSan CI job onReaderApiFlatBuffers.RandomAccessSkipsLateralPrefetches; the scheduling overhead of the ThreadSanitizer runtime makes the "cancelled before scheduled" race far more likely to manifest. Stock release builds had been papering over it by the workers happening to get past the entry check before cancellation landed
5 new correctness bugs surfaced by a targeted SDK audit:
-
A1 --
vtx_reader.hReadFooter():footer_sizewas read from the stream but used without checkingstream.gcount()orstream.fail(). On a truncated file the uninitialised value drove a seek to garbage and crashed the reader (SEH access violation in tests). Now validates stream reads, checks file size, and rejects implausibly large footer sizes -
A1 extended --
vtx_reader.hReadHeader(): same hardening applied (bounds check on declared header size against remaining file) -
A2 --
vtx_reader.hPerformHeavyLoading():stream.read()can produce partial reads; the previous code only checkedraw_buffer.size() <= 4which doesn't reflect actual bytes read. Now validates viastream.gcount()and logs the specific shortfall -
A3 --
vtx_reader.hPerformHeavyLoading(): corrupt seek tables withfile_offset + chunk_size_bytes > file_sizepreviously seeked past EOF and crashed the deserialiser. Now validates the chunk extent against the actual file size before seeking -
A4 --
vtx_reader.hSetEvents()+ callback call-sites:events_was read (viaevents_.OnChunkLoadFinishedetc.) without synchronisation whileSetEvents()could overwrite it from another thread. Reading astd::functionwhile another thread writes is UB. Introducedevents_mutex_+ aGetEventsSnapshot()helper used at every callback site; the actual callback invocations happen on the local snapshot outside the lock -
A5 --
vtx_writer_facade.cppWriterFacadeImpl:RecordFrame,Flush, and subsequentStopcalls after the initialStop()could overwrite the already-finalised file and truncate previously-recorded frames.WriterFacadeImplnow tracks astopped_flag and silently no-ops all three methods afterStop().Stop()itself is idempotent -
vtx_common (
vtx_types.h):VTXGameTimes::AddTimeRegistryno longer rejects valid historical UTC timestamps -- the regression check now correctly requires a prior frame to exist before flagging a repeat -
vtx_common (
vtx_types.h):VTXGameTimes()constructor no longer seedsstart_utc_withGetUtcNowTicks(); field starts at 0 and is populated when real data arrives.Clear()updated for consistency -
vtx_common (
vtx_types.h):OnlyIncreasing/OnlyDecreasinggame-time filters no longer reject the very first frame of a replay whosegame_timeis 0 -
vtx_common (
vtx_types.h): three warning messages inAddTimeRegistryused printf specifiers (%lld,%f) insidestd::formatcalls -- the values were never printed. Converted to{}placeholders -
vtx_reader (
vtx_reader_facade.h): swapped member declaration order inReaderContextsoreaderis destroyed beforechunk_state. The reader's async chunk-load callbacks capture a raw pointer intochunk_state; the previous order created a potential use-after-free during context teardown -
vtx_reader (
vtx_reader.h):PerformHeavyLoadingno longer silently swallows deserialization exceptions -- logs the exception message before returning an empty chunk -
sdk/src/schemas/vtx_schema.proto: removed six stray
// Aleauthor comments from the public Protobuf schema
- An earlier revision of the Windows CI dependency work also moved Windows CI onto vcpkg manifest mode and deleted the bundled
thirdparty/protobuf/. That change was reverted: vcpkg on GitHub's Windows runners builds Protobuf + abseil from source on every cache miss (~15-30 min per job), which made CI timeouts the common case.vcpkg.jsonis still committed for contributors who prefer the package-manager flow locally, but CI sticks with the bundled-binary fast path
- vtx_common: Core type system with SoA-based
PropertyContainer,Frame,Bucket, andTransformtypes - vtx_common: Dual serialization backend support (Protocol Buffers and FlatBuffers)
- vtx_common: Schema registry with JSON-based schema definitions and
PropertyAddressCachefor O(1) property lookup - vtx_common: zstd compression for chunks and headers
- vtx_common: xxHash-based content hashing for fast frame comparison
- vtx_common: Thread-safe logger with configurable sinks (
VTX_INFO,VTX_WARN,VTX_ERROR,VTX_DEBUG) - vtx_writer:
IVtxWriterFacadefor recording frame data into chunked.vtxfiles - vtx_writer:
ChunkedFileSinkwith configurable chunk size, compression, and seek table generation - vtx_writer: Protobuf and FlatBuffers serialization policies
- vtx_reader:
IVtxReaderFacadewith random-access and streaming frame access - vtx_reader: Async chunk-based caching with configurable cache window
- vtx_reader:
FrameAccessorfor type-safe, O(1) property access viaPropertyKey<T> - vtx_reader: Seek table for O(1) chunk lookup by frame index
- vtx_differ:
DefaultTreeDiff<TNodeView>for structural comparison of replay trees - vtx_differ:
PatchIndexandDiffIndexOpfor tracking add/remove/modify operations - vtx_differ: Configurable float epsilon for approximate comparisons
- vtx_differ: Protobuf and FlatBuffers view adapters
- Tools: VTX Inspector -- ImGui-based GUI for browsing replay files
- Tools: VTX CLI -- Headless JSON inspector for scripting and AI agents
- Tools: Schema Creator -- Interactive schema definition tool
- Build: CMake build system with modular options and CMake Presets support
- Build:
build_sdk.batone-click build script for Windows