From 507068ec69ae1ef0b9117c1649fb013459858987 Mon Sep 17 00:00:00 2001 From: Alejandro Canela Date: Tue, 12 May 2026 14:13:21 +0200 Subject: [PATCH] feat(writer): Added frame post processing. Frame post processing allows to sanitize, mofidy, infer or remove data before it goes inside of VTX. It runs in the writer during the ProcessFrame, implemented as single post process or a a chain of post process. Call SetPostPRocessor from the writer facade and pass the passing a FramePostProcessorChain previusly filled with IFramePostProcessor To achive this vtx_codegen.py is extended to generate entityview mutators to easily modify values by SetHealth,SetArmor,etc. Removing the need of knowing and harcoding strings in cpp. Added a unit test, bencharmk and samples of the new frame post processing benchmark --- CHANGELOG.md | 33 ++ CONTRIBUTING.md | 33 +- README.md | 28 +- benchmarks/bench_accessor.cpp | 2 +- benchmarks/bench_accessor_key_resolution.cpp | 2 +- benchmarks/bench_cs.cpp | 2 +- benchmarks/bench_rl.cpp | 2 +- benchmarks/bench_scenarios.cpp | 2 +- docs/BUILD.md | 33 ++ docs/POST_PROCESSING.md | 336 +++++++++++++++++ docs/SAMPLES.md | 52 ++- docs/SDK_API.md | 113 ++++++ samples/CMakeLists.txt | 8 + samples/advance_write.cpp | 65 ++++ samples/arena_generated.h | 352 +++++++++++++++++- .../reader/arena/arena_from_fbs_ds.vtx | Bin 1783337 -> 1783337 bytes .../reader/arena/arena_from_json_ds.vtx | Bin 1783339 -> 1783336 bytes .../reader/arena/arena_from_proto_ds.vtx | Bin 1887284 -> 1887291 bytes samples/post_process_write.cpp | 223 +++++++++++ samples/post_processed.vtx | Bin 0 -> 3302 bytes scripts/vtx_codegen.py | 320 ++++++++++------ .../core => common}/vtx_frame_accessor.h | 0 sdk/include/vtx/reader/core/vtx_reader.h | 10 +- .../vtx/writer/core/vtx_frame_mutation_view.h | 261 +++++++++++++ .../writer/core/vtx_frame_post_processor.h | 100 +++++ .../vtx/writer/core/vtx_writer_facade.h | 10 + sdk/include/vtx/writer/core/writer.h | 62 +++ .../src/vtx/reader/core/vtx_reader_facade.cpp | 26 +- .../src/vtx/writer/core/vtx_writer_facade.cpp | 14 +- tests/CMakeLists.txt | 1 + tests/common/test_frame_accessor.cpp | 2 +- tests/writer/test_frame_post_processor.cpp | 336 +++++++++++++++++ 32 files changed, 2266 insertions(+), 162 deletions(-) create mode 100644 docs/POST_PROCESSING.md create mode 100644 samples/post_process_write.cpp create mode 100644 samples/post_processed.vtx rename sdk/include/vtx/{reader/core => common}/vtx_frame_accessor.h (100%) create mode 100644 sdk/include/vtx/writer/core/vtx_frame_mutation_view.h create mode 100644 sdk/include/vtx/writer/core/vtx_frame_post_processor.h create mode 100644 tests/writer/test_frame_post_processor.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ba678..8643ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **writer/api**: writer-side frame post-processor pipeline. A new hook fires inside `ReplayWriter::RecordFrame` after timer validation and **before** `Serializer::FromNative` consumes the native `Frame`, 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`** -- `IFramePostProcessor` interface (`Init` / `Process` / `Clear` / `PrintInfo`), `FramePostProcessorChain` composable container, `FramePostProcessorInitContext` (frame_accessor + total_frames + schema/format version) and `FramePostProcessContext` (per-frame: global_frame_index, schema_version, frame_accessor) carriers. Chain execution: `Init`/`Process`/`PrintInfo` in registration order; `Clear` in reverse (destructor-like teardown); last writer wins on shared property mutations. + - **`sdk/include/vtx/writer/core/vtx_frame_mutation_view.h`** -- write-side mirror of `EntityView` / `FrameAccessor`. `EntityMutator` (non-owning wrapper over `PropertyContainer*` with `Get` + `Set` + `GetMutableView` + `GetMutableArray`); `BucketMutator` (mutable iteration + structural mutation: `AddEntity` / `RemoveEntity` / `RemoveIf` / `Clear`); `FrameMutationView` (entry point the processor receives -- wraps `Frame&` + borrows a `FrameAccessor*` so processors can resolve schema names without coupling to reader internals). Hot-path cost is identical to `EntityView::Get` -- single non-owning pointer indirection, fully inlinable. + - **`IVtxWriterFacade::SetPostProcessor(std::shared_ptr)` / `GetPostProcessor()` / `ClearPostProcessor()`** -- registration API on the writer facade, forwarded to both `FlatBuffersWriterFacadeImpl` and `ProtobuffWriterFacadeImpl`. `Init()` runs synchronously inside `SetPostProcessor` BEFORE the new processor becomes visible to any `RecordFrame()` -- this is the right place to resolve every `PropertyKey` upfront since the schema is constant for the recording session. The writer is single-threaded by design (`RecordFrame` called sequentially from the capture loop) so no mutex is needed on `post_processor_`. The destructor invokes `Clear()` on whatever is currently registered. `SetPostProcessor` does NOT call `Clear` on the previously-registered processor; the caller keeps the `shared_ptr` and calls `Clear` explicitly if they need outgoing teardown -- use `ClearPostProcessor()` for the common case of explicit pre-destruction reset +- **scripts/codegen**: `scripts/vtx_codegen.py` extended to emit, per schema struct, in addition to the existing `XView` read-only wrapper: + - **`XMutator`** -- write-capable wrapper around `EntityMutator`. All `Get*` methods identical to the View; adds `Set*(value)` for scalars and `GetMutable*()` returning `std::span` (arrays) or `EntityMutator` (nested structs). `PropertyKey` resolution stays cached in `static` locals 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 by `entity_type_id` (matching `EntityType::X`) and invokes the lambda with an `XMutator&`. Read-only counterpart `ForEachXView(const Bucket&, FrameAccessor&, Fn)` paralleled. Result: processors operate on strongly-typed views (`p.SetHealth(...)`) with zero hardcoded schema strings, zero `PropertyKey` members on the processor, and no manual `entity_type_id` gating -- 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_write` target (`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-generated `PlayerMutator`, registers a `PlayerHealthProcessor` (clamp `[0, 100]`, derive `IsAlive=false` when `Health<=0`, cross-frame low-health counter, lifecycle hooks), records 30 frames, then re-opens the `.vtx` with `OpenReplayFile` and uses `ForEachPlayerView` (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.cpp` extended to register an `ArenaConsistencyProcessor` on each of the three pipelines (JSON / Protobuf / FlatBuffers source). Same processor instance per pipeline using `VTX::ArenaSchema::ForEachPlayer` -- demonstrates that frame post-processing is **orthogonal to the source format**: the same logic runs on the canonical `VTX::Frame` regardless of whether it came from JSON, Protobuf, or FlatBuffers +- **tests**: `tests/writer/test_frame_post_processor.cpp` with 10 cases: + - `WriterPostProcessor_MutationViewUnit.SetThenGetRoundTrips` and `WriterPostProcessor_ChainUnit.OrderAndRemove` -- standalone unit smokes for the mutation view + chain primitives. + - `WriterPostProcessorTest.NoProcessorBaselineUnchanged` -- behaviour identical when no processor is registered. + - `WriterPostProcessorTest.DoubleHealthIsPersistedToDisk` -- `Init` resolves the Health key, processor doubles values pre-serialise, readback confirms 200.0f on disk. + - `WriterPostProcessorTest.ChainLastWriterWinsOnDisk` and `.ChainRemoveDropsAndOtherStillFires` -- chain ordering + `Remove` semantics from disk. + - `WriterPostProcessorTest.GhostInjectorEntityIsOnDisk` -- `BucketMutator::AddEntity` injects a synthetic entity with `entity_type_id` set explicitly, readback confirms it persisted. + - `WriterPostProcessorTest.TeamTwoFilterDropsEntitiesFromDisk` -- `BucketMutator::RemoveIf` filters entities pre-serialise. + - `WriterPostProcessorTest.GlobalFrameIndexIsMonotonic` -- `ctx.global_frame_index` monotonically increments across `RecordFrame` calls. + - `WriterPostProcessorTest.ClearPostProcessorCallsClearAndUnregisters` -- explicit teardown invokes `Clear` and subsequent `RecordFrame` calls bypass the processor entirely +- **docs**: new **`docs/POST_PROCESSING.md`** -- dedicated reference covering the feature pipeline diagram, lifecycle (`Init` synchronous before first `Process`, `Clear` on destructor / explicit teardown), threading model (single-threaded writer, no mutex needed), two ways to write a processor (generic with raw `PropertyKey` vs codegen-driven strongly-typed `XMutator` / `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 as `EntityView` when active), gotchas (the FlatBuffers serialiser drops entities with `entity_type_id < 0`, the writer renames bucket[0] to "data" / bucket[1] to "bone_data" / drops bucket[2+] silently, `type_ranges` invalidated after `RemoveIf` but rebuilt by the serializer), and pointers to the runnable demos +- **docs**: **`docs/SDK_API.md`** new "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 to `POST_PROCESSING.md` for the full reference +- **docs**: **`docs/SAMPLES.md`** updated for the two new sample targets (`vtx_sample_post_process_write` and the post-processor addition in `vtx_sample_advance_write`) plus the extended `arena_generated.h` codegen output (now includes `*Mutator` classes + `ForEachX` helpers). "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 minimal `IFramePostProcessor` implementation and `writer->SetPostProcessor` registration. In-tree docs list updated to include `POST_PROCESSING.md` + +- **scripts**: `scripts/check_clang_format.py` (+ `.sh` and `.bat` wrappers) -- local mirror of the CI clang-format diff-gate. Validates only the lines you've modified vs a base ref (default `origin/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 `: read-only check, apply fixes in place, or check against a different ref. Auto-detects `clang-format-diff.py` under `Program Files\LLVM\share\clang\` on Windows when it's not on `PATH`. 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 runs `check_clang_format.py` and aborts the push on violation. Opt-in per clone via `git config core.hooksPath scripts/git-hooks` (built-in to git ≥ 2.9, no Husky / pre-commit dependency). Bypass for a one-off push with `git push --no-verify` +- **docs**: **`docs/BUILD.md`** "Formatting gate" subsection extended with the local helper script usage (read-only check, `--fix`, `--base` arg) and the pre-push hook activation one-liner. Same coverage in **`CONTRIBUTING.md`** under "Validate formatting before pushing" + "Pre-push hook" so contributors landing on either doc find the workflow + +### Changed + +- **sdk/include layout**: `vtx_frame_accessor.h` moved from `sdk/include/vtx/reader/core/` to `sdk/include/vtx/common/`. The header is fundamentally a schema utility (`FrameAccessor` resolves names against `PropertyAddressCache`, `EntityView` is a generic read-only wrapper over `PropertyContainer`); pre-move it lived under `reader/` 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 from `reader/` (creating a writer→reader cross-dependency). Post-move `writer/core/` and `reader/core/` share `common/vtx_frame_accessor.h` directly 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, and `writer.h`); the codegen template emits the new path so regenerating `arena_generated.h` produces a correct include + ## [0.1.1] - 2026-04-28 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0121f8f..dd456c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,32 @@ scripts\build_sdk.bat A `.clang-format` file is provided for automatic formatting of new code. Do not mass-reformat existing files. +### Validate formatting before pushing + +CI runs a clang-format diff-gate on every PR: only the **lines** you've modified vs the base branch get checked (pre-existing files predate the style file and are exempt). To catch violations locally before pushing, use the helper script: + +```bash +# Check (read-only) -- same logic as CI +python scripts/check_clang_format.py + +# Auto-fix in place +python scripts/check_clang_format.py --fix +``` + +There are `.sh` and `.bat` wrappers if you prefer them (`scripts/check_clang_format.sh` / `.bat`). The script auto-detects `clang-format-diff.py` on Windows under `Program Files\LLVM\share\clang\` if it's not on `PATH`. + +### Pre-push hook (optional, opt-in) + +To run the format check automatically before every `git push`, activate the versioned hook directory once in your clone: + +```bash +git config core.hooksPath scripts/git-hooks +``` + +That activates `scripts/git-hooks/pre-push`, which calls the format check and aborts the push if it fails. Bypass for a one-off push with `git push --no-verify`. Deactivate with `git config --unset core.hooksPath`. + +No external dependencies -- `core.hooksPath` is built into git ≥ 2.9. + ### Naming Conventions | Element | Convention | Example | @@ -88,9 +114,10 @@ These use C++20 `std::format` syntax (`{}`), **not** printf-style (`%s`, `%d`). 1. Create a feature branch from `main` 2. Make focused, incremental changes -3. Ensure the project builds without warnings -4. Test with sample replay files when possible -5. Write clear commit messages describing _why_, not just _what_ +3. Run `python scripts/check_clang_format.py` before pushing (or set up the pre-push hook -- see [Validate formatting before pushing](#validate-formatting-before-pushing)) +4. Ensure the project builds without warnings +5. Test with sample replay files when possible +6. Write clear commit messages describing _why_, not just _what_ ## Project Structure diff --git a/README.md b/README.md index e96ae50..e476321 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,32 @@ writer->Flush(); writer->Stop(); ``` +#### Optional: post-process every frame before it lands on disk + +`writer->SetPostProcessor(...)` installs a hook that runs after timer validation and before serialization. Whatever it mutates is what gets persisted. Use it to sanitize values, derive consistency state, filter or inject entities, accumulate cross-frame stats, or branch by schema version. + +```cpp +#include "vtx/writer/core/vtx_frame_post_processor.h" + +class HealthClamp : public VTX::IFramePostProcessor { +public: + void Init(const VTX::FramePostProcessorInitContext& ctx) override { + health_key_ = ctx.frame_accessor->Get("Player", "Health"); + } + void Process(VTX::FrameMutationView& view, const VTX::FramePostProcessContext&) override { + for (auto entity : view.GetBucket("entity")) { + if (entity.Get(health_key_) < 0.0f) entity.Set(health_key_, 0.0f); + } + } +private: + VTX::PropertyKey health_key_ {-1}; +}; + +writer->SetPostProcessor(std::make_shared()); +``` + +For string-free, schema-driven processors with type-safety in compile time, use `scripts/vtx_codegen.py` to generate per-struct mutators and iteration helpers (`PlayerMutator`, `ForEachPlayer`, etc.) -- full reference in [`docs/POST_PROCESSING.md`](docs/POST_PROCESSING.md), runnable end-to-end demo in [`samples/post_process_write.cpp`](samples/post_process_write.cpp). + ### Read a replay ```cpp @@ -134,7 +160,7 @@ Dependencies are pulled via `vcpkg.json` on Windows or system packages on Linux. - **[Performance](https://github.com/ZenosInteractive/VTX/wiki/Performance)** — full numbers, methodology, and sizing guidance. - **[Use Cases](https://github.com/ZenosInteractive/VTX/wiki/Use-Cases)** — what people build on top of VTX. -In-tree reference: [`docs/`](docs/) includes `ARCHITECTURE.md`, `BUILD.md`, `FILE_FORMAT.md`, `PERFORMANCE.md`, `SAMPLES.md`, `SDK_API.md`. +In-tree reference: [`docs/`](docs/) includes `ARCHITECTURE.md`, `BUILD.md`, `FILE_FORMAT.md`, `PERFORMANCE.md`, `POST_PROCESSING.md`, `SAMPLES.md`, `SDK_API.md`. ## License diff --git a/benchmarks/bench_accessor.cpp b/benchmarks/bench_accessor.cpp index b6e2a59..5324e2a 100644 --- a/benchmarks/bench_accessor.cpp +++ b/benchmarks/bench_accessor.cpp @@ -23,7 +23,7 @@ // resident #include "vtx/reader/core/vtx_reader_facade.h" -#include "vtx/reader/core/vtx_frame_accessor.h" +#include "vtx/common/vtx_frame_accessor.h" #include "vtx/common/vtx_logger.h" #include "vtx/common/vtx_types.h" diff --git a/benchmarks/bench_accessor_key_resolution.cpp b/benchmarks/bench_accessor_key_resolution.cpp index fa56987..86ea366 100644 --- a/benchmarks/bench_accessor_key_resolution.cpp +++ b/benchmarks/bench_accessor_key_resolution.cpp @@ -9,7 +9,7 @@ // directly. This file probes the public-facing API instead. #include "vtx/reader/core/vtx_reader_facade.h" -#include "vtx/reader/core/vtx_frame_accessor.h" +#include "vtx/common/vtx_frame_accessor.h" #include "vtx/common/vtx_logger.h" #include diff --git a/benchmarks/bench_cs.cpp b/benchmarks/bench_cs.cpp index 46a8644..ca7f69f 100644 --- a/benchmarks/bench_cs.cpp +++ b/benchmarks/bench_cs.cpp @@ -23,7 +23,7 @@ #include "bench_utils.h" #include "vtx/reader/core/vtx_reader_facade.h" -#include "vtx/reader/core/vtx_frame_accessor.h" +#include "vtx/common/vtx_frame_accessor.h" #include "vtx/differ/core/vtx_differ_facade.h" #include "vtx/common/vtx_logger.h" #include "vtx/common/vtx_types.h" diff --git a/benchmarks/bench_rl.cpp b/benchmarks/bench_rl.cpp index 466a011..e382695 100644 --- a/benchmarks/bench_rl.cpp +++ b/benchmarks/bench_rl.cpp @@ -16,7 +16,7 @@ #include "bench_utils.h" #include "vtx/reader/core/vtx_reader_facade.h" -#include "vtx/reader/core/vtx_frame_accessor.h" +#include "vtx/common/vtx_frame_accessor.h" #include "vtx/differ/core/vtx_differ_facade.h" #include "vtx/common/vtx_logger.h" #include "vtx/common/vtx_types.h" diff --git a/benchmarks/bench_scenarios.cpp b/benchmarks/bench_scenarios.cpp index 648a0f4..eb5da40 100644 --- a/benchmarks/bench_scenarios.cpp +++ b/benchmarks/bench_scenarios.cpp @@ -23,7 +23,7 @@ #include "bench_utils.h" #include "vtx/reader/core/vtx_reader_facade.h" -#include "vtx/reader/core/vtx_frame_accessor.h" +#include "vtx/common/vtx_frame_accessor.h" #include "vtx/differ/core/vtx_differ_facade.h" #include "vtx/common/vtx_logger.h" #include "vtx/common/vtx_types.h" diff --git a/docs/BUILD.md b/docs/BUILD.md index 4d08257..509b6ca 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -10,6 +10,39 @@ Every push to `main` / `master` and every pull request triggers the CI workflow A dedicated `clang-format` job checks every C++ file **added or modified** by the PR / push against `.clang-format`. Pre-existing files are not checked -- about 90% of the codebase predates the style file, so a strict full-repo check would be permanently red. The gate catches *new* formatting regressions without forcing a one-time blame-destroying style sweep. +#### Validate locally before pushing + +The same diff-gate logic is shipped as a local helper so you can catch violations before the CI does: + +```bash +# Check (read-only) -- exits 1 if there are violations on changed lines +python scripts/check_clang_format.py + +# Auto-fix the offending lines in place +python scripts/check_clang_format.py --fix + +# Different base ref (default is origin/main) +python scripts/check_clang_format.py --base HEAD~1 +``` + +Cross-platform wrappers: +- `scripts/check_clang_format.sh` -- Linux / macOS / WSL / Git Bash +- `scripts/check_clang_format.bat` -- Windows `cmd` + +Exit codes: `0` clean, `1` violations, `2` tooling missing. The script auto-detects `clang-format-diff.py` under `Program Files\LLVM\share\clang\` on Windows when it's not on `PATH`. + +#### Pre-push hook (opt-in, versioned in the repo) + +A versioned pre-push hook lives at `scripts/git-hooks/pre-push` and runs the same check before every `git push`. Activate it in your clone once: + +```bash +git config core.hooksPath scripts/git-hooks +``` + +No external dependency (Husky, pre-commit, etc.) -- `core.hooksPath` is built into git ≥ 2.9. To bypass for a one-off push: `git push --no-verify`. To deactivate: `git config --unset core.hooksPath`. + +#### Sweeping the whole tree + If you want to apply the style to the whole tree in one commit (consider recording that SHA in `.git-blame-ignore-revs` afterwards): ```bash diff --git a/docs/POST_PROCESSING.md b/docs/POST_PROCESSING.md new file mode 100644 index 0000000..ff276f6 --- /dev/null +++ b/docs/POST_PROCESSING.md @@ -0,0 +1,336 @@ +# Frame Post-Processor + +A writer-side hook that runs on every `RecordFrame()` call, **after** timer validation succeeds and **before** the frame is handed to the serializer. Whatever the processor mutates is what ends up in the on-disk `.vtx` file. + +This is the canonical extension point for game-specific or integration-specific transformations: clamp out-of-range values, derive consistency state (e.g. `IsAlive` from `Health`), filter or inject entities, accumulate cross-frame statistics, branch behaviour by schema version. + +The SDK provides the **pipeline** (interface, chain, mutation view, lifecycle). Each integration provides the **logic** (processor classes) and, optionally, **strongly-typed accessors** via `scripts/vtx_codegen.py`. + +## Concepts + +``` + +-----------------------+ +RecordFrame --->| timer validation | + +-----------+-----------+ + | + v + +-----------------------+ + | POST-PROCESSOR HOOK | <-- IFramePostProcessor::Process + +-----------+-----------+ + | + v + +-----------------------+ + | Serializer::FromNat | <-- mutations land in wire bytes + +-----------+-----------+ + | + v + (.vtx file) +``` + +| Concept | Where it lives | What it does | +|---|---|---| +| `IFramePostProcessor` | `vtx/writer/core/vtx_frame_post_processor.h` | Interface that integrators implement. Four hooks: `Init`, `Process`, `Clear`, `PrintInfo`. | +| `FramePostProcessorChain` | same | Composable container of processors. Implements `IFramePostProcessor` itself, so a chain is registered just like a single processor. | +| `FrameMutationView` | `vtx/writer/core/vtx_frame_mutation_view.h` | Write-side mirror of `EntityView`/`FrameAccessor`. Entry point the processor receives per frame. | +| `EntityMutator` / `BucketMutator` | same | Cheap non-owning wrappers. `Get` (read) + `Set` (write) + iteration + structural mutation. | +| `FramePostProcessorInitContext` | `vtx_frame_post_processor.h` | Passed to `Init` once per registration: schema accessor, total frames, version info. | +| `FramePostProcessContext` | same | Passed to `Process` per frame: `global_frame_index`, schema version, frame accessor. | + +## Lifecycle + +| Method | When the writer calls it | Override? | +|---|---|---| +| `Init(InitContext)` | Once, synchronously inside `SetPostProcessor()` -- BEFORE the new processor becomes visible to any `RecordFrame()` | Optional. Resolve every `PropertyKey` upfront and load external resources here. | +| `Process(view, ctx)` | Once per `RecordFrame()`, on the writer's calling thread | **Mandatory.** The hot path. | +| `Clear()` | Writer's destructor, OR explicit `ClearPostProcessor()` | Optional. Reset cross-frame accumulators here. | +| `PrintInfo() const` | Whenever the caller invokes it | Optional. Telemetry dump. | + +`Clear` is **not** called on `Stop()` -- the writer can be re-used between `Stop` and destruction (e.g. caller does `Stop`, inspects telemetry, then drops the writer). `Clear` is the destructor's job, or use `ClearPostProcessor()` for explicit teardown. + +## Threading model + +The writer is **single-threaded by design**: `RecordFrame()` is called sequentially from the user's capture loop. No mutex is needed on `post_processor_`. `SetPostProcessor` and `RecordFrame` are expected to be called from the same thread. + +This is different from the reader-side processor model some other VTX features use (e.g. async chunk loads). The writer's simplicity is deliberate. + +## API at a glance + +```cpp +namespace VTX { + + struct FramePostProcessorInitContext { + const FrameAccessor* frame_accessor = nullptr; + int32_t total_frames = 0; + uint32_t schema_version = 0; + uint32_t format_major = 0; + uint32_t format_minor = 0; + }; + + struct FramePostProcessContext { + int32_t global_frame_index = 0; + int32_t chunk_local_frame_index = 0; + int32_t chunk_index = 0; + uint32_t schema_version = 0; + const FrameAccessor* frame_accessor = nullptr; + const Frame* previous_frame = nullptr; // null on writer-side + }; + + class IFramePostProcessor { + public: + virtual ~IFramePostProcessor() = default; + virtual void Init(const FramePostProcessorInitContext&) {} + virtual void Clear() {} + virtual void PrintInfo() const {} + virtual void Process(FrameMutationView& view, + const FramePostProcessContext& ctx) = 0; + }; + + class FramePostProcessorChain final : public IFramePostProcessor { + public: + void Add(std::shared_ptr); + bool Remove(const std::shared_ptr&); + size_t size() const noexcept; + // Init / Process / PrintInfo run in registration order. + // Clear runs in REVERSE order (destructor-like teardown). + }; + +} // namespace VTX +``` + +Registering on a writer: + +```cpp +auto writer = VTX::CreateFlatBuffersWriterFacade(config); +auto processor = std::make_shared(); +writer->SetPostProcessor(processor); // Init() runs here + +// ... RecordFrame() loop ... + +writer->ClearPostProcessor(); // explicit teardown +// OR let writer destructor handle it +``` + +`SetPostProcessor` does **not** invoke `Clear()` on whatever was previously registered. The previous processor is simply dropped; if you want explicit teardown of an outgoing processor, keep your own `shared_ptr` and call `Clear()` yourself. + +## Two ways to write a processor + +### A. Generic API (no codegen) + +Resolve `PropertyKey` by name once in `Init`, then use them in the hot path: + +```cpp +class HealthClampProcessor : public VTX::IFramePostProcessor { +public: + void Init(const VTX::FramePostProcessorInitContext& ctx) override { + health_key_ = ctx.frame_accessor->Get("Player", "Health"); + } + + void Process(VTX::FrameMutationView& view, const VTX::FramePostProcessContext&) override { + if (!view.HasBucket("entity") || !health_key_.IsValid()) return; + auto bucket = view.GetBucket("entity"); + for (auto entity : bucket) { + if (entity.raw() && entity.raw()->entity_type_id != /*Player type_id*/ 0) continue; + const float hp = entity.Get(health_key_); + if (hp < 0) entity.Set(health_key_, 0); + } + } + +private: + VTX::PropertyKey health_key_ {-1}; +}; +``` + +Works on any schema. Drawback: you carry strings and you have to gate on `entity_type_id` yourself. + +### B. Strongly-typed via codegen (recommended) + +Run `scripts/vtx_codegen.py` over your schema JSON to produce typed views, mutators, and per-struct `ForEachX` helpers. The processor stops carrying strings entirely: + +```cpp +#include "arena_generated.h" // produced by vtx_codegen.py + +class HealthClampProcessor : public VTX::IFramePostProcessor { +public: + void Process(VTX::FrameMutationView& view, const VTX::FramePostProcessContext&) override { + if (!view.HasBucket("entity")) return; + auto bucket = view.GetBucket("entity"); + + VTX::ArenaSchema::ForEachPlayer(bucket, *view.accessor(), [](auto& p) { + if (p.GetHealth() < 0) p.SetHealth(0); + }); + } +}; +``` + +- No `PropertyKey` members. +- No `Init()` (the codegen-generated `PlayerMutator` caches keys in `static` locals on first use). +- No manual `entity_type_id` gate (`ForEachPlayer` filters by type id internally). +- No `"Player"` / `"Health"` strings. +- If the schema gains a new property, regenerate the header; if a property is removed or renamed, the code fails to compile -- regression caught early. + +The codegen emits: + +| Per struct | What it gives you | +|---|---| +| `EntityType::X` enum | Strongly-typed id (`Player = 0`, `Projectile = 1`, ...) | +| `X::PropertyName` `constexpr const char*` | String constants matching the schema | +| `XView` class | Read-only wrapper (`GetHealth()`, `GetPosition()`, ...) | +| `XMutator` class | Read + write wrapper (`SetHealth(value)`, `GetMutableYyy()` for nested / arrays) | +| `ForEachX(bucket, accessor, fn)` | Filters a `BucketMutator` by entity_type_id, calls `fn(XMutator&)` | +| `ForEachXView(bucket, accessor, fn)` | Read-only iteration counterpart over `const Bucket&` | + +Run the codegen: + +```bash +python scripts/vtx_codegen.py path/to/schema.json path/to/generated.h +``` + +See `samples/arena_generated.h` for a complete example produced from `samples/content/writer/arena/arena_schema.json`. + +## Patterns + +### Cross-frame state + +Processors are regular C++ objects. Cross-frame state lives on member variables -- no API ceremony needed: + +```cpp +class KillStreakProcessor : public VTX::IFramePostProcessor { +public: + void Process(VTX::FrameMutationView& view, const VTX::FramePostProcessContext& ctx) override { + // ... read current frame, compare to previous_*, update ... + ++frames_seen_; + last_frame_index_ = ctx.global_frame_index; + } + void Clear() override { frames_seen_ = 0; last_frame_index_ = -1; } + +private: + int frames_seen_ = 0; + int32_t last_frame_index_ = -1; + std::unordered_map streaks_; +}; +``` + +`ctx.previous_frame` is always `nullptr` on the writer side -- the writer drops the native form after serialise. Cache what you need on the instance. + +### Composition: chain of processors + +When you have multiple independent transformations: + +```cpp +auto chain = std::make_shared(); +chain->Add(std::make_shared()); +chain->Add(std::make_shared()); +chain->Add(std::make_shared()); +writer->SetPostProcessor(chain); +``` + +| Phase | Order | +|---|---| +| `Init`, `Process`, `PrintInfo` | Registration order (A, B, C) | +| `Clear` | Reverse order (C, B, A) -- destructor semantics | +| Last writer wins | If A and B both `Set` the same property, B's value persists | + +### Replay-level metadata (KeyFrames, stats) + +If your processor accumulates artefacts that don't belong on individual frames (e.g. a `std::vector` extracted from death events), expose them via your own getters and let the caller read after the run: + +```cpp +class StatsProcessor : public VTX::IFramePostProcessor { +public: + void Process(VTX::FrameMutationView&, const VTX::FramePostProcessContext& ctx) override { + // ... accumulate into key_frames_ / order_stats_ / chaos_stats_ ... + } + const std::vector& GetKeyFrames() const { return key_frames_; } + const GlobalStats& GetOrderStats() const { return order_stats_; } + const GlobalStats& GetChaosStats() const { return chaos_stats_; } +private: + std::vector key_frames_; + GlobalStats order_stats_, chaos_stats_; +}; + +auto stats = std::make_shared(); +writer->SetPostProcessor(stats); +// ... record frames ... +writer->Stop(); + +// Caller reads after the run. No SDK plumbing required. +for (const auto& kf : stats->GetKeyFrames()) { ... } +``` + +The processor is just an object you also hold a `shared_ptr` to. + +### Schema-version branching + +Read `ctx.schema_version` per frame and branch: + +```cpp +void Process(VTX::FrameMutationView& view, const VTX::FramePostProcessContext& ctx) override { + if (ctx.schema_version == /*v13.8*/ 138) { + ProcessV138(view); + } else { + ProcessLatest(view); + } +} +``` + +`ctx.schema_version` is currently always `0` on the writer side (the version lives in the sink's header). Reader-side processors -- if added in a future release -- would see the actual version. Document any branching assumption explicitly. + +### Structural mutation + +`BucketMutator` exposes four operations beyond per-entity reads/writes: + +```cpp +EntityMutator AddEntity(); // appends a default-constructed PropertyContainer; returns mutator +void RemoveEntity(uint32_t entity_index); +template size_t RemoveIf(P pred); // bulk filter -- pred is callable +void Clear(); +``` + +Gotchas worth knowing: + +- **`AddEntity` and `entity_type_id`**: a freshly-added `PropertyContainer` has `entity_type_id = -1` by default. The FlatBuffers serializer **silently drops** entities with `entity_type_id < 0`. Always set it explicitly: + + ```cpp + auto ghost = bucket.AddEntity(); + ghost.raw()->entity_type_id = static_cast(MySchema::EntityType::Player); + ghost.raw()->int32_properties.resize(3, 0); // size to schema's expected slot count + ghost.raw()->float_properties.resize(2, 0.0f); + ghost.Set(health_key_, 100.0f); + ``` + +- **`RemoveIf` invalidates `type_ranges`**: the bucket's typed-range index is wiped to zero after a bulk filter. The serializer rebuilds them from `entity_type_id`, so on-disk output is correct; in-memory tooling that reads `type_ranges` after the processor runs must recompute them or rely on `entity_type_id` directly. + +- **Writer bucket limits**: the FlatBuffers serializer only persists `buckets[0]` (renamed to `"data"` on disk) and `buckets[1]` (renamed to `"bone_data"`). Additional buckets created by a processor via `view.raw()->CreateBucket(...)` are **silently dropped**. The Protobuf serializer preserves all buckets but applies the type-id reordering only to bucket 0. + +## Error handling + +The hook in `RecordFrame()` wraps `processor->Process()` in a try/catch: + +- `std::exception` and unknown exceptions are caught and swallowed (the recording pipeline keeps running). +- Whatever the processor managed to mutate before throwing **stays mutated**. +- Out-of-range or invalid keys on `EntityMutator::Set` are silent no-ops -- matching the tolerance of `EntityView::Get`. + +If `Init()` throws, the exception propagates to the caller and the half-initialized processor is **not** installed. + +## Performance + +When **no** processor is set, the hot path cost in `RecordFrame()` is one `shared_ptr` null-check + one untaken branch. Effectively zero overhead. + +When a processor is set, the cost is dominated by the processor's own logic. The mutation view layer (`EntityMutator::Get` / `Set`) compiles to the same indexed access you'd write by hand against `PropertyContainer::*_properties` -- the wrappers are header-only and inlined. + +Avoid resolving `PropertyKey` per `Process()` call -- the lookup hashes by name. Resolve once in `Init` (or rely on the codegen-generated `Mutator` which caches in `static` locals). + +## Out of scope + +- **Re-serializing mutated bytes back to disk on the reader side.** Not this feature. Reader-side mutations would diverge from on-disk wire bytes -- documented contract. +- **Mutating raw byte spans.** `GetRawFrameBytes` returns on-disk truth by design. +- **Async processors.** `Process` is synchronous on the writer's calling thread. Long-running work blocks `RecordFrame`. +- **Replay-level metadata emission API.** Processors that produce per-replay artefacts (KeyFrames, stats) expose them via their own getters -- the SDK does not add a metadata channel. + +## See also + +- `samples/post_process_write.cpp` -- minimal end-to-end demo: register processor, record synthetic frames, re-open the `.vtx`, verify persisted values. +- `samples/advance_write.cpp` -- the `ArenaConsistencyProcessor` runs over all three data sources (JSON / Proto / FBS), proving the post-processor is orthogonal to source format. +- `samples/arena_generated.h` -- example codegen output (`PlayerMutator`, `ForEachPlayer`, etc.). +- `scripts/vtx_codegen.py` -- the codegen itself. +- `tests/writer/test_frame_post_processor.cpp` -- coverage including value mutation, chain ordering, structural mutation, lifecycle. diff --git a/docs/SAMPLES.md b/docs/SAMPLES.md index d586cd9..b4fe84e 100644 --- a/docs/SAMPLES.md +++ b/docs/SAMPLES.md @@ -1,17 +1,19 @@ # Samples -The `samples/` directory contains five example programs, from a one-shot hello-world to a full data-source-driven pipeline. Each is built as an independent CMake target so you can open any one in isolation. +The `samples/` directory contains seven example programs, from a one-shot hello-world to a full data-source-driven pipeline with frame post-processing. Each is built as an independent CMake target so you can open any one in isolation. ``` samples/ - basic_read.cpp vtx_sample_read reader smoke test - basic_write.cpp vtx_sample_write writer smoke test - basic_diff.cpp vtx_sample_diff hash-based diff - generate_replay.cpp vtx_sample_generate arena simulator -> 3 data sources - advance_write.cpp vtx_sample_advance_write 3 data sources -> 3 .vtx replays + basic_read.cpp vtx_sample_read reader smoke test + basic_write.cpp vtx_sample_write writer smoke test + basic_diff.cpp vtx_sample_diff hash-based diff + ready_api.cpp vtx_sample_ready_api chunk-0 ready signalling + post_process_write.cpp vtx_sample_post_process_write writer-side post-processor + generate_replay.cpp vtx_sample_generate arena simulator -> 3 data sources + advance_write.cpp vtx_sample_advance_write 3 data sources -> 3 .vtx replays (with post-processor) arena_mappings.h JSON data model + JsonMapping + ArenaToVtx::MapFrame() - arena_generated.h autogenerated schema constants + typed views + arena_generated.h autogenerated schema constants + typed Views + Mutators + ForEachX helpers schemas/ arena_data.proto Protobuf game schema (namespace arena_pb) arena_data.fbs FlatBuffers game schema (namespace arena_fb) @@ -87,12 +89,12 @@ The simulation models player patrols, projectile fire, kill/respawn cycles, and ### 5. `advance_write` -- data-source consumer + writer driver -Reads the three data-source files produced by `generate_replay`, maps each record into a `VTX::Frame` using SDK-native integration primitives, and writes one `.vtx` replay per source. +Reads the three data-source files produced by `generate_replay`, maps each record into a `VTX::Frame` using SDK-native integration primitives, **runs a writer-side post-processor** (`ArenaConsistencyProcessor` using the codegen-generated typed API), and writes one `.vtx` replay per source. The same processor instance is registered per pipeline -- demonstrating that frame post-processing is **orthogonal to the source format**: the same logic applies whether the frame came from JSON, Protobuf, or FlatBuffers. | | | |---|---| | **Links against** | `vtx_writer`, `vtx_common` | -| **Needs codegen** | Yes -- arena schemas + `arena_generated.h` | +| **Needs codegen** | Yes -- arena schemas + `arena_generated.h` (now also includes `*Mutator` classes and `ForEachX` helpers) | | **Reads** | all three `content/writer/arena/arena_replay_data.*` files | | **Produces** | `content/reader/arena/arena_from_json_ds.vtx`
`content/reader/arena/arena_from_proto_ds.vtx`
`content/reader/arena/arena_from_fbs_ds.vtx` | @@ -116,6 +118,22 @@ class IFrameDataSource { That's the same interface a real ingestion pipeline would implement -- see `tools/integrations/rl/rl15/rl15_data_source.h` for an example over a streaming protobuf file. +### 6. `post_process_write` -- post-processor end-to-end + +The minimum end-to-end demo of the writer-side **frame post-processor pipeline**. Builds synthetic frames, registers a `PlayerHealthProcessor` (clamp Health to `[0, 100]`, derive `IsAlive`, count cross-frame stats), records 30 frames, then re-opens the `.vtx` with the reader to prove the persisted bytes contain the post-processed values. + +| | | +|---|---| +| **Links against** | `vtx_writer`, `vtx_reader` | +| **Needs codegen** | Yes -- `arena_generated.h` (uses `PlayerMutator` + `ForEachPlayer` + `ForEachPlayerView`) | +| **argv[1]** | schema JSON path (default: `content/writer/arena/arena_schema.json`) | +| **argv[2]** | output `.vtx` path (default: `post_processed.vtx`) | +| **argv[3]** | frame count (default: 30) | + +Key APIs exercised: `VTX::IFramePostProcessor` (`Init` / `Process` / `Clear` / `PrintInfo`), `writer->SetPostProcessor`, `FrameMutationView::GetBucket`, `VTX::ArenaSchema::ForEachPlayer`/`ForEachPlayerView` (codegen-generated), `PlayerMutator::SetHealth` / `SetIsAlive` (codegen-generated). + +The processor has **zero hardcoded strings**, **zero `PropertyKey` members**, and **no manual `entity_type_id` gating** -- everything comes from the codegen-generated wrappers in `arena_generated.h`. Full feature reference in [`POST_PROCESSING.md`](POST_PROCESSING.md). + ## Arena schema codegen The advanced samples depend on generated C++ code for the two arena wire schemas: @@ -127,13 +145,21 @@ The advanced samples depend on generated C++ code for the two arena wire schemas Both codegen steps are declared in `samples/CMakeLists.txt` as `add_custom_command` rules, so re-editing either schema triggers a rebuild automatically. -`arena_generated.h` (hand-checked into the repo, under `samples/`) is a **separate** autogenerated file: it contains schema-field name constants (`ArenaSchema::Player::UniqueID`, `ArenaSchema::Player::StructName`, ...) and typed view classes (`PlayerView`, `ProjectileView`, `MatchStateView`). It is produced from the VTX property schema JSON by: +`arena_generated.h` (hand-checked into the repo, under `samples/`) is a **separate** autogenerated file. From the VTX property schema JSON it emits, per struct: + +- **Constants**: `ArenaSchema::Player::UniqueID`, `ArenaSchema::Player::StructName`, etc. +- **`EntityType` enum**: `ArenaSchema::EntityType::Player = 0`, `Projectile = 1`, ... +- **Read-only views**: `PlayerView`, `ProjectileView`, `MatchStateView` -- wrap `EntityView`, cache `PropertyKey` in `static` locals. +- **Mutators**: `PlayerMutator`, `ProjectileMutator`, `MatchStateMutator` -- wrap `EntityMutator`, expose `Get*` (same as Views) **plus** `Set*` for scalars and `GetMutable*` for arrays / nested structs. +- **Iteration helpers**: `ForEachPlayer(bucket, accessor, fn)` / `ForEachPlayerView(bucket, accessor, fn)` -- filter a `BucketMutator` (or `const Bucket&`) by `entity_type_id` and invoke `fn` with the strongly-typed mutator/view. + +Produced by: ```bat python scripts/vtx_codegen.py samples/content/writer/arena/arena_schema.json samples/arena_generated.h ArenaSchema ``` -Regenerate it whenever `arena_schema.json` changes. +Regenerate it whenever `arena_schema.json` changes. The same script handles any schema -- the same pattern applies to League, CS, Rocket League, or any other integration that ships its own JSON. The SDK doesn't know about the game; the codegen output knows. ## What each sample teaches @@ -142,10 +168,14 @@ Regenerate it whenever `arena_schema.json` changes. | Open a `.vtx` and print its contents | `basic_read.cpp` | | Build `PropertyContainer`s by hand and write them | `basic_write.cpp` | | Compare two frames at all | `basic_diff.cpp` | +| Wait for the reader's first chunk to land (poll / block / callback) | `ready_api.cpp` | | Simulate game data and persist it as a raw data source | `generate_replay.cpp` | | Implement `IFrameDataSource` over **JSON** using `JsonMapping` | `advance_write.cpp` -- `ArenaJsonDataSource` + `arena_mappings.h` | | Implement `IFrameDataSource` over **Protobuf** using `ProtoBinding` | `advance_write.cpp` -- `ArenaProtoDataSource` + `ProtoBinding` specialisations | | Implement `IFrameDataSource` over **FlatBuffers** using `FlatBufferBinding` | `advance_write.cpp` -- `ArenaFbsDataSource` + `FlatBufferBinding` specialisations | +| Hook a writer-side frame post-processor with full lifecycle | `post_process_write.cpp` -- `PlayerHealthProcessor` | +| Run the same post-processor across multiple data sources | `advance_write.cpp` -- `ArenaConsistencyProcessor` registered per pipeline | +| Use codegen-generated typed mutators (`PlayerMutator`, `ForEachPlayer`) | `post_process_write.cpp` + `advance_write.cpp` | | See the same patterns in a production integration | `tools/integrations/rl/rl15/` | ## Running the samples diff --git a/docs/SDK_API.md b/docs/SDK_API.md index 552071c..9c82b55 100644 --- a/docs/SDK_API.md +++ b/docs/SDK_API.md @@ -187,6 +187,119 @@ writer->Stop(); // Write footer and close file --- +## Frame Post-Processor + +A writer-side hook that runs on every `RecordFrame()` call, **after** timer validation and **before** the serializer touches the native `Frame`. Whatever the processor mutates lands on disk -- callers who re-read the `.vtx` see the post-processed values. + +See [`POST_PROCESSING.md`](POST_PROCESSING.md) for the full reference (lifecycle, threading, structural mutation, error handling, performance, codegen integration). This section is the API cheat-sheet. + +### Implementing a processor + +```cpp +#include "vtx/writer/core/vtx_frame_post_processor.h" +#include "vtx/writer/core/vtx_frame_mutation_view.h" + +class HealthClampProcessor : public VTX::IFramePostProcessor { +public: + // Called once, synchronously, inside SetPostProcessor. Resolve every + // PropertyKey here -- the schema is constant for the recording session. + void Init(const VTX::FramePostProcessorInitContext& ctx) override { + health_key_ = ctx.frame_accessor->Get("Player", "Health"); + } + + // Called once per RecordFrame, on the writer's calling thread. + void Process(VTX::FrameMutationView& view, + const VTX::FramePostProcessContext& ctx) override { + if (!view.HasBucket("entity") || !health_key_.IsValid()) return; + auto bucket = view.GetBucket("entity"); + for (auto entity : bucket) { + if (entity.raw()->entity_type_id != /*Player*/ 0) continue; + if (entity.Get(health_key_) < 0.0f) entity.Set(health_key_, 0.0f); + } + } + + // Optional: reset cross-frame accumulators. Called by writer destructor + // or explicit ClearPostProcessor(). + void Clear() override {} + + // Optional: telemetry dump. Called when the caller invokes it. + void PrintInfo() const override {} + +private: + VTX::PropertyKey health_key_ {-1}; +}; +``` + +### Registering on the writer + +```cpp +auto writer = VTX::CreateFlatBuffersWriterFacade(config); +auto processor = std::make_shared(); +writer->SetPostProcessor(processor); // Init() runs here + +for (...) { writer->RecordFrame(frame, time); } // Process() runs per frame +writer->Stop(); +// Writer's destructor calls processor->Clear() +// OR call writer->ClearPostProcessor() explicitly first. +``` + +### Composing multiple processors + +```cpp +auto chain = std::make_shared(); +chain->Add(std::make_shared()); +chain->Add(std::make_shared()); +chain->Add(std::make_shared()); +writer->SetPostProcessor(chain); +``` + +`Init`, `Process`, and `PrintInfo` run in registration order. `Clear` runs in reverse (destructor-like teardown). If two processors `Set` the same property, the last one wins. + +### Strongly-typed accessors via codegen + +`scripts/vtx_codegen.py` produces per-struct `XMutator` classes and `ForEachX` helpers for any schema. The processor becomes string-free: + +```cpp +#include "arena_generated.h" // produced by vtx_codegen.py + +class HealthClampProcessor : public VTX::IFramePostProcessor { +public: + void Process(VTX::FrameMutationView& view, const VTX::FramePostProcessContext&) override { + if (!view.HasBucket("entity")) return; + auto bucket = view.GetBucket("entity"); + VTX::ArenaSchema::ForEachPlayer(bucket, *view.accessor(), [](auto& p) { + if (p.GetHealth() < 0) p.SetHealth(0); + }); + } +}; +``` + +No `Init`, no `PropertyKey` members, no `entity_type_id` gating, no schema strings. See [`POST_PROCESSING.md`](POST_PROCESSING.md#strongly-typed-via-codegen-recommended) and [`SAMPLES.md`](SAMPLES.md#6-post_process_write----post-processor-end-to-end). + +### Mutation view API + +```cpp +// Per-entity reads / writes (writer-side mirror of EntityView) +template T Get(PropertyKey key) const; +template void Set(PropertyKey key, T value); + +// Nested struct mutation (Champion.Spells[i].Cooldown style) +EntityMutator GetMutableView(PropertyKey key); + +// Array mutation +template std::span GetMutableArray(PropertyKey key); + +// Bucket-level (BucketMutator) +EntityMutator AddEntity(); // inject synthetic entity +void RemoveEntity(uint32_t entity_index); +template size_t RemoveIf(P predicate); // bulk filter; P : bool(EntityView) +void Clear(); +``` + +Out-of-range / invalid-key writes are **silent no-ops**, matching the read-side tolerance. The serializer drops entities whose `entity_type_id < 0`, so newly-added entities must have their type id set explicitly before the chunk is flushed (see [`POST_PROCESSING.md` gotchas](POST_PROCESSING.md#structural-mutation)). + +--- + ## Diffing Frames ### Creating a Differ diff --git a/samples/CMakeLists.txt b/samples/CMakeLists.txt index 6dd39a3..1b8a80f 100644 --- a/samples/CMakeLists.txt +++ b/samples/CMakeLists.txt @@ -61,6 +61,14 @@ add_executable(vtx_sample_ready_api ready_api.cpp) target_link_libraries(vtx_sample_ready_api PRIVATE vtx_reader) vtx_configure_sample(vtx_sample_ready_api) +# --- post_process_write (frame post-processor: writer-side hook) --- +add_executable(vtx_sample_post_process_write post_process_write.cpp) +target_link_libraries(vtx_sample_post_process_write PRIVATE vtx_writer vtx_reader) +target_include_directories(vtx_sample_post_process_write PRIVATE + "${VTX_COMMON_GENERATED_DIR}" +) +vtx_configure_sample(vtx_sample_post_process_write) + # ============================================================================== # Arena-specific codegen (arena_data.proto + arena_data.fbs) # diff --git a/samples/advance_write.cpp b/samples/advance_write.cpp index 8d47519..1080a90 100644 --- a/samples/advance_write.cpp +++ b/samples/advance_write.cpp @@ -38,6 +38,8 @@ #include "vtx/writer/core/vtx_writer_facade.h" #include "vtx/writer/core/vtx_data_source.h" +#include "vtx/writer/core/vtx_frame_post_processor.h" +#include "vtx/writer/core/vtx_frame_mutation_view.h" #include "vtx/common/adapters/json/json_adapter.h" #include "vtx/common/vtx_types_helpers.h" #include "vtx/common/readers/frame_reader/flatbuffer_loader.h" @@ -51,8 +53,10 @@ #include "arena_data.pb.h" #include "arena_data_generated.h" +#include #include #include +#include #include #include @@ -439,6 +443,60 @@ class ArenaFbsDataSource : public VTX::IFrameDataSource { size_t cursor_ = 0; }; +class ArenaConsistencyProcessor : public VTX::IFramePostProcessor { +public: + void Process(VTX::FrameMutationView& view, const VTX::FramePostProcessContext& /*ctx*/) override { + if (!view.HasBucket("entity")) + return; + + auto bucket = view.GetBucket("entity"); + VTX::ArenaSchema::ForEachPlayer(bucket, *view.accessor(), [&](auto& p) { + const float hp = p.GetHealth(); + const float clamped_hp = std::clamp(hp, 0.0f, 100.0f); + if (clamped_hp != hp) { + p.SetHealth(clamped_hp); + ++health_mutations_; + } + + const float armor = p.GetArmor(); + const float clamped_armor = std::clamp(armor, 0.0f, 100.0f); + if (clamped_armor != armor) { + p.SetArmor(clamped_armor); + ++armor_mutations_; + } + + if (clamped_hp <= 0.0f && p.GetIsAlive()) { + p.SetIsAlive(false); + ++is_alive_mutations_; + } + ++players_inspected_; + }); + ++frames_seen_; + } + + void PrintInfo() const override { + VTX_INFO( + " [post-process] frames={} players_inspected={} health_clamps={} armor_clamps={} is_alive_corrections={}", + frames_seen_, players_inspected_, health_mutations_, armor_mutations_, is_alive_mutations_); + } + + void Clear() override { + frames_seen_ = 0; + players_inspected_ = 0; + health_mutations_ = 0; + armor_mutations_ = 0; + is_alive_mutations_ = 0; + } + +private: + int frames_seen_ = 0; + int players_inspected_ = 0; + int health_mutations_ = 0; + int armor_mutations_ = 0; + int is_alive_mutations_ = 0; +}; + + // =================================================================== // Pipeline driver -- initialise source, stream frames into the writer // =================================================================== @@ -467,6 +525,9 @@ static bool RunPipeline(VTX::IFrameDataSource& source, const std::string& output return false; } + auto processor = std::make_shared(); + writer->SetPostProcessor(processor); + VTX::Frame frame; VTX::GameTime::GameTimeRegister time; int count = 0; @@ -477,6 +538,8 @@ static bool RunPipeline(VTX::IFrameDataSource& source, const std::string& output writer->Flush(); writer->Stop(); + processor->PrintInfo(); + VTX_INFO(" -> wrote {} frames to {}", count, output_path); return true; } @@ -494,6 +557,8 @@ int main() { VTX_INFO("=== advance_write - IFrameDataSource pattern ==="); VTX_INFO("Demonstrates JSON, Protobuf and FlatBuffers sources using integration-style bindings."); + VTX_INFO("Each writer also runs ArenaConsistencyProcessor (frame post-processor)"); + VTX_INFO("on every recorded frame; the persisted .vtx contains the post-processed values."); VTX_INFO("--- 1. JSON data source ---"); ArenaJsonDataSource json_ds(writer_dir + "/arena_replay_data.json"); diff --git a/samples/arena_generated.h b/samples/arena_generated.h index d23770a..df3a1e9 100644 --- a/samples/arena_generated.h +++ b/samples/arena_generated.h @@ -6,7 +6,8 @@ #include #include #include "vtx/common/vtx_property_cache.h" -#include "vtx/reader/core/vtx_frame_accessor.h" +#include "vtx/common/vtx_frame_accessor.h" +#include "vtx/writer/core/vtx_frame_mutation_view.h" namespace VTX::ArenaSchema { @@ -102,6 +103,130 @@ namespace VTX::ArenaSchema { }; + class PlayerMutator { + private: + VTX::EntityMutator data_mut; + const VTX::FrameAccessor& accessor; + + public: + PlayerMutator(VTX::EntityMutator m, const VTX::FrameAccessor& acc) + : data_mut(m), accessor(acc) {} + + PlayerMutator(VTX::PropertyContainer& container, const VTX::FrameAccessor& acc) + : data_mut(container), accessor(acc) {} + + inline const std::string& GetUniqueID() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::UniqueID); + return data_mut.Get(cached_key); + } + + inline void SetUniqueID(const std::string& value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::UniqueID); + data_mut.Set(cached_key, value); + } + + inline const std::string& GetName() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Name); + return data_mut.Get(cached_key); + } + + inline void SetName(const std::string& value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Name); + data_mut.Set(cached_key, value); + } + + inline int32_t GetTeam() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Team); + return data_mut.Get(cached_key); + } + + inline void SetTeam(int32_t value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Team); + data_mut.Set(cached_key, value); + } + + inline float GetHealth() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Health); + return data_mut.Get(cached_key); + } + + inline void SetHealth(float value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Health); + data_mut.Set(cached_key, value); + } + + inline float GetArmor() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Armor); + return data_mut.Get(cached_key); + } + + inline void SetArmor(float value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Armor); + data_mut.Set(cached_key, value); + } + + inline const VTX::Vector& GetPosition() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Position); + return data_mut.Get(cached_key); + } + + inline void SetPosition(const VTX::Vector& value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Position); + data_mut.Set(cached_key, value); + } + + inline const VTX::Quat& GetRotation() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Rotation); + return data_mut.Get(cached_key); + } + + inline void SetRotation(const VTX::Quat& value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Rotation); + data_mut.Set(cached_key, value); + } + + inline const VTX::Vector& GetVelocity() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Velocity); + return data_mut.Get(cached_key); + } + + inline void SetVelocity(const VTX::Vector& value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Velocity); + data_mut.Set(cached_key, value); + } + + inline bool GetIsAlive() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::IsAlive); + return data_mut.Get(cached_key); + } + + inline void SetIsAlive(bool value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::IsAlive); + data_mut.Set(cached_key, value); + } + + inline int32_t GetScore() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Score); + return data_mut.Get(cached_key); + } + + inline void SetScore(int32_t value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Score); + data_mut.Set(cached_key, value); + } + + inline int32_t GetDeaths() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Deaths); + return data_mut.Get(cached_key); + } + + inline void SetDeaths(int32_t value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Player, Player::Deaths); + data_mut.Set(cached_key, value); + } + + }; + namespace Projectile { constexpr const char* StructName = "Projectile"; constexpr const char* UniqueID = "UniqueID"; @@ -156,6 +281,80 @@ namespace VTX::ArenaSchema { }; + class ProjectileMutator { + private: + VTX::EntityMutator data_mut; + const VTX::FrameAccessor& accessor; + + public: + ProjectileMutator(VTX::EntityMutator m, const VTX::FrameAccessor& acc) + : data_mut(m), accessor(acc) {} + + ProjectileMutator(VTX::PropertyContainer& container, const VTX::FrameAccessor& acc) + : data_mut(container), accessor(acc) {} + + inline const std::string& GetUniqueID() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Projectile, Projectile::UniqueID); + return data_mut.Get(cached_key); + } + + inline void SetUniqueID(const std::string& value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Projectile, Projectile::UniqueID); + data_mut.Set(cached_key, value); + } + + inline const std::string& GetOwnerID() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Projectile, Projectile::OwnerID); + return data_mut.Get(cached_key); + } + + inline void SetOwnerID(const std::string& value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Projectile, Projectile::OwnerID); + data_mut.Set(cached_key, value); + } + + inline const VTX::Vector& GetPosition() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Projectile, Projectile::Position); + return data_mut.Get(cached_key); + } + + inline void SetPosition(const VTX::Vector& value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Projectile, Projectile::Position); + data_mut.Set(cached_key, value); + } + + inline const VTX::Vector& GetVelocity() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Projectile, Projectile::Velocity); + return data_mut.Get(cached_key); + } + + inline void SetVelocity(const VTX::Vector& value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Projectile, Projectile::Velocity); + data_mut.Set(cached_key, value); + } + + inline float GetDamage() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Projectile, Projectile::Damage); + return data_mut.Get(cached_key); + } + + inline void SetDamage(float value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Projectile, Projectile::Damage); + data_mut.Set(cached_key, value); + } + + inline const std::string& GetType() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Projectile, Projectile::Type); + return data_mut.Get(cached_key); + } + + inline void SetType(const std::string& value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::Projectile, Projectile::Type); + data_mut.Set(cached_key, value); + } + + }; + namespace MatchState { constexpr const char* StructName = "MatchState"; constexpr const char* UniqueID = "UniqueID"; @@ -210,4 +409,155 @@ namespace VTX::ArenaSchema { }; + class MatchStateMutator { + private: + VTX::EntityMutator data_mut; + const VTX::FrameAccessor& accessor; + + public: + MatchStateMutator(VTX::EntityMutator m, const VTX::FrameAccessor& acc) + : data_mut(m), accessor(acc) {} + + MatchStateMutator(VTX::PropertyContainer& container, const VTX::FrameAccessor& acc) + : data_mut(container), accessor(acc) {} + + inline const std::string& GetUniqueID() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::MatchState, MatchState::UniqueID); + return data_mut.Get(cached_key); + } + + inline void SetUniqueID(const std::string& value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::MatchState, MatchState::UniqueID); + data_mut.Set(cached_key, value); + } + + inline int32_t GetScoreTeam1() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::MatchState, MatchState::ScoreTeam1); + return data_mut.Get(cached_key); + } + + inline void SetScoreTeam1(int32_t value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::MatchState, MatchState::ScoreTeam1); + data_mut.Set(cached_key, value); + } + + inline int32_t GetScoreTeam2() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::MatchState, MatchState::ScoreTeam2); + return data_mut.Get(cached_key); + } + + inline void SetScoreTeam2(int32_t value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::MatchState, MatchState::ScoreTeam2); + data_mut.Set(cached_key, value); + } + + inline int32_t GetRound() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::MatchState, MatchState::Round); + return data_mut.Get(cached_key); + } + + inline void SetRound(int32_t value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::MatchState, MatchState::Round); + data_mut.Set(cached_key, value); + } + + inline const std::string& GetPhase() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::MatchState, MatchState::Phase); + return data_mut.Get(cached_key); + } + + inline void SetPhase(const std::string& value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::MatchState, MatchState::Phase); + data_mut.Set(cached_key, value); + } + + inline float GetTimeRemaining() const { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::MatchState, MatchState::TimeRemaining); + return data_mut.Get(cached_key); + } + + inline void SetTimeRemaining(float value) { + static VTX::PropertyKey cached_key = accessor.Get(EntityType::MatchState, MatchState::TimeRemaining); + data_mut.Set(cached_key, value); + } + + }; + + // ---------------------------------------------------------------- + // Strongly-typed iteration helpers + // ---------------------------------------------------------------- + // ForEachX(bucket, accessor, fn) walks a BucketMutator and calls + // fn(XMutator&) only for entities whose entity_type_id matches the + // struct. Read-only counterparts (XView) are available via + // ForEachXView. + + template + void ForEachPlayer(VTX::BucketMutator& bucket, const VTX::FrameAccessor& accessor, Fn fn) { + constexpr int32_t kTypeId = static_cast(EntityType::Player); + for (auto entity : bucket) { + const auto* raw = entity.raw(); + if (raw && raw->entity_type_id == kTypeId) { + PlayerMutator obj(entity, accessor); + fn(obj); + } + } + } + + template + void ForEachPlayerView(const VTX::Bucket& bucket, const VTX::FrameAccessor& accessor, Fn fn) { + constexpr int32_t kTypeId = static_cast(EntityType::Player); + for (const auto& container : bucket.entities) { + if (container.entity_type_id == kTypeId) { + PlayerView obj(container, accessor); + fn(obj); + } + } + } + + template + void ForEachProjectile(VTX::BucketMutator& bucket, const VTX::FrameAccessor& accessor, Fn fn) { + constexpr int32_t kTypeId = static_cast(EntityType::Projectile); + for (auto entity : bucket) { + const auto* raw = entity.raw(); + if (raw && raw->entity_type_id == kTypeId) { + ProjectileMutator obj(entity, accessor); + fn(obj); + } + } + } + + template + void ForEachProjectileView(const VTX::Bucket& bucket, const VTX::FrameAccessor& accessor, Fn fn) { + constexpr int32_t kTypeId = static_cast(EntityType::Projectile); + for (const auto& container : bucket.entities) { + if (container.entity_type_id == kTypeId) { + ProjectileView obj(container, accessor); + fn(obj); + } + } + } + + template + void ForEachMatchState(VTX::BucketMutator& bucket, const VTX::FrameAccessor& accessor, Fn fn) { + constexpr int32_t kTypeId = static_cast(EntityType::MatchState); + for (auto entity : bucket) { + const auto* raw = entity.raw(); + if (raw && raw->entity_type_id == kTypeId) { + MatchStateMutator obj(entity, accessor); + fn(obj); + } + } + } + + template + void ForEachMatchStateView(const VTX::Bucket& bucket, const VTX::FrameAccessor& accessor, Fn fn) { + constexpr int32_t kTypeId = static_cast(EntityType::MatchState); + for (const auto& container : bucket.entities) { + if (container.entity_type_id == kTypeId) { + MatchStateView obj(container, accessor); + fn(obj); + } + } + } + } // namespace VTX::ArenaSchema \ No newline at end of file diff --git a/samples/content/reader/arena/arena_from_fbs_ds.vtx b/samples/content/reader/arena/arena_from_fbs_ds.vtx index 7139e940b1960ada5fbafd6fb9e791719715d4bc..fad0da87c898845af1b7a4da96c18744812d8f0f 100644 GIT binary patch delta 12097 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�>N)V#)h~i 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 diff --git a/samples/content/reader/arena/arena_from_json_ds.vtx b/samples/content/reader/arena/arena_from_json_ds.vtx index 7e4460a5374aa35c97b2b060ed98f72bbb73c571..fc7e6ade0b9d71706880fc9fbe2d0a894c430637 100644 GIT binary patch delta 12054 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�c;R*Cx` zaDS=m<30bgD=S?g*L11p2Hhhm=QTOku#{D>#F}h7^cYOReoDnRXj2 z%FHATE7XbpmzZO=T!dqG1OY<=Awh{Jyil27YiHOVd%V@2VmHpRQ}}N%IN5Fa3bzJB zCkvKPDN)`M0$G%;@Y{|D+S!F}h5uipGK-b+^W6XYol*JI{!z22{xYyNpX|9QXj=Uj{CTq&03*7^ag03chX zv<1%`cz6)dFNr-%ij3zaR`hi3Tz8e{V9-8#S_7Mdl z{p0r?+y^$J8%H{+dlXk6>xvjWJS+Nvt@(J! z*&!+Su$vbQ{;?kHITZ9{`*2XiNqzx&%}quHL6hZI#!|%(%@&@$jdB_u%*nOEm-cF1 z{sFz0muX{YhuNK*{dlB_hx?|%ukZfp?B#y4dp1?9zc;&{!Yj4cDDdsPA|?y#csuRH zNw&=50Nsg0G%6M-h1jm|8Dl{1p&F77Y#y_qMkPb(6*jh-zvju3K%4$fO1 z%gwu^-m%Z3Rn^9*iPDUwVoJkN?I`Bsle+xu+>wVMldJudlxV4GtuOvA_a2>I|ddx^Jl#hGg z{GcKtL}>j05SnziD` zSBK&+Ncd=z97D{R!_1kPcKGkfvIFZ5Y&-1Qfqe XNO3+F|z&+&l2>z`FzA4ul=} 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 diff --git a/samples/content/reader/arena/arena_from_proto_ds.vtx b/samples/content/reader/arena/arena_from_proto_ds.vtx index 75c6c742923a97291090fbe1168615f5df546da8..a8361a57e7caadbc65026dc8415392e762966e35 100644 GIT binary patch delta 24509 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 delta 24502 zcmXWibyO7Z*8uRPySux)+oiih5YVNhJ46w8=FuAZ3=1t;b5N$NE3m z4{#pfKEQi`|A62D;RB)v#1BXwkUk)LK>mQ@0p$a#2hx?0kp>R(RMz1I!E<&~O#U&`w&KDryp{H~jY?yZQTWCb}GveJQ!2c4fZ8O2MqFqk89`@-j((<7E`lU$&~iUKLS|?Q8`gz6A|7S?9o~vStD20 zX<>UuYo_w4yN@kTe~X-Jxr$BEF69M?ycb{#{^&>W_PU6IDd4~&JAzDS$fif)8Y1&i*YNgsA2mU)bV+>@}~OelGjJsNl%$AL0FmXg^QW; zim2KVsB7dX>U#LdNtFI*LBpaZR_8>QfQZ*5al?99qs{4eMS4d_-!Xy}T8Ch*(TN1e zWy~KX@)-t^^RbR8MQNWZxrP2^6^!!_=~D^_dCAui95s9xY*!VgMCnx?ynAG(r2f2* z)kU|D)mpfRRo}&doZU!4>9nkb^@W5zd3RO8h&MJ1fj9|EVs*6#Ym+5=VksRvfrn#S zY-%ebL2e^kqRV2&2)^~iUSidcsLerOoa@ENFxPS82p7xBp1Jm)xLC7mqC`PmDspMR zk!T+U)(jMWV&yCuIU^4<`DbD>0z$h)9HU0qP%jE;I;;h?5HAnVCTmO9@7mSWRBmS1G&3FQupWQt}w3bL_44~v~= z4@=He3Ckl%fB(s-4*w$0WB>Mlp$dI2@B9v#%l*u;ujMI+Ub4K%d%@z{@R7{8(1FEL z-QSP#?SkK>(~-QLV~5|up3^4ij|Yo2dnuU%(Og2abvBv$8aGRwd0Z|s&COi2%cV!mttTM88AoVwM)$VWRg5ngPcEqV1eDqXqi!s0_3+wyZpmPKn zuVDmPhbd%4#|VOR@uNS%{-Pg$Eu}g7^-pj0VWB3jB!<}25~l8 z`ecvESf^X@D|BtO=EhekL-xJ0n&_7YWvz^3se1X-RTvxy)rAAnHUS%dS-e;<##eNm1Y)Z?^b3qNkwKdL18;tMwNG- zhm&fH=2T;goEoExlFdR@8X~D43*M(Pj(+!~U{eI?Qq!XmW180qKdD|PdLg_;+MQ9w^tj)FsmG#Mnw9EE+S(_SDcHFEd7!(~%r5rgjMMPhb3yDYX?0T2!P3q>s4lq_y9ak#;ucMhmD-1p>3La-91P+y*&= zrQt72NGY34@Yf<)Vy)ov3A}V__?6^p_`>8W2?8r2q!L>5Ql+Z@7`2V>7~`16q^#9x zC$fH=OHD@gFxC~@lbYy>C2U}eBycc2CRN-JPvAFWz#*BnBAv>rVtf>-BjwiD!^of0 z!|1D|Ak|mUD;b)sObQsPvf*=YkF>k4j5vk-A;ppT$&^6LMKMB{kywe-#E73->A)B| z@4)Cp!-j8@fIrfcfHxABiifjcMlfQ1?o`3G=j7S3>m->?Hw4NNi_wVnh#ey68lt2M z#=%L$zK?I9za=r!N0F>~VKgab{*Wk){AO@_fh2MKBay{;CIO5p+>KS4T-#uF(3YJjy*&-3&tYXLm>qx{+Zj!L_DoB9Dx*39{&*Imq zdq^x**2N`1!^IitUNb0k4$axH%Md@Y{n{YQ{R+?giH zxBknxrT(a&FM|PR!Tl*b@4}Eu-J}q#@h1n^hEwSzB`l#X_xhOz&V;>yFD<@Ta?tS-EMIG(C%4whK4Nj+>wk|>#y)I%sh@cBLoEMpF*dn$P-y$~Q z)DfAb(Gfwm_s|7q^w1SAmC)HKt`Qqhw9q}`9iWS`+$N@3tEcmoHxubIeMvXKGA1mz z{emvx-3z)GG5$8~mHjr+Z&z&ehZbxYqdRO4JtH0u0mHwa;5{X6!}dL}F~>@^ULQ`e z?i2PC23Z7H7Y%p{FGP6?cf;GQC7(Dqf`ZSi7Y2{4FRujXxK&fEqYP;4L3|(T+ugRU z+P`hpErbM@41Y;@7n4S&f=5^X2rtLGTvPQ==`h~465e;ba!s;dR(5iJLV<7mE!9){ z4Y(#7YJm!z70Z|ZwOa-bxztvy99a^2y)umc5o?6C{=;(eeTrq$(^QMB;SCF?eHCI| zc4cC<#B@zrVivr&4SLSCb$VEhk-E_hS819HxF=N<~EVk{Sj{d#xfC)@fH!g+7?kse_1)Oc zqq#)VTLLtSlpez!Puzwt``$)%D9}W7vCw!MEAY2Rz^O}%B8UvQ-Vka2_nW$*^*8l9 zgGeF?rAQ)ynSE;Qq%l6iSFefanl`C}yPm}WqD}dd`7t({xs=UuOkeXCDhH?o6Z)u& z=c}mgj8hEt-F^8Y)qVMJ`xW?%LECvwKHhwN`t1gX_HbUosR-(X&UxO`OVwN_oRQo? z(mezHt25rTK7WHl{Qhsn4VEE#cL#Cq82?MZO} zy(1M7pE(N=rxptlvspTBd9tpiHf1WV-Fpr!@6F71_#f36n_qf|Tzh(b9fIr{x;>c{ zIoo=id;M%&3H@w)+}a$`deLf{@8i{mi5pp>Ro*aTCC9UHc}}KRbcrzUHTF_Dru9(e zDJby7)p+s*5`LhHqO#-J9kAmWjf=O6dp=?0932VZQ^VI9oghZ0j@<4X;K)+^RA$+;@dyxSm)1af~S?l-Bn4ggkn7 zTrc%IG}lF*5`MfB!Y$4q(U2R9Z5I>9-ygh!LO{jE^xcwI3%aN~4%xE9OScpgnQaT|3MeWsBz+C`w-=s}^}@`-?6 z{u9AKt_#I(`Zmt0-yik+&>ur``hP%4x__KfMz5oOVb8?U_x-7;Ihw+uEEk}VbxBdZ zWJ*;{0{OEKI=v<^H|%8-wE$dI+6NBV;E{phfy*Z(r!tq4fNnJ% zC;2Tqxx0Qk&gXuGg}HWxqPGY#XhfYtmUOv75tZ|Ir+-G@D-LWv4G}zk8B1FB1}CLp zlgyu^gXL4iH+*y9Rg_# zQyzDmVv|kvv9UEL61Vq57F}N}B1+ddJ^nB&dQ35QOVar`Ep_>ryWM)Xa|JgKT`)9b z&5gas5+yQzpx-<=)T5f27J!RwZnT^H#|KhkaXCu?g-8NS^jBf_Un^24nY^ zD&*s8u}-|oqI0K0@eP>fnFuu`Jt{_i%Dlv;^SE51o!dR=^Wf}!Ekij6Cyjb?P8zJb z&NS&XKw76+C+&Hy_FVE~k@TT~0n_dQ?ehgk;^%{!ThjX1{?d2mV{VkCA4vKATcq1- zLz(*GmKj5X<{6zTftRES2{}^hujpN!T)ES*WU3irX_FjC)3)qLm7_vQMcsT!LwVO2 z2k9T;KqeaeZ=#7q`2s{ZoJHKEbzCg?vj4G@+B+&R=JAd>fuw&9tH8+5uR);ooB3Ep*{gntqRY zY>^(~ljWP0EJZgzjye^j< zyyB`pY(JkiC*9-syB-k^(RqA5F>i}L#?+F3j5){)!{g9drgKv-7pYKpZrvRpvb{{9 zY^vDY7B;6oZj73IWrbyy{1}TiwmF88`7xf5eKWBx0;G#p< zP!K1yett^J`gzpSTqVL*xNO#tF$gI#p*3H6SP;ft~a!^3CdiU2<3ijKI3~W;wogt}z+C!AF)u7k{Z7eefZH@ON7K1rQ zWrJP^FMybuly#ywr!AU8L1(ke8hf+L{Ux(N>|xdDNnCJrR=z6MWy8yu($I4Co+X(f z>qr@mT+@<4{S==V|FL4w#JG3Nuc;MWQ5G{A-T-47%JM#<7!6Gt;{{)u^&B;tK1qMR z!=+H7c9Sh?(tl1y?KI0qPOhZ|QACjioOt7+(SU`}lNfUE4qSTiVd_Km7eu?sc0|^q zCfH~BMlmAf1-R;;a-EK)_^{GPb8(G?Ok=2ha&S52b*Mc{2@Q5P53@PfcGg&BuQ~sRF5%ij8cgUZoLTF64eQ7Tx zY7>Z|!&*wT!PWRIj$KA)g*zw05KUrgiEH?bRwHSG9xGAX3>Vy3LKyg1EXIyZ0oT&d zm#|1Sf-uc@net9iO^foqv=-+)uU31EXaeX}hn9NWYpy}hf%uBw)mosTgMzljDs#=cn)L=F!y}oR!0PLoGNY*kvai zo+-spC;kZNZhgW(tVZd7(M;+41O+{)=o+gF9FEmZ0%>;_jta$KaNy=T#V7Y|oTqh- z1bGqf*g0K-DK3+E)j%jZHOfEK5uA9Ne+HB0zTmKneZwi4ycx_$yaAa!zlrjG_reKa zdGlgdKR&$TTlT5CY3n*xal>{rebqApV{d;pL19<6^~v2Ay9Ci^>bSk(Abi=M>ehdz zqEEA@qN~adqUMb!qgm>{V7-0EPN4W_Ji0pR*Wkj$Pf!BM4A!%pe4G!ZLIid*zwt|x z#8~wA%Y4=K<+h^cN;{%Gx_hG;=)U2b0?PI*ltV!ZgIMv~pumOz&cCJp>h4P`gVwsK z_{E$#%#!=`atqPYvQf)OuZmYSUQy3`WwY{vcpD>w^(wUaUQFfNWk!f|wn~-WxD&jxeIKZoT znZRHdGu0O*R}&u1zH^4HApRzrk@pe3?qUs&Q)n%Cb2anx7 zvHYidKsoIm(VVPbu$9ckNEGAy=mUWu7qJS_BYHw2Q?Wq{AN$>2zYfs3fLC;pV@njR zfi7N=D6g;+Z?(;!K(h$t_TFQ4X#ZoUT1IOu-mz}%(X}pYM*UCNpS?P;l%W@Qa!lQAUc!HG&-%27f&!ni+D>=TJX~QhJQh4 zz(n1vNkF}OxC~?#Sq8FZkj4775{fOP^Mxo)-GP5MT3R~#i&W{LxQiL+{Fc9hy2`-m z#MfX~a4KJ&4o4)K)xHpv{sIt=F8H1ol}uWw9>gmYot{#O3+*5!(iSz)2lZ&Bs;{JG zsME)>L{}yI;6~bMQ1vMl>#FM_(m;?sD#h_BLej7iLPz5>!poEhss;@&DyPbB&fV@I z+~}H3I&2LyHf&`LD?(MTV#=2WJ?gu-JVaPj_Egxc-^j3>E;Kj=w1C(sUv01x@#GtI zno|Ubr?!G1^caq%DE@^&R^txGjz5P&zcYb?ll_bUxArrEamxa`Q|b+lBef%es=-sX zs1Nu`PLuA(QDwPxIKJ}stb?EU*rKjwL!+cTRVrfZeuGkZzhEhoo`D#DDXS|yeY1cF zxy->EBggY5o~fsC<)T7xd=k(>AiGVJz=K z>Itu?)#{BXcA^4&_1YY=qz-bH3YXYLP)_bMoPDw+e8;WFWa~;^Eb7hASlWw^NO!q| z7^6ZL-P9>(hoegCcyLM|Y2o*cI!UT~ol0@y>brsne8-|V3T6OMDLFNci`W}1OAbX6 zr(-z=r|x}{%YJqS$}uK!^5DtHxxvUo|d?`b(t7lkWgUfD#&5oTe#!y2*S&2OXl zMUAlC%UrRc_No9iCu*Ij=;(~P-EomR>(eoAr1l4*N%MaGs45&mtX@`AY)1lbC36hbcsyKF58Z3CF zI+#6q30mbC#|nP-8E3)Ci@iYDnCKk!uAvOeKgkn6t8o#3m){e(1L zy3XSl@dns*$_mXIles@%XL4EYb3Y5WHiVX@W#+wwgCFbPeCXyUWC0eNY zhAUYdjtd-G6oC|lg|TJh?XlZ;TXEGj!>}n60|_Z3r*VrUpKwKq{m=(7j0S?two@l`r{}2^OKSdNo25>P%QH0YDk_pof`gjt6jvGk^bBI_$ zJ_+RNX77bS3umlRMdj=uIwfWhHMJ6`1CL@b{**E5sXRF--Gjghuj_R`R~N%n{Z7T> z<&s!g*qj&e)UK*6SP;ggf)4h~`RhRo>nlNxTZT&Tm8#L00v5eTZ{pG(9iK`RY6EfJ=;M2p#*uk3beT6oGty<(0oS(K@t0HSkBlROFIS$u~f~@`Y z;jMqzX;3?X81{Ge=U5yqSV9KajD1X;_lY?X_la?j5s9I#h{Sh_h{R`o=*rv-v=(H-9mG+eSkIbeS>^nJ8i=jy0NWiMl{RHqgHEk(cVOq7-4-N7qIy;2|M1r zZ>H)(tuib_IUC-g)QyZ$>W`^V>S|z=x`G5sUCICzZ5@oj$rioG85>2bGkij+tFA&h zzkTBHq+7*|*eqgVS1K_Ag7p{yf+kF6NCPHwrVEp~RD&_o$H#Dx6QaRjaSR75C8pfY z3Mf=QzA$zG6mo8Wi$@!1wdrNpj3nybkPwprzq-(7z+_~P!u|%|h9~_*f9F2``)dE} z@6hr0oy3UcoqGOw7)4qZjDk1=mPtqpaO^EW%TsoXE+S=7>@zwEyrO3Z?|{;40XW-aNGe3ok{e&62J_YpQ$acFR3Zl6Vd}yP#FR2 z@0LxL|j3Mhgj4!SWT1!3j0fVQSL1lR;J(8>l>$gr3JWLRk2-HB=$GVGb* z?MW%_-;<=3+mk@wzb7C7esbW3QkJGhDOu4WLyBFHA!!A7zu|0XB}Iw*km50vk|^zc zNX~ndk~S|&X{ZrhN<@!R>ZwGt7PbNyxK|T~)?o!u#@z;QYixi&?5%^J>z-9U5t?N33&Az+S2br;`8)nA$Iy@p{dB7k(Udic7_kNl*5Ly%wj-U zl4YSSceDX7+-4X&Y=wisaGJxzD;}I7_bYR``bQ>C*|$s@t8NZGyaA3x;GhO<`e~8l z_x>7($KMr>MEX$<51kS4w8?cQxASEt{^G4(NcVm9+<)L>?k;c>>mmnP;{wOeq@zr9 z-kn~MHF~j#1j8akiB>SCxerLQyAO!WxZTmNy$?8`Ln(-Kpjm26F<$}=F=PoJFedYL z7_w0VOv&0f^l;e^EPDiafvp8S!Fh(&9QL^@6!Aj~6grhhi7B!-iNtRZS|Tn8Ext5V zr3@#~kZ{%OWw{?9V|BBM>^tnujyddo`*tCq%kiey;Mx6Vw`EDN9=WgCu@ZbEp?CpG??|q4x>3xY^1^P>+ zJLcD12e9n^H$d70St4kREE#7&}5*7!mANLGA^tWpz;k;sBpf=Ro`v z%7htL?@LH{%t7!iHY9vPPYd7lqprogVMy*J2^^bhh`T`}WW1>oQb$k)BhA}{@Z&eY z(hNgjq*FmK<4=EQ9A|H5PK)3(3lZ>{mLh~}A2-4^D-z`zwv8c*UP8kPUb~pj_2V_$Mb2A{>eP2LC@xsqK$cR zfnQl4%agIX%Qt+^Jv6i69zCLn5!ETg2=DXz5yGp>#LD5z#73e$3Y;H5C}Q`&QSjt+ zbLbXzan!l=bLhr2aJY{zQ&ig^AjLr19c)$s34vPP!Sv&i5cj})h+V}kj5qNX7ODCd z=2mnFR8B5nf@2pj`}@b?M`(DsMb7<4%4~S}UdQ?ccK%tov-{tVHJ7L1Owo%M`&3ms z&+p+MPaP3YoodkHO$`0Vyi**?@?tFm0i0ii^k1NARG{>>(W>etySKM6(s^KAlu)j`^Ho1=wo z^H9R-tO%PrDuj*u0K%p~6JcZMiD-`Ey>Iq(g43pLZB1-D*;iF5qHhGE`d zL(@elt$;N+g(L+^`*3^nvRHt;bOE#=j1w)W$cz^3I6%_~rJ)3jyHNr<-%tXNo?;BF z0bNY8Y8A$S3tgJ;P8RHUUI`zQK6$ z5`ihg-;n*iA()%drNpAanMCyadn0T4``pc3)KoPYYU#fiQ4vLi3mbAs8y(Zp>tsAtt+w9L*!Igyxa7K`-WJmpy2 zxXqj`g=Zc%L92TT&j(LmAf+q#4|*?LJRbV zksgfDIu)AD=K@fPw88Wyg#idw;=IY2o>j*(G z+_}v_hhQ?WD3Az94IDu7l98z*pHMnA-awGziG<4*T;kRe!N3)VN)>YiC|3TkU~253R5(Fjq%*y!$^%> zV(y4GFkClhn4+LFjFZ7PO!n*|=Bi@_ldZ9W(HL6B1TCY$j=IR?Z5Fh4Z5~RSdk@M@ z;SXNK_XcMx)sz=qub8n)Et)cN03rz?N$__`m)|j+vUn zOeH0f0_A21I=zKhi2E`M6qjt@6|#NQ0!(v4vJlZ9-(IH zf>P5gK)en-z5EOM@AB^?-M`n9f_LG@A8x}Xxy~*mi_b3(MekJt6A&uJDkwHldw^g` zA0VLm2IEN@hPlSX0*PWSfL`-Q$Q^GtBq{8ifjs|^;T-pd#LpWMFX@2N%=-@U%sY}e zWjvDTZa9=!8M`C#Faqx5BRs%~w>sd74-MrwNt)n^?%HzKUk%_1%Wvh?Zna zt%>qjmlW{C`y}uL$uxjN{2Vh*oros$i9?gUt4FgeRG?X;WKjy%IVc5v$-972Su~5o z4B-0pU%-OfZ9t3etxurHtqgj-be8%!ly3^xcq8vdij;m`RePs;(psz z?)lfj_orWz#_nZmLhofdtq?LPDR7TF^&1au3%JK53rgm(KT;;c3@MYKaW5n3dFuhs zxtHOTy!GhzM8ulfqlRi)!GgTiV12n;iZ#$ZfwL1TR=^My%WaL;sGLDN;Z33|M&r=C zX|)`#$NB(RH`f|GK6~$qdp6=)X$tVXcZ_MqJzZZloD3%vhJKWw{FrC}F zo#Eq^ovQljoqhKNSUhPk3=g&*5ES>O*{P%GOxw_O`s-*q z4P}%_s6E0~6?|v=34Uu^+Hz|f=XhfqxO>QK0BL9f1mKYk-r+0;sSs0RSi4 zYl3~xO9FX6WVDDWGJ5hD${C~!mP}fqC|yTI_dEfdQX4TBjrACcnF9<9^&V#P-xX%# z(;ddC{T>ihz6Zt&P_T?l6gc%Ay4-XXqfl~>3C)9@QWKw#9z)xS_%2Rh*8qii_ZN@!5f{>;hzmhC zRCtdlrYtoP2DFlNLF#EcA$kX!Z9jsJzHaayeU%D^U2!vbnmjo8i zMtN%Idil)s?eCTiR6jdAs-J}srS#SX!&-k>uF?Y_e!K9Z`%{H5teNpZtNcN^>)Gv( zQaF?~CmZUxkPa<1O@VG%HE{f;UgW4hy`6|ByqXA7{P!b@4;j?I3oUKw0c(1H1jihC z0CCwZ;Huoi%)!P(j>WW_OuHw?9MM}x92${(98{v$nMD=r9L_#xnVwu{Fg25#opw<~ z0HCFX>MbinJAARmuozolScvm5Uy_C~YePdAcd8)_|Kc`KLUac6s)v^JXv5B!R|0UV z9ul4pC_kCcFh8N?oyXF{JNTAEJ9Si>0o8Wj0zRId_I`eLtEEnNqa|u_nAqNl(7I&3 zNsKbQ*BWiPOf*mardbPEjMsRdYJH`W$2I5PnMoz zPI25YqOLBOw3u4JU1bRzvIj2=dj-7B(F2&Wae<8tTHtMZJ}k{^1X2*(36byYfcy$t zmgsu9B(aQlR5&DycrFaMVb*V}fK$R>95~+l93_a$%*D~u%nyuzCyb-+U3J#cMF~0R zqRb{R{o)cB|Nf97_4p?7xh=|hO$4x`AP3gTb|`YM)+v5a-#hbKBAh#Hz^B8y;I!~6 zj>pS~6s{WxSf42pqHlw4t6)M)u<>HP{&B&a;C;tf${zvoz}+DbGaFYYXN6sq5M|*9g zgP9!C!Hy5JmW7X5%kTr5$GrfOx+Yk>bT#a`_HjU%<6*#)Swt^y(Os{*8q%S7s=StN zZ+-xM+XX~DyMJPN3SCQ^gRXVzfSV7rz$9^B%WEr-=c|hDyZjf?ErrAwI_nAas--_B zMbI0QlIV#cwyy^0s#jo^iwiLBjw8sc!233NbIfY%6riJW4S6pQ(1njf!bZCxHfM`% z)6U1XN;>dnN9aTNZwuH+HNw1k>S4#dr)|J6)xTyJRkXd~2Z+aS)PUdsmoVI ze*ESuYZL4d`OVi640H-o;tcJ+`ZIuR9&?9&h>Z#dM{-NwmyjjFSjT4Z6-Wpp_^0Z0txZtos zw^cYqHqX>yYv0ZChR$O*-Suy8P**>mYnASe+uPvmq@~{sZ^oqUT7RhY3~F%$_-&h3 zUp~B@cF`%bKBytDH8&LyhEo4~=53nSuAe|zMRG8wRFU6eP^K*J_fz%6SjsfBZOV-* zI`_ZxM3Ev{vxD^l>-2gzK08OHrn68>#7>E$bh& z$MKZYBU7@1wJY9}lW)fL`G+b<>g zrCq2rKB@{n5!938k11Y?&mf#)H~lFtbsi)2o<7vlZ|{uaM8D2~?QMu~(;VZ6=S1F} zU84U9Dx{2a=%o4l>yxZ;T%nx`MoIn(qI%VC<1iVoZaIta(Yz?5KI&LYl7M)?x@fAl>dChllgg zSa`j~rs-LuH7CO%t($>NrLQxSQHBd;qnRt;@6jW-I-|>Fy zlUl|((XmEjc5@!04ZUf`55Rb*N#M`X#I2}g1^NW5UfVC4v(_DAyf;VKDe;AxCWcL! z9sK+WQoqcj+Ns8=T|Uw&cWE1i-D;RDsWrU#P}(-l!CIY1;Gr66lfp<-`_>WPsaA}nJ=qN(6%fanCs-n9vvIZCy?^T|zF6cv0 z%^v+LZFZc=e@=ZyZ_~-bwhGB$ro#I?oOIB@?E09A!3+0|?|&=;c6HPHU(TxRY2#6QM06_8qJyfY`G*(h{Qi%LphlF@ynLLXZM%9mr)Av(KDzsAq_-Ae8GFDg4<~09gb_t7B&f!+he3S0C zfg(qlg0o^q!@Ht@=x5T8!yB8sbNM0~uW1|2iIwU;7;9K&7^ky~4ynP#s>U$sBXRJml9YFQSQ(bbr+ z%7kg;3BBu>T*G?)q%<^)MJBTO{7|4#e)3iA6KuYGD^f~k<_XIgkt4|@QlpW+8?0Pz zC!1rVWMZBCLcp=$F{Rr)?j)HAW5|J3_BZ`I>(h?USw-QNHpMngU7bO}W(E2z(-jV- ze`rJTuN*ynr+GyVs%EeRP{omfkCsa9(nOy-N@Hc~N>XyvI^`YbKHa6Gr0?wIYt4~t zU1cYyi&3c;qj>1qa?7z6btAL%dnqR%UqnWq@?KCO<`)pT~hjJXDFBE>y7>P zjVRRRr6rFlj++={lUM#otG;+qv-U14;4|NxPyFdKjl94Z?b3-K&auDhiYzv1t6tUA z9arZ0ll`9Si7U&qu#x*dhnx9O$;B(h>GCY<5mPhZH!iPA3jHB~c{W2RdW~O)L%m8p z3%z@oVwwk1!ej!TSIO8mva8gLl>Xs1jMs~6SsgVmkUp##GV0ujDYa8p-}VTBDm^yq zv3BDRkrazon`EUK)#axyMO+m>Yo{t$4e3#w={IQUysReyVHBrp8NP{IxfKNl#)}{04SBugRN7vf@Y5 z2I5msVnnlnlJUpl$uDt2r zXD5Yb06H$}WFGR$7)Gsu?=%UL7IF?lXI6fu-_V38{l;r!vMJ&;JZc?X+9~{a)lbAl z#o9^Ux{JIgYg=(}8M9ijXlH1jbeER6oRnASRnW+cexn89lisBFY0WC(@zA_diz@23 z0)5!;x!!In{`~Et{p(fMN>98u!{)G&o>WuWF>tFBVV&1ZE21REOJ=zx{aMm^k1p|Y z-1KM3H(gpbDd&Z9L0Yc@E%6k)qa*HsxFqR0l4n&a_J@Pn8Dxj3eQE zMzQ3ejA$@J`=O~porTiO^v_AW=KjEd7JE<5L2x!MQiVys%CaP1Y zZW)-A8^9{6v(ZW7!DK=Q9Xghd879vVc-&Km42-f>kP7lF*q}ffOr3!bjh%0e#s;(; z???_ndlHn2LlCEoT8PuBJ`60>2nCEq>|wCh7q@i8nR<$340`(0DYq+i$XNmqY&PacPFvQdu3O!>%ltm33g=K4k5 zINRfeZ^jxN6*4?um5}hMhCEwLhQ4~ljQpwNhx|FEcvU#jjQGjs0iR*vzL^>6TaaiY zI4QLCS(3>7S-n$cl?Dwr;6`o}q3%9b|9AKCwadScuNN0D&TDjFD%ljM^Efrsx$tAe z_Rm(t_EaDoXfCnbYin(cg$*c8K%Qpqw~;3%!3NyPfG|-*^l=U|a`nwRyqQG#$X3B( zwQX+gplvSfpiMjckFBQHI3$^iA8mLFzqB1rn1T@15dwUcP((iCt0UVgoMs3S2^-4r zumG7Km5j_czq%{gY`QD?IEVa#rSq?(r0G&YZyBiEaWpc8_2&qpeyJzHOO_iJ1Lo>i z0>m}7U`j|PqlJhlE|phgY^|PLbl?Qph2I? zp+RyCKu|L;S~oD{jwQPLAV8ba0!F2*iqciJL8J%1y(lp-}(`Fy=>oW_o`4ca7`rb!zax*SXQ~*A8CCe-l)X|LS({UWf_)xOiXq z^MWiU5!Opj3iz3EAwM|3L4F|Xzw@c)Kl=JOw;IASYYCI5t%CZEvjKi=#jqY?9`rX} z|2vgi4-y|j> z%&p@PX5Sfz;&CD@@bNDRN9`)e`h+Gd(7Fy{=bj9c+}V?ejNFq@6}@H{uh^B?^0{JY z<+@^UH~FU$;<9)l>eaCGcEJ=TQznF-d#8ge`>g&?MMY!p!ZL!-51U{qhBg zw*joJmJRh^i#YPlP1NPzFqxs9Lef;2L_QDtKm8iyn=ujOo2R45H&>yEH;Z-fdwaU0 zuVWwf+DMM#V67Eo=z&mwWSCO<-AORfew&fO2*g%D2PRHViW-QLMGXYnqXt}_!o-A_ zP(4Mu@J(r;X(6C_ffe)&|UxuQPmE8kS6RsrxEP^_iU&Gr6sDzUIg)6 zZ4B|;ISlcf$l$6_=ficOjq3=6el{8UM34pXoGIYpL^*t>iEv56C`K37^28D*nqIk+ zPyg$}+kgM!cp?!d;zbJB%IKn73)IoAhEB+*Zverg@O+KciaO9H;J2;*&D2ZtQ&PA^Op zBgk5YviL%evJmA!ROfs`TW~a?0gIu2U`4wd$TNut@|t78)(R6G#4KYR-btR|71J+J zL6)CT!Mbs118oUZkf#@HO<)cPrt$-V+u2~+Pye(U-H{*d>rmT1^JsC(5FlK*6K3>h zdB^Y7(M5sNO}IuVJY22-_3;rm#veBmV5s|s!IirJ>{HHx&Zbk~7&uzMgyt?|XlZIO z2J!kBnyD^~mG2Vfh`Sm?Ls5mHNuR-Z;dSpMZ*E=0g|1xCcHgKR&B9fb-ojP-FX7>q z!|-roqN9t^y^{;v(1)K7-&Oek0#F~X;ELt&=SnU4bD4UDq%@?ziRh`niN{2L6ExVrDSxKczbR$gziH&B zf0KvIziE@~Z`xVxZ=%WcH>tAvnYKLonT}xeGgXWInQ|!lGdWxRnOczgGnq{JGZk6* zGi?m{GyQgaKNHMIe>Unye>RKKKU>7nKkF0GKYP>dpOq2fpXE;e*<%U+>@dhbi&5L3 zUlO+VF9DJE zFXg8Dmt-vZm8ORJm6~??m1eD9sTc8A!h!NvYNGI0V&T}Yl#JM~MC|TY3JUF4nsDk@ zvX$spas~BcyEXk-thPUPsQ1V2$n?kBjP%DcEB0ekV*9birGNd{jvf72ix>S^T~R+4 zrqGX7vHB-zkNPKFrTvqDR{AGRWBy4rI{rz^DgH?%Mf{UMp!$sB zsQr()F8q&>K!5lj1x@fj(qZvGavtqJa*^&oQm*Vj3W@h0ap?3P{igIE;fnMh#i07H zQYZYcOdI^KMmPRfpQruTXwd$vOtb&GlH7kqV(7mTY5K1iTK(5v*MEhG>3?k*`_E6n z{xcTVe-={OKU1mu=Ngy&GYp9S8D&EMY-H)5g-8153V)pbvjp!y&pi6iTB5&ElN5iW z*~;JO$A-U=^n}0B%!YoW(l};_$Z~@%`4S z$9`*$#D9J(jnsZ?i&ekX#74ihc%t9Bv7z7kv+=h^IPtf_s{DzfX7&?hk^6~?F7^}o z!25}An)DON_~<9%G5U#)M*T#?Uwg6u|1kD^(*w1D;?wPynJI z0>I?M8^8w-G+-$vfdPIT2tf7+1b85BUL1M5Hj{Z)OxzwMW{`-^@A-1U!pfYGB?l46 z%YUhMVzX3~Nkum5=(%VVEDxRc6MxDm_<2-Vx-l9WHHsTz(c5N2WzLUML^Ig3Jpg~L`fG!NPpIF z`br{#unmj`rCsV)nVylG3YnP7l1#D_zg!e~6mX4IvpV8Rkye6Y9i4MZb}3RqLr^$4 zfDyRvGbSYEgqe~?7@a0ZwQ^sPL5>hBg0bEb#18CixjcbjCx%!u^{7S)vw{*Eb;7BN zB3Qy^c0!{!w72=`CrC;&la!YjC4aOJEu7&)5py%nyns+JB_$#%kBbZiiJ4GgjU2%q zIGgW0XcA48)Yf`rBUevyHk`MXCzGHe%|`Nv0pnm9))eI3wefn6OK^-j`+R6UQpO29cxR^$4yUW1*^;?(O5;k=-}mOQ7$VYZVi$$NJGa4JGm}E%4W!s zV*_V6Cb=cnBgJGAG5W#X6@N{Vb5@o}B9RwV^=26&x@@UDb--Ah5EON&<_Tkhl3|1p za^(sjiRSFcqPKQ7GyU|yQka>LrLV*#e+$|7%Bv(mtES$2ItO-vMM&1bd`Cwd21%?zg&9clzLw}!LRLdC2{N|W4 z6k_0F9u!;?N?|!?r;?bF(0|P)uPLaaC8!le?%}wJi0P^b#UxHRPF`TlJQ__~vg%wJ8EGV_ zLMP^2id}Y;FCj$^3K+Ibh-qp zQbw3H$|la96m9t2Zk}M7DxWzbhZi#j%xkLo#3HO1Cu0&&2nOq5pE@rsCm*v&Pq+eJ zb2xH{j8AzqNq@jbQJn~^1MbBjs+IP7#R1~*w1XzMjFL-;Kx#CUK8-lS_VIFpsxHmI)??4UnZ+>8Dm^63 zSR}sq@m@46ais%umq^4Qo{nmE&Mr}?vi!lwkxh`*5Q`a&zS< zBR6q|!PM)CAzTGUb1dhiH8Vg<_UrNVq9@b(nyZSauea*j;4hwqwURnlu{Mu;Ao)a zmLZd8(SHQq2>xmjTMpRI93^K-iY$C&@=aEThr=90;voe_i-$DVrW-64r(x1g7ScLK z&zqN&_(Ujz;fSE_YK5*&8K`6$q<^WwOKnL9kj=c0@ zE%s2{wBnc}6)DNe9LE>B-4v}zzF0xIC`cSZ5phn}Ddgf*mcStx9JHTS#`PVOks4vD zh>c=COVJ(q1zrI`P7qlbM{`dKv0~AR<0oCxlu!{B3fG*#Y@pmko@QZC1&u^SU74xh$ z8w%Em(*~Sm3v$w_K4<0^6EY(_^=o<=VY|+Z)6)E4=_OY(@fyUL z;IXZv=gK`rVTtCUnjNK$2Gb^^I2o%QGZx5LcLBpZ-2`iUu1p}2=})G4EwLp!AT6=R zhp!j0+P$tYRCGzj+A;U!piwbIDm5{QGS;}T?VMm5kZ#rF9+CDOG|ORf_Q)l)V5?2UFrZ#JiFdJnS zi!cd$DwwcD$jpb+uBIe;&iPqNi8H)-sY=ct(F_%sLU_YPxS;0lkkXULgeN10LNK{0 zKqF1qgJfImWN>!Asd%<9MP;+pl*g@ z87D6}28XrzV$1uf$q+0O*~ubF7L?WDiJvepAtydqq{V>*oaR{Mnrt|=YtlwWalgSy zX$o0zFtSS&3OeVB5?hewsy%IE?9Y(ALtTI_N) zb$yg#h>SB25DH@|rBLM&7Y~pp z2F@u}GL0!4lhbTe?K3#Y=7r1xBb#EP4;*Aph}eEc+Vz^Buvnd;mYomTrpH{NT#Yz! z2ea%74u5K+4J0s5pnnS>vKqYQP~78{8idt~o=+j#8UxR>UOx^hBAl zECR3ikgj18M$X(@LJ+$x-jhmBnuVfNm70ziCU+7Aiww=eXN8GG#?uT!p5itE&CQ=< zEZs9R7N%qm`~i~@GE?~Eo6E_S)Xh;^#zaIO9#qAg;gp+^6n_ul#?3k?cu3}|p7GY? zD5DB7<-#;SEwTwo(PG|^`LYo_TNsQS6_WMEMJ~7p>1Ha!!iV#5H`EtKyc@ZeqGbu4 za7a*b(2&j(ewNHH!IN6Zmg7eT$~lrInZ=YWX384zbQqivxJkj%nvqB{aT;Wq7}|C* zlS@AtVQ+>~;C~$sn+TXLiqLFyc9h5#!B|(DZL|7EE5p|jpo(d1W@%pTl)QixH7I0X z>xL9By{0hK%2I6A@l4Ioq8O+O3JHfCN9^uSh~|uyRwdjZj6|EO+MMo`aT$@76(O;W zrQc4Bd%?zvQWQ)rxai17I!9MzIbP8nr-nr_2DFC)!GDp|ih!M-l7fV2sL8no1BND_ zXk(Esl&KhzsDP1XuqmY`>g+QAfJi51SS&|!QW}4(WW+FqgiY$mW$oI`N+(1cJU5#s znHiKqGm`Ych&K(^HNigfRTZ-}9s#Q`d8$N1ZE-OHiHj2(EZy3@fNq@{Yj<@*fA_(Tq)R0E33nzF+YLbI|bYkMC@dbDS3sHhObbu|nk0M!bo?0yXOH*{HaP$fpL5n#dU|5J&Dj1j1s8a9Kf=C3rwH)zsV+jc1jg4r#6#1+xsz$>)cO zoPUu)0b%3*xPanrQ|U;wd1kOpmdTR>GZHd;_~h-n^!I09LT9zIsWj;pW3?K!}rZh)g)UJyX2+@Sh!?HPwnbMS%k=R2ATS)1w zGi-CCI@xZ>M>n9SX%G!f+>$iGfPagyMw8DcB4&odj8Q;23$rTqP~#gH84x~kWWn+E z0%zv&nuiTbf-5B>S%Yk1os;Lr%MDgKg;Y6afXF#_Q6qz8sU{{LqnK`taez%2yn;pQ z5L@Qp$3)MzdbY0e6AqTgLmoR4HBvBE7^TLjoI5luY!XiLtm@P=TIBU227iL3P4G<5 zl35}G@|!hY6j=GDL-T5Z`iPf$))mhciUY@}sC3LEIghAKw5yS6%9)i$%(WdkRjY7L z(|L5+%Gq3G+USvgOq@tF_LF+WS!|IG?6Zs#W!}7^I?lr=hKk(lfXKx%E6Rx|sYXZ% zQ!{EH<`?b>kVsP&9xJxQOn*kk6i@XuG2uDn$0v0)lV+Bjk0qWxTK;VD1*_XAY8eNS+q*i+@a+4dLN{iGd+G zAM7G)Ng**!jacSBlyP=Vy<&vCiHRve;RFr>3T|l$JvnD=$xv{oR3ZRxOMVgr|SxSa6$PUh!qA0pSSzahJ6vt8oQI|(ZhB{+PVwE6C z&11WN;GN7&8(CRZaDQV*OLBE5=fzocp@@~OA`-kI{jK0)9@EWHQ*ySb2EtGUr z8Fo2Qfhe~VN30m=$TyY3G`z*5Ya#(6HEURXHq?j{r4qIgb&HpttUQ;ZL590I~SuNrI@Mu&>hV*HRhdKN3C#C3)f zBQ@80z}MVQR98wgQ!_7>B^p!^n=-?Rq8i_3jxVGS>S76oiI{kLW+*d8gsSo*dC4Bw zt)twv_-G#_s~*f%9nIZpG)HB2cDUqZlc*w*a--&_jDK+&BQcvVirdWs6*Xs{V{}vH z8E2A@Jws-UA=3N#AYMim7Iw->T6TqqR4T7VCBMukQKg8Aup_R><)UB_+0fLp+hj6l z%s&t~Txt_Y$df*TjOgCg!@)5bHJ4rS4A}NLS%d~cabFd z;+%^m%p2Br-4B+ga*dE~ZN7*vQcl(XHr82+cERtV?8V z@LE&;Y^6W(jO<&ElvH#hUTc=*6e#i&Drfm>(Ub z^M7y!Bc}FrwJDi{Rit!+CZYI*IH(cu^XVn1dqN|UoP`(_G&!f;kagvhOw&j_SNMKl zI7;0nmBnRFf_zL+GguQxvQ4VtT8X={lFm0YwNnJ!*mRjEDI3(0TgQ`Z5u~G0!pRI3 zM!cn6wJ?y+G^R^qYyr+DFe-?W236^EiGKizgRqGxmT&3EDusLajmIn_p&~^kvBoy? z#fg%)QM>stR!`g@7-=R5+10t2pD@*>N$Dep#BdmuKwu6QU&NGrQaVg6U89fQn{ih1 zmAZl>Be|035tN8~QV>?(ZrMDYz*vp$kd5j@t*OGy$S6qTg|Y*VE1G-~KC>9uLVt^B zf8Fcr2Rlo3s533g4`oRYWEE#zDR6u@4p+Q7x==Z#sF{d}r-lku#%RbYHsy$uN7c7y zc1W1EfvZ_}%QPSLP^-)w5!v~3c_~e>MV)w~#rGrPc->!26*bSQ6o1+>b@pUZzVyT=OrMB|@CR48a!FkZSo(_|ifDSKoFcVsPix;mH{ z4x#o`zO75XCIRIrr%M`Q+`{vjXb_{W#*Hs!ju7%-I4TsByfmb=K6^~!n|}-$2%id< z1PG4h`GSOJL9NMltE|lBL4r{Rg~U2O-_zxi1S_W@EM1Nva5v|)tcw{*-6)w-jD&H= zMWKB15WM2-B1?$DIaup$sSrOBEm?jdiLn+#G9NWt!vn*-pcK1Cin*gjtw55P$}^Un z9xAoFtB!w+U@3G4rMu)PiGP?sx*0-L3a5N4QQ}@fs9LPFCHnF4$WRhEreao!jVw_; zcJ_Zs1`GH` zF5rg0aMNlfXrCN?_jWmQA**`(k8%V0e|;k@>~iX6BEv?(9WBQ{Jr$J%*O?YZT}_L% zj}oX0!<>{@&*_7=ZGZ1Y%QAeX);s8@hFP#uWkxki*abL*y0NPZ>sWxJQEBBmwJvd+ zH4n7i7oWS#sucl6O%woDGRL`xo~a`7Cy|+!L@a(Sd!WVclwA%`I|~h6;}v1~4b>%WAV2N*XrS^|>=n z&cEGXiQpOOZ=Z5BY#dsT(er%$yNapum`15_E4pX|Gh=NFpVTB~ZOb z>}5jGdW4BwJ#+)eTwB+cjO(<)N#aM{CUhQVlLW;P?g7gXK6^=X(_&0p95>>?pgg{i z(JTvG(f)0iI`A%M19k(#U$ zuz-;?XrQZ%Ie64{+cdb^z{nepeH^dSI9H%J2#}*oniX$9*@ZMrBCh|W(_v~cIPp^a z03_(ZzfGW9ku8Wo?Cn9=fLYjf^yv2*jiKJ?4}VvwBp*_7RccsZOn?_BzRrOurNR7TI=P|>F?=ZL+E_roLPl7j7m%K8!%ifeMraMF4su)FB?AhsAE*d zJ5AUcrqoLY>9MIVG;`)3tGCTM@2}|fMMqvaZyb#9<%L<=|MplE%z|n|q%M4nK!}_b z2Y;2Kk{OtO30CBCDGp$SUlZ7I2CKN1LQF-MhdVm(Q4?@c9wmxfA9f}2GcHPZBM_-2{8dHF4&QaV_AS9c~yX1IoQSZM=~mVY0*)ElA3{wziCijcfnN{xN%bw zKSQ9NP6Q#4qJmu{9LWew members, and no manual +// entity_type_id gating -- the codegen takes care of all of that. +// +// This sample drives four behaviours through a single processor: +// 1. Clamp Health to [0, 100] (value mutation) +// 2. Force IsAlive=false when Health drops to 0 (derived state) +// 3. Count frames where a player is "low health" (cross-frame state) +// 4. Dump that count via PrintInfo() on teardown (lifecycle hook) +// +// After the .vtx is written we re-open it with the reader and print the +// first few frames to prove the persisted bytes reflect the mutations. +// +// Args +// argv[1] -- schema JSON path (default: content/writer/arena/arena_schema.json) +// argv[2] -- output .vtx path (default: post_processed.vtx) +// argv[3] -- frame count (default: 30) +// +// Build +// Link against vtx_writer + vtx_reader (vtx_common is transitive). + +#include "vtx/writer/core/vtx_writer_facade.h" +#include "vtx/writer/core/vtx_frame_post_processor.h" +#include "vtx/writer/core/vtx_frame_mutation_view.h" +#include "vtx/reader/core/vtx_reader_facade.h" +#include "vtx/common/vtx_types.h" +#include "vtx/common/vtx_logger.h" + +#include "arena_generated.h" + +#include +#include +#include + +// --------------------------------------------------------------------------- +// PlayerHealthProcessor +// --------------------------------------------------------------------------- +// Uses the codegen-generated PlayerMutator + ForEachPlayer helpers. +// * No PropertyKey members. +// * No Init() to resolve them (the Mutator caches keys in static locals). +// * No manual entity_type_id gate (ForEachPlayer does it). +// * No "Player" / "Health" string literals in the processor body. +class PlayerHealthProcessor : public VTX::IFramePostProcessor { +public: + void Process(VTX::FrameMutationView& view, const VTX::FramePostProcessContext& ctx) override { + if (!view.HasBucket("entity")) + return; + + auto bucket = view.GetBucket("entity"); + VTX::ArenaSchema::ForEachPlayer(bucket, *view.accessor(), [&](auto& p) { + // (1) Clamp Health to [0, 100]. + const float hp = p.GetHealth(); + const float clamped_hp = std::clamp(hp, 0.0f, 100.0f); + if (clamped_hp != hp) + p.SetHealth(clamped_hp); + + // (2) Derived state: IsAlive=false when Health hits 0. + if (clamped_hp <= 0.0f && p.GetIsAlive()) + p.SetIsAlive(false); + + // (3) Cross-frame accumulator: count "low health" frame-entities. + // Demonstrates state lives on the processor instance. + if (clamped_hp < 50.0f) + ++low_health_frame_entities_; + }); + + ++frames_seen_; + last_global_frame_ = ctx.global_frame_index; + } + + void PrintInfo() const override { + VTX_INFO("PlayerHealthProcessor: frames_seen={}, last_global_frame={}, " + "low_health_frame_entities={}", + frames_seen_, last_global_frame_, low_health_frame_entities_); + } + + void Clear() override { + VTX_INFO("PlayerHealthProcessor::Clear -- resetting state"); + frames_seen_ = 0; + last_global_frame_ = -1; + low_health_frame_entities_ = 0; + } + +private: + int frames_seen_ = 0; + int32_t last_global_frame_ = -1; + int low_health_frame_entities_ = 0; +}; + + +// --------------------------------------------------------------------------- +// Helpers: build a synthetic Player frame with two entities. +// --------------------------------------------------------------------------- +// Builds entities using PlayerMutator. Health is intentionally driven out +// of range (negative on even frames, >100 on multiples of 7) so the +// processor's clamp has something to do. +// +// Note: PlayerMutator writes into pre-sized typed vectors of the +// PropertyContainer. The writer's schema layout dictates how big each +// vector is -- we resize them here based on the schema struct definition +// (counted from arena_generated.h). +static VTX::Frame MakeSyntheticFrame(int frame_index, const VTX::FrameAccessor& accessor) { + VTX::Frame frame; + auto& bucket = frame.CreateBucket("entity"); + + auto push_player = [&](int player_id, int team, float health) { + VTX::PropertyContainer pc; + pc.entity_type_id = static_cast(VTX::ArenaSchema::EntityType::Player); + // Pre-size the typed vectors so PlayerMutator::SetX can index them. + // Player schema: UniqueID, Name (strings); Team, Score, Deaths (int32); + // Health, Armor (floats); Position, Velocity (vectors); Rotation (quat); + // IsAlive (bool). + pc.string_properties.resize(2); + pc.int32_properties.resize(3); + pc.float_properties.resize(2); + pc.vector_properties.resize(2); + pc.quat_properties.resize(1); + pc.bool_properties.resize(1); + + VTX::ArenaSchema::PlayerMutator p(pc, accessor); + p.SetUniqueID("player_" + std::to_string(player_id)); + p.SetName("Player_" + std::to_string(player_id)); + p.SetTeam(team); + p.SetHealth(health); + p.SetArmor(50.0f); + p.SetPosition({double(frame_index), double(player_id), 0.0}); + p.SetVelocity({}); + p.SetRotation({0.0f, 0.0f, 0.0f, 1.0f}); + p.SetIsAlive(true); + p.SetScore(0); + p.SetDeaths(0); + + bucket.unique_ids.push_back("player_" + std::to_string(player_id)); + bucket.entities.push_back(std::move(pc)); + }; + + // Two players. P0 drifts into negative health on even frames; P1 drifts + // over 100 every 7th frame. Both will be clamped by the processor. + push_player(0, 1, (frame_index % 2 == 0) ? -5.0f - float(frame_index) : 80.0f); + push_player(1, 2, (frame_index % 7 == 0) ? 150.0f : 60.0f - float(frame_index) * 0.5f); + return frame; +} + + +int main(int argc, char* argv[]) { + const std::string schema_path = (argc > 1) ? argv[1] : "content/writer/arena/arena_schema.json"; + const std::string output_path = (argc > 2) ? argv[2] : "post_processed.vtx"; + const int frame_count = (argc > 3) ? std::max(1, std::atoi(argv[3])) : 30; + + // --- 1. Build the writer + register the processor BEFORE the first frame. + VTX::WriterFacadeConfig config; + config.output_filepath = output_path; + config.schema_json_path = schema_path; + config.replay_name = "PostProcessSample"; + config.replay_uuid = "sample-postproc-0001"; + config.default_fps = 60.0f; + config.chunk_max_frames = 16; + config.use_compression = true; + + auto writer = VTX::CreateFlatBuffersWriterFacade(config); + if (!writer) { + VTX_ERROR("Failed to create writer. Schema path: {}", schema_path); + return 1; + } + + // Build a FrameAccessor from the writer's loaded schema so MakeSyntheticFrame + // can use the strongly-typed PlayerMutator. + VTX::FrameAccessor accessor; + accessor.InitializeFromCache(writer->GetSchema().GetPropertyCache()); + + auto processor = std::make_shared(); + writer->SetPostProcessor(processor); + + // --- 2. Record synthetic frames. The processor runs on each one. + for (int i = 0; i < frame_count; ++i) { + VTX::Frame frame = MakeSyntheticFrame(i, accessor); + VTX::GameTime::GameTimeRegister game_time; + game_time.game_time = static_cast(i) / 60.0f; + writer->RecordFrame(frame, game_time); + } + + // Dump telemetry from the processor before tearing it down. + processor->PrintInfo(); + + writer->Flush(); + writer->Stop(); + // Writer's destructor will invoke processor->Clear(). + writer.reset(); + + VTX_INFO("Wrote {} post-processed frames to {}", frame_count, output_path); + + // --- 3. Re-open the .vtx and prove the persisted bytes are post-processed. + auto ctx = VTX::OpenReplayFile(output_path); + if (!ctx) { + VTX_ERROR("Re-open failed: {}", ctx.GetError()); + return 1; + } + if (!ctx.WaitUntilReady(std::chrono::seconds(2))) { + VTX_ERROR("Reader never became ready"); + return 1; + } + + VTX_INFO("Reader sees {} frames", ctx.reader->GetTotalFrames()); + const VTX::FrameAccessor read_accessor = ctx.reader->CreateAccessor(); + const int frames_to_print = std::min(frame_count, 5); + for (int i = 0; i < frames_to_print; ++i) { + const VTX::Frame* frame = ctx.reader->GetFrameSync(i); + if (!frame || frame->GetBuckets().empty()) + continue; + const auto& bucket = frame->GetBuckets()[0]; + // Use the codegen-generated PlayerView on the reader side too -- + // zero hardcoded strings, mirrors the writer-side processor. + VTX::ArenaSchema::ForEachPlayerView(bucket, read_accessor, [&](auto& p) { + VTX_INFO("frame {} player {} -> Health={} IsAlive={}", i, p.GetName(), p.GetHealth(), p.GetIsAlive()); + }); + } + + return 0; +} diff --git a/samples/post_processed.vtx b/samples/post_processed.vtx new file mode 100644 index 0000000000000000000000000000000000000000..2d81e19f5d3d8fadabc673843d8da34848ef4b48 GIT binary patch literal 3302 zcmb8ycU%)$0tfIH2oNw*vr0{)2&k0MLywUr()$8Nq>0oBDy-6%A|Q2LQA9wb>mmjb zM5V3>=+RM#!cv4RAc!H-bA#UA-hDpz&rLqRN#4xM`^>yI-#0T(4lX89Z~)}%m3Juf zxSyqgNpPl~OO4dJ+$<-m8&>E2)(N0j1 zGmIDV6EW;zi@fO2fC~yu`Jtn^r~ELx&e57X+t ziZZl{GNYhCW>(4h%$uydgu&YIqReQg!HPjMCD%vA7i!#Jy!XwQaQ?|LT(~FgBKhg@ zBX`ZcYDX*{l19aYOJRacJ)A?s8%x^;-k$r*uK0rov}EZ+*(UXArEZ;hl%JZ;F53Ep z+n1LvsyEv69zIfBWZh1XJyy`fxK~_n`+N>BAChOA_H=fwB%K&hXLVo1%p|>QJg7EI zS=tMqvCm7_-;sKZ;d_X@#pJ9r>9(@S@!%cdt|l!k+4SLVA7-yPI`ni@^b<^9&aR&V zQHAArw+F;v$Ku~g+_pm|4OKTVlP^GqWs*r3H{$0Egv0^pxf_x{Rx`m?)iRoI(_~DJcDmKKjTQVBv zJ!n7B#&PiaFL@^hbi-br74zqeZLTas$!6>QV_usWSDC&0(}e0&$70n|vJ0V^I~gTd znqxB%Ves3#U_2^gix;VOV);gqLG_D%F5FHZj5dSM{ey3~aay{eLrS4n>-~+c9gKw` z`s0T;Nyn+TgVJ-bw!=%8Hsf>(ZdcMm%1CGIzO-~{;J#EX(klYCET{d`Fe$)f=vjz^ zZTdad$=5;T*Rw4EH`>u$daI$t({wi1Sj^=t>q6)+#zV$7mv&+YB;2$R?flY`KOUS; zdElBmook2tl|OghfYsZoeNTW{jCYDoVtkxFsqnd4E%I5&t#ilSZiqU&3@RVCG zh2Er5FJAq1e=d*jA8QEvxXRAdM@mZX?R#4v4n`ognzV}yMyc#ZU*X#cZTHmNdOToL z{z#XBs87OS7b%*otykw+8%H^pvUJXRhwaoxJW(}J?Tmg!S=|or@l8@r(|4HTq+aVS z4*%rc_4t!~USn0WYnCTeVF!UP>wWRcLbt-R)>fW!GaVIP#^=I~4oqHOKUlw1s`y|v;5=arvXvBL8 zFB{+CmYBgT=5u`BTp_Gjk2TPF?L>R9l+~4mt8+^8U;VqMR>xL_S1&n5yL&F5@|GM} zHFBC-ZZZ|T$Yg#wA;_j2+CBWIs5jF*TlAfr0ZyOAS~IuPVE=Ycp%CTE@)IZ(-IzSL zxW4|?kH%#&d3^b>p#ORcBAiEliK~2bbK_lY^%(4YB@7X;nI3J{vmT*Xu)V#Ndsh?L zmO)tqf!hU+${VVS*#ca@A~r}VYEAYbaF!%0~~M*@Z3~AF^v5<-$R(reNkZyuN}Lv zeXF;F)%+}wjrk$f=`+m3>PdPB3BCe82`UH`o{=|k`W;Kkj5~r}f;=yan+wS2xl<-3 z>Bn|K)8?vwd9beWX!1>uTcvd5XXpa$6&4pcM@ZA^f zaTvxB9X+p0v|G6RC%&{Y8BhG~>qPz9I@|G#B2ka9BlLnJSXhOaNm7}1p4`uj`$~Nkp@;{oMcOT zm=*9gW)pMu3o-KTi>xf-xF)%d*?NiB=?z?~y@Yj>HLyl7%4qfxN7-oPu&kZsW{Pwx z+#qM}-L8qeR}E6t&DM9t#V+?B4RMB?)gFu-we^vBt!A+q7&BAZ_c@>za+WXDaE+B> zzC^IxrolHVFS(=UAZOaT7K%2TD|A(>-CFOY_?4Q0f&XF#4Kot_+uE-=8~d#L5+_t_ z{zDto-{QGgNr{f~u-LMqR!eI#*~z%LuMmcX#8oox@Q}koFyZgxS*#~w#xNghP_pu} z8TqxIJdW5-RPiZn>qM;Q_ghx*>(iXE_9>6b$p`(0_zfR9V(mm8*_Nm!qmt2M-}X3{ zq;Uo$-x-$|tW2%9#S`0Gq^GXGs7S`tJwr2wIs>CEE*uL;E*#&++oyQ5v$yxl^BdB> znwJW_dv3a_Riw@A+s(C<&bg8~6Jip!9=BtS&oJ9UznaLJ>N~b|$6Gvm(U7`<@uZ^H zGo$|AjgeJ0jh=^&fh{wb;*CMSe3d*Imsf^6iVokpA2PRq-W<$HNM@a{vA_2l`i@B1 zt?q5&L~mQD*~ib+Q!_eM0`gBZ*gg}gb(M>zxf+-*cHorz+mUkEx99B&*eoBA5kf(L zL?K9fy)6j#N&ujtT)6nvAQt-GyBop*2edYWf=2yCAp? zmE+-171Sf;^CBvS0Bt}}c%`oHpk*+jV;-RAux8*HYkYPeIsCdwG|oJ==0u*BRbiV= z)o3w?&Hw<*in#P_9@(E41G$S5P&Akz z*41u42wGmhyY-dxLv>P8VxmRwq@no&N%+?2wlam><*(~hKa@Ck`aRYx>FWM*8J}tj N66OnaloRxZ^G^u%n?nEq literal 0 HcmV?d00001 diff --git a/scripts/vtx_codegen.py b/scripts/vtx_codegen.py index ec38f3a..59a9811 100644 --- a/scripts/vtx_codegen.py +++ b/scripts/vtx_codegen.py @@ -72,20 +72,146 @@ def normalize_schema_entries(data): return normalized def clean_enum_name(name): - """Convierte strings como 'smoke_grenade' o 'he' a PascalCase 'SmokeGrenade', 'He'""" + """Converts strings like 'smoke_grenade' or 'he' to PascalCase 'SmokeGrenade', 'He'""" parts = str(name).replace(" ", "_").split("_") return "".join(p.capitalize() for p in parts) + +def resolve_property_types(val, struct_name, known_structs, type_map): + raw_type_field = val.get("type", "") + is_list_enum = isinstance(raw_type_field, list) + raw_type = "implicit_enum" if is_list_enum else str(raw_type_field).lower() + + struct_type = val.get("structType", "").lower() + container = val.get("container", "").lower() + is_array = val.get("array", False) or (container == "array") + b_is_enum = val.get("bIsEnum", False) + b_is_actor = val.get("bIsActor", False) + + if raw_type in ["struct", "complexobject", "object", "none", ""] and struct_type: + lookup_type = struct_type + else: + lookup_type = raw_type + lookup_type = lookup_type.replace("vtx::", "").replace("std::", "") + + if is_list_enum: + accessor_type = "int32_t" + user_ret_type = f"{struct_name}::E{val.get('name', '')}" + elif b_is_enum or lookup_type in ["byte", "uint8"]: + accessor_type = "int32_t" + user_ret_type = "uint8_t" + elif b_is_actor: + accessor_type = "int64_t" + user_ret_type = "int64_t" + elif lookup_type in known_structs: + accessor_type = "VTX::EntityView" + user_ret_type = "VTX::EntityView" + else: + accessor_type = type_map.get(lookup_type) + if not accessor_type: + print(f" [WARNING] Unknown type '{lookup_type}' in property '{val.get('name', '')}'. Using float by default.") + accessor_type = "float" + user_ret_type = accessor_type + + return { + "is_array": is_array, + "is_list_enum": is_list_enum, + "accessor_type": accessor_type, + "user_ret_type": user_ret_type, + } + + +def emit_getter(lines, val, struct_name, info, complex_types, source_member): + prop_name = val.get("name", "") + accessor_type = info["accessor_type"] + user_ret_type = info["user_ret_type"] + is_array = info["is_array"] + + if accessor_type == "VTX::EntityView": + if is_array: + span_type = "VTX::PropertyContainer" + lines.append(f" /** @brief Returns a span of nested structures */") + lines.append(f" inline std::span Get{prop_name}() const {{") + lines.append(f" static VTX::PropertyKey> cached_key = accessor.GetViewArrayKey(EntityType::{struct_name}, {struct_name}::{prop_name});") + lines.append(f" return {source_member}.AsView().GetViewArray(cached_key);" if source_member == "data_mut" else f" return {source_member}.GetViewArray(cached_key);") + lines.append(" }") + else: + lines.append(f" /** @brief Returns a nested view */") + lines.append(f" inline VTX::EntityView Get{prop_name}() const {{") + lines.append(f" static VTX::PropertyKey cached_key = accessor.GetViewKey(EntityType::{struct_name}, {struct_name}::{prop_name});") + lines.append(f" return {source_member}.AsView().GetView(cached_key);" if source_member == "data_mut" else f" return {source_member}.GetView(cached_key);") + lines.append(" }") + else: + if is_array: + span_ret_type = "uint8_t" if accessor_type == "bool" else accessor_type + lines.append(f" /** @brief Returns a span of {accessor_type} */") + lines.append(f" inline std::span Get{prop_name}() const {{") + lines.append(f" static VTX::PropertyKey<{accessor_type}> cached_key = accessor.GetArray<{accessor_type}>(EntityType::{struct_name}, {struct_name}::{prop_name});") + lines.append(f" return {source_member}.AsView().GetArray<{accessor_type}>(cached_key);" if source_member == "data_mut" else f" return {source_member}.GetArray<{accessor_type}>(cached_key);") + lines.append(" }") + else: + ret_ref = "&" if user_ret_type in complex_types else "" + const_ref = f"const {user_ret_type}{ret_ref}" if user_ret_type in complex_types else user_ret_type + lines.append(f" inline {const_ref} Get{prop_name}() const {{") + lines.append(f" static VTX::PropertyKey<{accessor_type}> cached_key = accessor.Get<{accessor_type}>(EntityType::{struct_name}, {struct_name}::{prop_name});") + if user_ret_type != accessor_type: + lines.append(f" return static_cast<{user_ret_type}>({source_member}.Get<{accessor_type}>(cached_key));") + else: + lines.append(f" return {source_member}.Get<{accessor_type}>(cached_key);") + lines.append(" }") + lines.append("") + + +def emit_setter(lines, val, struct_name, info, complex_types): + prop_name = val.get("name", "") + accessor_type = info["accessor_type"] + user_ret_type = info["user_ret_type"] + is_array = info["is_array"] + + if accessor_type == "VTX::EntityView": + # Nested struct: writeable accessor returns a mutator. + if is_array: + lines.append(f" /** @brief Returns a mutable span of nested PropertyContainers */") + lines.append(f" inline std::span GetMutable{prop_name}() {{") + lines.append(f" static VTX::PropertyKey> cached_key = accessor.GetViewArrayKey(EntityType::{struct_name}, {struct_name}::{prop_name});") + lines.append(f" return data_mut.GetMutableViewArray(cached_key);") + lines.append(" }") + else: + lines.append(f" /** @brief Returns a mutator over the nested struct */") + lines.append(f" inline VTX::EntityMutator GetMutable{prop_name}() {{") + lines.append(f" static VTX::PropertyKey cached_key = accessor.GetViewKey(EntityType::{struct_name}, {struct_name}::{prop_name});") + lines.append(f" return data_mut.GetMutableView(cached_key);") + lines.append(" }") + else: + if is_array: + span_ret_type = "uint8_t" if accessor_type == "bool" else accessor_type + lines.append(f" /** @brief Returns a mutable span of {accessor_type} */") + lines.append(f" inline std::span<{span_ret_type}> GetMutable{prop_name}() {{") + lines.append(f" static VTX::PropertyKey<{accessor_type}> cached_key = accessor.GetArray<{accessor_type}>(EntityType::{struct_name}, {struct_name}::{prop_name});") + lines.append(f" return data_mut.GetMutableArray<{accessor_type}>(cached_key);") + lines.append(" }") + else: + param_type = f"const {user_ret_type}&" if user_ret_type in complex_types else user_ret_type + lines.append(f" inline void Set{prop_name}({param_type} value) {{") + lines.append(f" static VTX::PropertyKey<{accessor_type}> cached_key = accessor.Get<{accessor_type}>(EntityType::{struct_name}, {struct_name}::{prop_name});") + if user_ret_type != accessor_type: + lines.append(f" data_mut.Set<{accessor_type}>(cached_key, static_cast<{accessor_type}>(value));") + else: + lines.append(f" data_mut.Set<{accessor_type}>(cached_key, value);") + lines.append(" }") + lines.append("") + + def generate_cpp_header(json_path, output_path, namespace="Schema"): print(f"[VTX CodeGen] Reading schema from: {json_path}") - + try: with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) except Exception as e: print(f"[VTX CodeGen] Error reading JSON: {e}") sys.exit(1) - + type_map = { "int": "int32_t", "int8": "int32_t", @@ -116,61 +242,57 @@ def generate_cpp_header(json_path, output_path, namespace="Schema"): "#include ", "#include ", "#include \"vtx/common/vtx_property_cache.h\"", - "#include \"vtx/reader/core/vtx_frame_accessor.h\"", + "#include \"vtx/common/vtx_frame_accessor.h\"", + "#include \"vtx/writer/core/vtx_frame_mutation_view.h\"", "", f"namespace VTX::{namespace} {{", "" ] schema_list = normalize_schema_entries(data) - + lines.append(" /** @brief Strongly typed IDs for all entities in the schema */") lines.append(" enum class EntityType : int32_t {") - - known_structs = {} - + + known_structs = {} for i, struct_def in enumerate(schema_list): name = struct_def.get("struct", "") if name: known_structs[name.lower()] = name lines.append(f" {name} = {i},") - + lines.append(" Unknown = -1") lines.append(" };") lines.append("") for struct_def in schema_list: struct_name = struct_def.get("struct", "") - if not struct_name: continue - + if not struct_name: + continue + + # 1. Constexpr names + nested enums for list-typed properties. lines.append(f" namespace {struct_name} {{") lines.append(f" constexpr const char* StructName = \"{struct_name}\";") - + values = struct_def.get("values", []) - - # 1. Generate Constexpr names and Enums for val in values: prop_name = val.get("name", "") - if not prop_name: continue - + if not prop_name: + continue lines.append(f" constexpr const char* {prop_name} = \"{prop_name}\";") - - # Enum generation logic for list-based types raw_type_field = val.get("type", "") if isinstance(raw_type_field, list): lines.append(f" enum class E{prop_name} : int32_t {{") for enum_idx, enum_str in enumerate(raw_type_field): - clean_name = clean_enum_name(enum_str) - lines.append(f" {clean_name} = {enum_idx},") + clean = clean_enum_name(enum_str) + lines.append(f" {clean} = {enum_idx},") lines.append(" };") - lines.append(" }") lines.append("") - # 2. View Class + # 2. View class -- read-only. lines.append(f" class {struct_name}View {{") lines.append(" private:") - lines.append(" VTX::EntityView data_view;") lines.append(" const VTX::FrameAccessor& accessor;") lines.append("") @@ -181,97 +303,75 @@ def generate_cpp_header(json_path, output_path, namespace="Schema"): lines.append(f" {struct_name}View(const VTX::PropertyContainer& container, const VTX::FrameAccessor& acc) ") lines.append(f" : data_view(container), accessor(acc) {{}}") lines.append("") - - # 3. Generate Getters for val in values: - prop_name = val.get("name", "") - if not prop_name: continue - - raw_type_field = val.get("type", "") - is_list_enum = isinstance(raw_type_field, list) - - if is_list_enum: - raw_type = "implicit_enum" - else: - raw_type = str(raw_type_field).lower() - - struct_type = val.get("structType", "").lower() - container = val.get("container", "").lower() - - is_array = val.get("array", False) or (container == "array") - b_is_enum = val.get("bIsEnum", False) - b_is_actor = val.get("bIsActor", False) - - if raw_type in ["struct", "complexobject", "object", "none", ""] and struct_type: - lookup_type = struct_type - else: - lookup_type = raw_type - - lookup_type = lookup_type.replace("vtx::", "").replace("std::", "") - - accessor_type = "" - user_ret_type = "" - - if is_list_enum: - accessor_type = "int32_t" - user_ret_type = f"{struct_name}::E{prop_name}" - elif b_is_enum or lookup_type in ["byte", "uint8"]: - accessor_type = "int32_t" - user_ret_type = "uint8_t" - elif b_is_actor: - accessor_type = "int64_t" - user_ret_type = "int64_t" - elif lookup_type in known_structs: - accessor_type = "VTX::EntityView" - user_ret_type = "VTX::EntityView" - else: - accessor_type = type_map.get(lookup_type) - if not accessor_type: - print(f" [WARNING] Unknown type '{lookup_type}' in property '{prop_name}'. Using float by default.") - accessor_type = "float" - user_ret_type = accessor_type - - # Render Method - if accessor_type == "VTX::EntityView": - if is_array: - span_type = "VTX::PropertyContainer" - lines.append(f" /** @brief Returns a span of nested structures */") - lines.append(f" inline std::span Get{prop_name}() const {{") - lines.append(f" static VTX::PropertyKey> cached_key = accessor.GetViewArrayKey(EntityType::{struct_name}, {struct_name}::{prop_name});") - lines.append(f" return data_view.GetViewArray(cached_key);") - lines.append(" }") - else: - lines.append(f" /** @brief Returns a nested view */") - lines.append(f" inline VTX::EntityView Get{prop_name}() const {{") - lines.append(f" static VTX::PropertyKey cached_key = accessor.GetViewKey(EntityType::{struct_name}, {struct_name}::{prop_name});") - lines.append(f" return data_view.GetView(cached_key);") - lines.append(" }") - else: - if is_array: - span_ret_type = "uint8_t" if accessor_type == "bool" else accessor_type - lines.append(f" /** @brief Returns a span of {accessor_type} */") - lines.append(f" inline std::span Get{prop_name}() const {{") - lines.append(f" static VTX::PropertyKey<{accessor_type}> cached_key = accessor.GetArray<{accessor_type}>(EntityType::{struct_name}, {struct_name}::{prop_name});") - lines.append(f" return data_view.GetArray<{accessor_type}>(cached_key);") - lines.append(" }") - else: - ret_ref = "&" if user_ret_type in complex_types else "" - const_ref = f"const {user_ret_type}{ret_ref}" if user_ret_type in complex_types else user_ret_type - - lines.append(f" inline {const_ref} Get{prop_name}() const {{") - lines.append(f" static VTX::PropertyKey<{accessor_type}> cached_key = accessor.Get<{accessor_type}>(EntityType::{struct_name}, {struct_name}::{prop_name});") - - # Apply static cast if we need to return the generated Enum or a uint8_t - if user_ret_type != accessor_type: - lines.append(f" return static_cast<{user_ret_type}>(data_view.Get<{accessor_type}>(cached_key));") - else: - lines.append(f" return data_view.Get<{accessor_type}>(cached_key);") - lines.append(" }") - lines.append("") + if not val.get("name", ""): + continue + info = resolve_property_types(val, struct_name, known_structs, type_map) + emit_getter(lines, val, struct_name, info, complex_types, "data_view") + lines.append(" };") + lines.append("") + # 3. Mutator class -- read + write. + lines.append(f" class {struct_name}Mutator {{") + lines.append(" private:") + lines.append(" VTX::EntityMutator data_mut;") + lines.append(" const VTX::FrameAccessor& accessor;") + lines.append("") + lines.append(" public:") + lines.append(f" {struct_name}Mutator(VTX::EntityMutator m, const VTX::FrameAccessor& acc) ") + lines.append(f" : data_mut(m), accessor(acc) {{}}") + lines.append("") + lines.append(f" {struct_name}Mutator(VTX::PropertyContainer& container, const VTX::FrameAccessor& acc) ") + lines.append(f" : data_mut(container), accessor(acc) {{}}") + lines.append("") + for val in values: + if not val.get("name", ""): + continue + info = resolve_property_types(val, struct_name, known_structs, type_map) + emit_getter(lines, val, struct_name, info, complex_types, "data_mut") + emit_setter(lines, val, struct_name, info, complex_types) lines.append(" };") lines.append("") - + + # 4. Strongly-typed iteration helpers. Filter a BucketMutator by + # entity_type_id and yield the matching strongly-typed mutator. + lines.append(" // ----------------------------------------------------------------") + lines.append(" // Strongly-typed iteration helpers") + lines.append(" // ----------------------------------------------------------------") + lines.append(" // ForEachX(bucket, accessor, fn) walks a BucketMutator and calls") + lines.append(" // fn(XMutator&) only for entities whose entity_type_id matches the") + lines.append(" // struct. Read-only counterparts (XView) are available via") + lines.append(" // ForEachXView.") + lines.append("") + for struct_def in schema_list: + struct_name = struct_def.get("struct", "") + if not struct_name: + continue + lines.append(f" template ") + lines.append(f" void ForEach{struct_name}(VTX::BucketMutator& bucket, const VTX::FrameAccessor& accessor, Fn fn) {{") + lines.append(f" constexpr int32_t kTypeId = static_cast(EntityType::{struct_name});") + lines.append(f" for (auto entity : bucket) {{") + lines.append(f" const auto* raw = entity.raw();") + lines.append(f" if (raw && raw->entity_type_id == kTypeId) {{") + lines.append(f" {struct_name}Mutator obj(entity, accessor);") + lines.append(f" fn(obj);") + lines.append(f" }}") + lines.append(f" }}") + lines.append(f" }}") + lines.append("") + + lines.append(f" template ") + lines.append(f" void ForEach{struct_name}View(const VTX::Bucket& bucket, const VTX::FrameAccessor& accessor, Fn fn) {{") + lines.append(f" constexpr int32_t kTypeId = static_cast(EntityType::{struct_name});") + lines.append(f" for (const auto& container : bucket.entities) {{") + lines.append(f" if (container.entity_type_id == kTypeId) {{") + lines.append(f" {struct_name}View obj(container, accessor);") + lines.append(f" fn(obj);") + lines.append(f" }}") + lines.append(f" }}") + lines.append(f" }}") + lines.append("") + lines.append(f"}} // namespace VTX::{namespace}") output_content = "\n".join(lines) diff --git a/sdk/include/vtx/reader/core/vtx_frame_accessor.h b/sdk/include/vtx/common/vtx_frame_accessor.h similarity index 100% rename from sdk/include/vtx/reader/core/vtx_frame_accessor.h rename to sdk/include/vtx/common/vtx_frame_accessor.h diff --git a/sdk/include/vtx/reader/core/vtx_reader.h b/sdk/include/vtx/reader/core/vtx_reader.h index 8a50b19..375b137 100644 --- a/sdk/include/vtx/reader/core/vtx_reader.h +++ b/sdk/include/vtx/reader/core/vtx_reader.h @@ -20,7 +20,7 @@ #include "vtx/common/vtx_types.h" #include "vtx/common/vtx_concepts.h" #include "vtx/reader/core/vtx_schema_adapter.h" -#include "vtx_frame_accessor.h" +#include "vtx/common/vtx_frame_accessor.h" #include "vtx/reader/serialization/flatbuffers_to_vtx.h" namespace VTX { @@ -406,10 +406,6 @@ namespace VTX { } void ReadFooter(std::ifstream& stream) { - // Fix A1: the original code declared `uint32_t footer_size;` and - // used it after `stream.read()` without checking `gcount()`. If - // the file is too short to contain the trailing 8-byte footer - // size, we would seek to a garbage offset and crash. const int64_t file_size = GetStreamSize(stream); if (file_size < 8) { throw std::runtime_error("VTX file too small to contain footer"); @@ -422,9 +418,6 @@ namespace VTX { throw std::runtime_error("VTX file truncated while reading footer size"); } - // Fix A3: guard against an implausible footer_size (either - // malicious UINT32_MAX or garbage from a corrupt trailer) that - // would seek before the beginning of the file. if (footer_size == 0 || static_cast(footer_size) + 8 > file_size) { throw std::runtime_error("VTX file has implausible footer size"); } @@ -567,7 +560,6 @@ namespace VTX { } const bool load_succeeded = thread_survived && !data.native_frames.empty(); - { std::lock_guard lock(cache_mutex_); if (!stop_token.stop_requested()) { diff --git a/sdk/include/vtx/writer/core/vtx_frame_mutation_view.h b/sdk/include/vtx/writer/core/vtx_frame_mutation_view.h new file mode 100644 index 0000000..ce0b89e --- /dev/null +++ b/sdk/include/vtx/writer/core/vtx_frame_mutation_view.h @@ -0,0 +1,261 @@ +#pragma once + +#include +#include +#include +#include + +#include "vtx/common/vtx_concepts.h" +#include "vtx/common/vtx_frame_accessor.h" +#include "vtx/common/vtx_types.h" + +namespace VTX { + + class EntityMutator { + public: + EntityMutator() = default; + explicit EntityMutator(PropertyContainer& data) + : data_(&data) {} + + template + typename EntityView::template ScalarRetType Get(PropertyKey key) const { + return AsView().Get(key); + } + + template + void Set(PropertyKey key, T value) { + if (!data_ || !key.IsValid()) + return; + constexpr auto MemberPtr = EntityView::GetContainerMember(); + auto& values = data_->*MemberPtr; + if (static_cast(key.index) >= values.size()) + return; + values[key.index] = std::move(value); + } + + EntityMutator GetMutableView(PropertyKey key) { + if (!data_ || !key.IsValid()) + return {}; + if (static_cast(key.index) >= data_->any_struct_properties.size()) + return {}; + return EntityMutator(data_->any_struct_properties[key.index]); + } + + std::span GetMutableViewArray(PropertyKey> key) { + if (!data_ || !key.IsValid()) + return {}; + return data_->any_struct_arrays.GetMutableSubArray(key.index); + } + + template + auto GetMutableArray(PropertyKey key) { + constexpr auto MemberPtr = EntityView::GetArrayContainerMember(); + using SpanType = decltype((data_->*MemberPtr).GetMutableSubArray(0)); + if (!data_ || !key.IsValid()) + return SpanType {}; + return (data_->*MemberPtr).GetMutableSubArray(key.index); + } + + EntityView AsView() const { return data_ ? EntityView(*data_) : EntityView(); } + + PropertyContainer* raw() noexcept { return data_; } + const PropertyContainer* raw() const noexcept { return data_; } + bool valid() const noexcept { return data_ != nullptr; } + + private: + PropertyContainer* data_ = nullptr; + }; + + class BucketMutator { + public: + BucketMutator() = default; + explicit BucketMutator(Bucket& bucket) + : bucket_(&bucket) {} + + size_t entity_count() const noexcept { return bucket_ ? bucket_->entities.size() : 0; } + + EntityMutator entity(uint32_t i) { + if (!bucket_ || i >= bucket_->entities.size()) + return {}; + return EntityMutator(bucket_->entities[i]); + } + + EntityView entity_view(uint32_t i) const { + if (!bucket_ || i >= bucket_->entities.size()) + return {}; + return EntityView(bucket_->entities[i]); + } + + class iterator { + public: + iterator() = default; + iterator(Bucket* b, size_t i) + : bucket_(b) + , idx_(i) {} + + EntityMutator operator*() const { return EntityMutator(bucket_->entities[idx_]); } + iterator& operator++() { + ++idx_; + return *this; + } + bool operator==(const iterator& o) const noexcept { return bucket_ == o.bucket_ && idx_ == o.idx_; } + bool operator!=(const iterator& o) const noexcept { return !(*this == o); } + + private: + Bucket* bucket_ = nullptr; + size_t idx_ = 0; + }; + + iterator begin() { return bucket_ ? iterator {bucket_, 0} : iterator {}; } + iterator end() { return bucket_ ? iterator {bucket_, bucket_->entities.size()} : iterator {}; } + + std::span entities_of_type(int32_t type_id) { + if (!bucket_) + return {}; + if (type_id < 0 || static_cast(type_id) >= bucket_->type_ranges.size()) + return {}; + const auto& range = bucket_->type_ranges[type_id]; + if (range.count <= 0) + return {}; + return std::span(bucket_->entities.data() + range.start_index, + static_cast(range.count)); + } + + EntityMutator AddEntity() { + if (!bucket_) + return {}; + bucket_->entities.emplace_back(); + if (bucket_->unique_ids.size() + 1 == bucket_->entities.size()) + bucket_->unique_ids.emplace_back(); + return EntityMutator(bucket_->entities.back()); + } + + void RemoveEntity(uint32_t entity_index) { + if (!bucket_ || entity_index >= bucket_->entities.size()) + return; + bucket_->entities.erase(bucket_->entities.begin() + entity_index); + if (entity_index < bucket_->unique_ids.size()) + bucket_->unique_ids.erase(bucket_->unique_ids.begin() + entity_index); + for (auto& r : bucket_->type_ranges) { + if (r.count <= 0) + continue; + const int32_t end = r.start_index + r.count; + if (static_cast(entity_index) < r.start_index) { + --r.start_index; + } else if (static_cast(entity_index) < end) { + --r.count; + } + } + } + + template + size_t RemoveIf(Predicate pred) { + if (!bucket_) + return 0; + const size_t before = bucket_->entities.size(); + std::vector keep(before, 1); + for (size_t i = 0; i < before; ++i) { + if (pred(EntityView(bucket_->entities[i]))) + keep[i] = 0; + } + size_t write = 0; + for (size_t read = 0; read < before; ++read) { + if (!keep[read]) + continue; + if (write != read) { + bucket_->entities[write] = std::move(bucket_->entities[read]); + if (read < bucket_->unique_ids.size() && write < bucket_->unique_ids.size()) + bucket_->unique_ids[write] = std::move(bucket_->unique_ids[read]); + } + ++write; + } + bucket_->entities.resize(write); + if (bucket_->unique_ids.size() > write) + bucket_->unique_ids.resize(write); + for (auto& r : bucket_->type_ranges) { + r.start_index = 0; + r.count = 0; + } + return before - write; + } + + void Clear() { + if (!bucket_) + return; + bucket_->entities.clear(); + bucket_->unique_ids.clear(); + for (auto& r : bucket_->type_ranges) { + r.start_index = 0; + r.count = 0; + } + } + + Bucket* raw() noexcept { return bucket_; } + const Bucket* raw() const noexcept { return bucket_; } + bool valid() const noexcept { return bucket_ != nullptr; } + + private: + Bucket* bucket_ = nullptr; + }; + + class FrameMutationView { + public: + FrameMutationView() = default; + FrameMutationView(Frame& frame, const FrameAccessor& accessor) + : frame_(&frame) + , accessor_(&accessor) {} + + bool HasBucket(const std::string& name) const { + if (!frame_) + return false; + return frame_->bucket_map.contains(name); + } + + BucketMutator GetBucket(const std::string& name) { + if (!frame_) + return {}; + auto it = frame_->bucket_map.find(name); + if (it == frame_->bucket_map.end()) + return {}; + return BucketMutator(frame_->buckets[it->second]); + } + + BucketMutator GetBucket(int32_t bucket_index) { + if (!frame_ || bucket_index < 0 || static_cast(bucket_index) >= frame_->buckets.size()) + return {}; + return BucketMutator(frame_->buckets[static_cast(bucket_index)]); + } + + size_t bucket_count() const noexcept { return frame_ ? frame_->buckets.size() : 0; } + + template + PropertyKey ResolveKey(const std::string& struct_name, const std::string& prop_name) const { + if (!accessor_) + return PropertyKey {-1}; + return accessor_->Get(struct_name, prop_name); + } + + template + PropertyKey ResolveArrayKey(const std::string& struct_name, const std::string& prop_name) const { + if (!accessor_) + return PropertyKey {-1}; + return accessor_->GetArray(struct_name, prop_name); + } + + PropertyKey ResolveViewKey(const std::string& struct_name, const std::string& prop_name) const { + if (!accessor_) + return PropertyKey {-1}; + return accessor_->GetViewKey(struct_name, prop_name); + } + + const Frame* AsConstFrame() const noexcept { return frame_; } + Frame* raw() noexcept { return frame_; } + const FrameAccessor* accessor() const noexcept { return accessor_; } + bool valid() const noexcept { return frame_ != nullptr && accessor_ != nullptr; } + + private: + Frame* frame_ = nullptr; + const FrameAccessor* accessor_ = nullptr; + }; + +} // namespace VTX diff --git a/sdk/include/vtx/writer/core/vtx_frame_post_processor.h b/sdk/include/vtx/writer/core/vtx_frame_post_processor.h new file mode 100644 index 0000000..633f53c --- /dev/null +++ b/sdk/include/vtx/writer/core/vtx_frame_post_processor.h @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include + +#include "vtx/common/vtx_frame_accessor.h" +#include "vtx/writer/core/vtx_frame_mutation_view.h" + +namespace VTX { + + struct FramePostProcessorInitContext { + const FrameAccessor* frame_accessor = nullptr; + int32_t total_frames = 0; + uint32_t schema_version = 0; + uint32_t format_major = 0; + uint32_t format_minor = 0; + }; + + struct FramePostProcessContext { + int32_t global_frame_index = 0; + int32_t chunk_local_frame_index = 0; + int32_t chunk_index = 0; + uint32_t schema_version = 0; + + const FrameAccessor* frame_accessor = nullptr; + const Frame* previous_frame = nullptr; + }; + + class IFramePostProcessor { + public: + virtual ~IFramePostProcessor() = default; + + virtual void Init(const FramePostProcessorInitContext& context) {} + virtual void Clear() {} + virtual void PrintInfo() const {} + + virtual void Process(FrameMutationView& view, const FramePostProcessContext& ctx) = 0; + }; + + class FramePostProcessorChain final : public IFramePostProcessor { + public: + void Add(std::shared_ptr p) { + if (p) + processors_.push_back(std::move(p)); + } + + bool Remove(const std::shared_ptr& p) { + for (auto it = processors_.begin(); it != processors_.end(); ++it) { + if (*it == p) { + processors_.erase(it); + return true; + } + } + return false; + } + + size_t size() const noexcept { return processors_.size(); } + bool empty() const noexcept { return processors_.empty(); } + + void Init(const FramePostProcessorInitContext& ctx) override { + for (auto& p : processors_) { + if (p) + p->Init(ctx); + } + } + + void Process(FrameMutationView& view, const FramePostProcessContext& ctx) override { + for (auto& p : processors_) { + if (p) + p->Process(view, ctx); + } + } + + void PrintInfo() const override { + for (const auto& p : processors_) { + if (p) + p->PrintInfo(); + } + } + + void Clear() override { + for (auto it = processors_.rbegin(); it != processors_.rend(); ++it) { + if (*it) + (*it)->Clear(); + } + } + + void ClearChain() noexcept { processors_.clear(); } + + void ClearMembers() { + Clear(); + ClearChain(); + } + + private: + std::vector> processors_; + }; + +} // namespace VTX diff --git a/sdk/include/vtx/writer/core/vtx_writer_facade.h b/sdk/include/vtx/writer/core/vtx_writer_facade.h index f10501a..cf7dc26 100644 --- a/sdk/include/vtx/writer/core/vtx_writer_facade.h +++ b/sdk/include/vtx/writer/core/vtx_writer_facade.h @@ -6,6 +6,7 @@ #include "schema_sanitizer.h" #include "vtx/common/vtx_types.h" #include "vtx/common/readers/schema_reader/schema_registry.h" +#include "vtx/writer/core/vtx_frame_post_processor.h" namespace VTX { @@ -20,6 +21,15 @@ namespace VTX { virtual void Flush() = 0; virtual void Stop() = 0; virtual VTX::SchemaRegistry& GetSchema() = 0; + + // The processor's Process() runs on every RecordFrame() call, + // after timer validation and BEFORE serialisation to disk: its + // mutations are what end up in the .vtx file. Call before + // RecordFrame to take effect; safe to swap mid-recording (new + // processor takes over on the next RecordFrame). + virtual void SetPostProcessor(std::shared_ptr processor) = 0; + virtual std::shared_ptr GetPostProcessor() const = 0; + virtual void ClearPostProcessor() = 0; }; struct WriterFacadeConfig { diff --git a/sdk/include/vtx/writer/core/writer.h b/sdk/include/vtx/writer/core/writer.h index b465198..7902be9 100644 --- a/sdk/include/vtx/writer/core/writer.h +++ b/sdk/include/vtx/writer/core/writer.h @@ -3,6 +3,9 @@ #include #include #include "vtx/common/vtx_types.h" +#include "vtx/common/vtx_frame_accessor.h" +#include "vtx/writer/core/vtx_frame_mutation_view.h" +#include "vtx/writer/core/vtx_frame_post_processor.h" namespace VTX { @@ -45,6 +48,15 @@ namespace VTX { registry_.LoadFromJson(config.schema_json_path); auto schema = Serializer::CreateSchema(registry_); sink_.OnSessionStart(schema); + frame_accessor_.InitializeFromCache(registry_.GetPropertyCache()); + } + + ~ReplayWriter() { + if (post_processor_) { + try { + post_processor_->Clear(); + } catch (...) {} + } } void RecordFrame(VTX::Frame& native_frame, const VTX::GameTime::GameTimeRegister& game_time_register) { @@ -61,6 +73,22 @@ namespace VTX { return; } + if (post_processor_) { + FrameMutationView view(native_frame, frame_accessor_); + FramePostProcessContext ctx; + ctx.global_frame_index = total_frames_; + ctx.chunk_local_frame_index = static_cast(pending_frames_.size()); + ctx.chunk_index = chunks_flushed_; + ctx.schema_version = 0; + ctx.frame_accessor = &frame_accessor_; + ctx.previous_frame = nullptr; + try { + post_processor_->Process(view, ctx); + } catch (const std::exception& e) { + (void)e; + } catch (...) {} + } + std::unique_ptr sink_frame = Serializer::FromNative(std::move(native_frame)); size_t frameSize = Serializer::GetFrameSize(*sink_frame); @@ -84,6 +112,7 @@ namespace VTX { pending_frames_.clear(); current_chunk_bytes_ = 0; + ++chunks_flushed_; timer_.UpdateChunkStartIndex(); } @@ -108,6 +137,35 @@ namespace VTX { VTX::SchemaRegistry& GetRegistry() { return registry_; } + void SetPostProcessor(std::shared_ptr processor) { + if (processor) { + FramePostProcessorInitContext init_ctx; + init_ctx.frame_accessor = &frame_accessor_; + init_ctx.total_frames = 0; + init_ctx.schema_version = 0; + init_ctx.format_major = 0; + init_ctx.format_minor = 0; + try { + processor->Init(init_ctx); + } catch (...) { + throw; + } + } + post_processor_ = std::move(processor); + } + + std::shared_ptr GetPostProcessor() const { return post_processor_; } + + void ClearPostProcessor() { + auto outgoing = std::move(post_processor_); + post_processor_.reset(); + if (outgoing) { + try { + outgoing->Clear(); + } catch (...) {} + } + } + private: SinkPolicy sink_; ChunkingPolicy chunker_; @@ -118,5 +176,9 @@ namespace VTX { VTX::GameTime::VTXGameTimes timer_; size_t current_chunk_bytes_ = 0; int32_t total_frames_ = 0; + int32_t chunks_flushed_ = 0; + + FrameAccessor frame_accessor_ = {}; + std::shared_ptr post_processor_; }; } // namespace VTX \ No newline at end of file diff --git a/sdk/src/vtx_reader/src/vtx/reader/core/vtx_reader_facade.cpp b/sdk/src/vtx_reader/src/vtx/reader/core/vtx_reader_facade.cpp index 30c98e7..da7197b 100644 --- a/sdk/src/vtx_reader/src/vtx/reader/core/vtx_reader_facade.cpp +++ b/sdk/src/vtx_reader/src/vtx/reader/core/vtx_reader_facade.cpp @@ -110,12 +110,17 @@ namespace VTX { } bool IsReady() const override { return InternalReader.IsReady(); } + bool IsReadyFailed() const override { return InternalReader.IsReadyFailed(); } + std::string GetReadyError() const override { return InternalReader.GetReadyError(); } + bool WaitUntilReady() override { return InternalReader.WaitUntilReady(); } + bool WaitUntilReady(std::chrono::milliseconds timeout) override { return InternalReader.WaitUntilReady(timeout); } + void MarkReadyVacuous() override { InternalReader.MarkReadyVacuous(); } private: @@ -180,12 +185,17 @@ namespace VTX { } bool IsReady() const override { return InternalReader.IsReady(); } + bool IsReadyFailed() const override { return InternalReader.IsReadyFailed(); } + std::string GetReadyError() const override { return InternalReader.GetReadyError(); } + bool WaitUntilReady() override { return InternalReader.WaitUntilReady(); } + bool WaitUntilReady(std::chrono::milliseconds timeout) override { return InternalReader.WaitUntilReady(timeout); } + void MarkReadyVacuous() override { InternalReader.MarkReadyVacuous(); } private: @@ -254,22 +264,6 @@ namespace VTX { }; result.reader->SetEvents(events); - // Eagerly warm chunk 0 so callers can poll IsReady(), block - // via WaitUntilReady(), or register OnReady / OnReadyFailed - // on a subsequent SetEvents. WarmAt() dispatches the load - // asynchronously, so OpenReplayFile's own return latency - // is unchanged. Empty replays (0 frames) get a vacuous - // "ready" flip so waiters / pollers don't hang forever. - // - // We narrow the cache window to (0, 0) around the eager - // warm so ONLY chunk 0 is loaded -- not the default-window - // forward neighbours. Loading extra chunks on every open - // would quietly break callers that set a narrow cache - // window immediately after OpenReplayFile() (e.g. memory- - // constrained tools, tests that isolate a single chunk). - // The window is restored to the reader's default right - // after, and nothing external holds a handle to the reader - // yet, so the temporary narrow is unobservable. if (result.reader->GetTotalFrames() > 0) { result.reader->SetCacheWindow(0, 0); result.reader->WarmAt(0); diff --git a/sdk/src/vtx_writer/src/vtx/writer/core/vtx_writer_facade.cpp b/sdk/src/vtx_writer/src/vtx/writer/core/vtx_writer_facade.cpp index f04fe3d..88dd460 100644 --- a/sdk/src/vtx_writer/src/vtx/writer/core/vtx_writer_facade.cpp +++ b/sdk/src/vtx_writer/src/vtx/writer/core/vtx_writer_facade.cpp @@ -16,9 +16,7 @@ namespace VTX { WriterFacadeImpl(typename ReplayWriter::Config internal_config) : writer_(internal_config) {} - // RecordFrame / Flush / Stop are no-ops after the first Stop(). The - // on-disk file was finalised (header + chunks + footer) there, and - // letting further writes through would rewrite/corrupt the footer. + void RecordFrame(VTX::Frame& native_frame, const VTX::GameTime::GameTimeRegister& game_time_register) override { if (stopped_) return; @@ -33,13 +31,19 @@ namespace VTX { void Stop() override { if (stopped_) - return; // idempotent: second Stop() is a no-op + return; // second Stop() is a no-op writer_.Stop(); stopped_ = true; } VTX::SchemaRegistry& GetSchema() override { return writer_.GetRegistry(); } + void SetPostProcessor(std::shared_ptr processor) override { + writer_.SetPostProcessor(std::move(processor)); + } + std::shared_ptr GetPostProcessor() const override { return writer_.GetPostProcessor(); } + void ClearPostProcessor() override { writer_.ClearPostProcessor(); } + private: ReplayWriter writer_; bool stopped_ = false; @@ -81,4 +85,4 @@ namespace VTX { internal_cfg.sink_config.b_use_compression = config.use_compression; return std::make_unique>(internal_cfg); } -} // namespace VTX \ No newline at end of file +} // namespace VTX diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 13ca183..ad40cce 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -68,6 +68,7 @@ add_executable(vtx_tests writer/test_writer_edges.cpp writer/test_roundtrip.cpp writer/test_writer_advanced.cpp + writer/test_frame_post_processor.cpp reader/test_reader_context.cpp reader/test_corrupt_files.cpp diff --git a/tests/common/test_frame_accessor.cpp b/tests/common/test_frame_accessor.cpp index d10c470..3c94d59 100644 --- a/tests/common/test_frame_accessor.cpp +++ b/tests/common/test_frame_accessor.cpp @@ -5,7 +5,7 @@ #include #include -#include "vtx/reader/core/vtx_frame_accessor.h" +#include "vtx/common/vtx_frame_accessor.h" namespace { diff --git a/tests/writer/test_frame_post_processor.cpp b/tests/writer/test_frame_post_processor.cpp new file mode 100644 index 0000000..f23dd08 --- /dev/null +++ b/tests/writer/test_frame_post_processor.cpp @@ -0,0 +1,336 @@ +#include + +#include +#include +#include +#include + +#include "vtx/common/vtx_types.h" +#include "vtx/writer/core/vtx_frame_post_processor.h" +#include "vtx/writer/core/vtx_frame_mutation_view.h" +#include "vtx/reader/core/vtx_reader_facade.h" +#include "vtx/writer/core/vtx_writer_facade.h" + +#include "util/test_fixtures.h" + +namespace { + + constexpr int kPlayersPerFrame = 3; + + VTX::Frame MakePlayerFrame(int frame_index) { + VTX::Frame f; + auto& bucket = f.CreateBucket("entity"); + for (int p = 0; p < kPlayersPerFrame; ++p) { + VTX::PropertyContainer pc; + pc.entity_type_id = 0; + pc.string_properties = {"player_" + std::to_string(p), "Alpha_" + std::to_string(p)}; + pc.int32_properties = {p % 2 == 0 ? 1 : 2, frame_index * 10 + p, 0}; + pc.float_properties = {100.0f - float(frame_index), 50.0f}; + pc.vector_properties = {VTX::Vector {double(frame_index), double(p), 0.0}, VTX::Vector {}}; + pc.quat_properties = {VTX::Quat {0.0f, 0.0f, 0.0f, 1.0f}}; + pc.bool_properties = {true}; + + bucket.unique_ids.push_back("player_" + std::to_string(p)); + bucket.entities.push_back(std::move(pc)); + } + return f; + } + + std::string OutPath(const std::string& uuid) { + return VtxTest::OutputPath("writer_post_processor_" + uuid + ".vtx"); + } + + VTX::WriterFacadeConfig MakeConfig(const std::string& uuid) { + VTX::WriterFacadeConfig cfg; + cfg.output_filepath = OutPath(uuid); + cfg.schema_json_path = VtxTest::FixturePath("test_schema.json"); + cfg.replay_name = "WriterPostProcessorTest"; + cfg.replay_uuid = uuid; + cfg.default_fps = 60.0f; + cfg.chunk_max_frames = 8; + cfg.use_compression = true; + return cfg; + } + + void RunWriter(VTX::IVtxWriterFacade& writer, int frames) { + for (int i = 0; i < frames; ++i) { + auto frame = MakePlayerFrame(i); + VTX::GameTime::GameTimeRegister t; + t.game_time = float(i) / 60.0f; + writer.RecordFrame(frame, t); + } + writer.Flush(); + writer.Stop(); + } + + + class DoubleHealthProcessor : public VTX::IFramePostProcessor { + public: + void Init(const VTX::FramePostProcessorInitContext& ctx) override { + init_called_ = true; + ASSERT_NE(ctx.frame_accessor, nullptr); + health_key_ = ctx.frame_accessor->Get("Player", "Health"); + } + void Process(VTX::FrameMutationView& view, const VTX::FramePostProcessContext& ctx) override { + ++process_calls_; + last_global_frame_ = ctx.global_frame_index; + if (!health_key_.IsValid()) + return; + auto bucket = view.GetBucket("entity"); + for (auto entity : bucket) { + entity.Set(health_key_, entity.Get(health_key_) * 2.0f); + } + } + void Clear() override { clear_called_ = true; } + + bool init_called_ = false; + bool clear_called_ = false; + int process_calls_ = 0; + int32_t last_global_frame_ = -1; + VTX::PropertyKey health_key_ {-1}; + }; + + class ScoreSetter : public VTX::IFramePostProcessor { + public: + explicit ScoreSetter(int32_t tag) + : tag_(tag) {} + void Init(const VTX::FramePostProcessorInitContext& ctx) override { + score_key_ = ctx.frame_accessor->Get("Player", "Score"); + } + void Process(VTX::FrameMutationView& view, const VTX::FramePostProcessContext&) override { + if (!score_key_.IsValid()) + return; + auto bucket = view.GetBucket("entity"); + for (auto entity : bucket) { + entity.Set(score_key_, tag_); + } + } + int32_t tag_; + VTX::PropertyKey score_key_ {-1}; + }; + + class GhostInjector : public VTX::IFramePostProcessor { + public: + void Init(const VTX::FramePostProcessorInitContext& ctx) override { + team_key_ = ctx.frame_accessor->Get("Player", "Team"); + health_key_ = ctx.frame_accessor->Get("Player", "Health"); + } + void Process(VTX::FrameMutationView& view, const VTX::FramePostProcessContext&) override { + auto bucket = view.GetBucket("entity"); + auto ghost = bucket.AddEntity(); + if (auto* raw = ghost.raw()) { + raw->entity_type_id = 0; + raw->int32_properties.resize(3, 0); + raw->float_properties.resize(2, 0.0f); + } + if (team_key_.IsValid()) + ghost.Set(team_key_, 99); + if (health_key_.IsValid()) + ghost.Set(health_key_, 1.0f); + } + VTX::PropertyKey team_key_ {-1}; + VTX::PropertyKey health_key_ {-1}; + }; + + class TeamTwoFilter : public VTX::IFramePostProcessor { + public: + void Init(const VTX::FramePostProcessorInitContext& ctx) override { + team_key_ = ctx.frame_accessor->Get("Player", "Team"); + } + void Process(VTX::FrameMutationView& view, const VTX::FramePostProcessContext&) override { + if (!team_key_.IsValid()) + return; + auto bucket = view.GetBucket("entity"); + bucket.RemoveIf([this](VTX::EntityView e) { return e.Get(team_key_) == 2; }); + } + VTX::PropertyKey team_key_ {-1}; + }; + + class FrameIndexRecorder : public VTX::IFramePostProcessor { + public: + void Process(VTX::FrameMutationView&, const VTX::FramePostProcessContext& ctx) override { + indices_.push_back(ctx.global_frame_index); + } + std::vector indices_; + }; + +} // namespace + + +TEST(WriterPostProcessor_MutationViewUnit, SetThenGetRoundTrips) { + VTX::PropertyContainer pc; + pc.float_properties = {0.0f}; + VTX::EntityMutator m(pc); + VTX::PropertyKey key {0}; + m.Set(key, 42.5f); + EXPECT_FLOAT_EQ(m.Get(key), 42.5f); +} + +TEST(WriterPostProcessor_ChainUnit, OrderAndRemove) { + VTX::FramePostProcessorChain chain; + auto a = std::make_shared(1); + auto b = std::make_shared(2); + chain.Add(a); + chain.Add(b); + EXPECT_EQ(chain.size(), 2u); + EXPECT_TRUE(chain.Remove(a)); + EXPECT_EQ(chain.size(), 1u); + EXPECT_FALSE(chain.Remove(a)); +} + + +class WriterPostProcessorTest : public ::testing::Test { +protected: + std::string TestUuid() const { + const auto* info = ::testing::UnitTest::GetInstance()->current_test_info(); + return VtxTest::SanitizePathComponent(info->name()); + } +}; + + +TEST_F(WriterPostProcessorTest, NoProcessorBaselineUnchanged) { + const auto cfg = MakeConfig(TestUuid()); + { + auto writer = VTX::CreateFlatBuffersWriterFacade(cfg); + RunWriter(*writer, 5); + } + auto ctx = VTX::OpenReplayFile(cfg.output_filepath); + ASSERT_TRUE(ctx) << ctx.error; + const auto* frame = ctx.reader->GetFrameSync(0); + ASSERT_NE(frame, nullptr); + EXPECT_FLOAT_EQ(frame->GetBuckets()[0].entities[0].float_properties[0], 100.0f); +} + +TEST_F(WriterPostProcessorTest, DoubleHealthIsPersistedToDisk) { + const auto cfg = MakeConfig(TestUuid()); + auto proc = std::make_shared(); + { + auto writer = VTX::CreateFlatBuffersWriterFacade(cfg); + writer->SetPostProcessor(proc); + EXPECT_TRUE(proc->init_called_); + EXPECT_TRUE(proc->health_key_.IsValid()) << "Init must have resolved the Health key against the loaded schema"; + RunWriter(*writer, 5); + } + EXPECT_TRUE(proc->clear_called_); + EXPECT_EQ(proc->process_calls_, 5); + + auto rctx = VTX::OpenReplayFile(cfg.output_filepath); + ASSERT_TRUE(rctx) << rctx.error; + const auto* frame = rctx.reader->GetFrameSync(0); + ASSERT_NE(frame, nullptr); + EXPECT_FLOAT_EQ(frame->GetBuckets()[0].entities[0].float_properties[0], 200.0f); + EXPECT_FLOAT_EQ(frame->GetBuckets()[0].entities[1].float_properties[0], 200.0f); +} + +TEST_F(WriterPostProcessorTest, ChainLastWriterWinsOnDisk) { + const auto cfg = MakeConfig(TestUuid()); + { + auto writer = VTX::CreateFlatBuffersWriterFacade(cfg); + auto chain = std::make_shared(); + chain->Add(std::make_shared(11)); + chain->Add(std::make_shared(22)); + writer->SetPostProcessor(chain); + RunWriter(*writer, 3); + } + auto rctx = VTX::OpenReplayFile(cfg.output_filepath); + ASSERT_TRUE(rctx) << rctx.error; + const auto* frame = rctx.reader->GetFrameSync(0); + ASSERT_NE(frame, nullptr); + EXPECT_EQ(frame->GetBuckets()[0].entities[0].int32_properties[1], 22); +} + +TEST_F(WriterPostProcessorTest, ChainRemoveDropsAndOtherStillFires) { + const auto cfg = MakeConfig(TestUuid()); + { + auto writer = VTX::CreateFlatBuffersWriterFacade(cfg); + auto chain = std::make_shared(); + auto a = std::make_shared(11); + auto b = std::make_shared(22); + chain->Add(a); + chain->Add(b); + chain->Remove(a); + writer->SetPostProcessor(chain); + RunWriter(*writer, 3); + } + auto rctx = VTX::OpenReplayFile(cfg.output_filepath); + ASSERT_TRUE(rctx) << rctx.error; + const auto* frame = rctx.reader->GetFrameSync(0); + ASSERT_NE(frame, nullptr); + EXPECT_EQ(frame->GetBuckets()[0].entities[0].int32_properties[1], 22); +} + +TEST_F(WriterPostProcessorTest, GhostInjectorEntityIsOnDisk) { + const auto cfg = MakeConfig(TestUuid()); + { + auto writer = VTX::CreateFlatBuffersWriterFacade(cfg); + writer->SetPostProcessor(std::make_shared()); + RunWriter(*writer, 3); + } + auto rctx = VTX::OpenReplayFile(cfg.output_filepath); + ASSERT_TRUE(rctx) << rctx.error; + const auto* frame = rctx.reader->GetFrameSync(0); + ASSERT_NE(frame, nullptr); + const auto& entities = frame->GetBuckets()[0].entities; + EXPECT_EQ(entities.size(), kPlayersPerFrame + 1); + EXPECT_EQ(entities.back().int32_properties[0], 99); + EXPECT_FLOAT_EQ(entities.back().float_properties[0], 1.0f); +} + +TEST_F(WriterPostProcessorTest, TeamTwoFilterDropsEntitiesFromDisk) { + const auto cfg = MakeConfig(TestUuid()); + { + auto writer = VTX::CreateFlatBuffersWriterFacade(cfg); + writer->SetPostProcessor(std::make_shared()); + RunWriter(*writer, 3); + } + auto rctx = VTX::OpenReplayFile(cfg.output_filepath); + ASSERT_TRUE(rctx) << rctx.error; + const auto* frame = rctx.reader->GetFrameSync(0); + ASSERT_NE(frame, nullptr); + const auto& entities = frame->GetBuckets()[0].entities; + EXPECT_EQ(entities.size(), 2u); + for (const auto& e : entities) { + EXPECT_NE(e.int32_properties[0], 2); + } +} + +TEST_F(WriterPostProcessorTest, GlobalFrameIndexIsMonotonic) { + const auto cfg = MakeConfig(TestUuid()); + auto recorder = std::make_shared(); + { + auto writer = VTX::CreateFlatBuffersWriterFacade(cfg); + writer->SetPostProcessor(recorder); + RunWriter(*writer, 7); + } + ASSERT_EQ(recorder->indices_.size(), 7u); + for (size_t i = 0; i < recorder->indices_.size(); ++i) { + EXPECT_EQ(recorder->indices_[i], static_cast(i)); + } +} + +TEST_F(WriterPostProcessorTest, ClearPostProcessorCallsClearAndUnregisters) { + const auto cfg = MakeConfig(TestUuid()); + auto proc = std::make_shared(); + auto writer = VTX::CreateFlatBuffersWriterFacade(cfg); + writer->SetPostProcessor(proc); + + for (int i = 0; i < 2; ++i) { + auto f = MakePlayerFrame(i); + VTX::GameTime::GameTimeRegister t; + t.game_time = float(i) / 60.0f; + writer->RecordFrame(f, t); + } + writer->ClearPostProcessor(); + EXPECT_TRUE(proc->clear_called_); + EXPECT_EQ(writer->GetPostProcessor(), nullptr); + + const int calls_before = proc->process_calls_; + auto f = MakePlayerFrame(2); + VTX::GameTime::GameTimeRegister t; + t.game_time = 2.0f / 60.0f; + writer->RecordFrame(f, t); + EXPECT_EQ(proc->process_calls_, calls_before); + + writer->Flush(); + writer->Stop(); +}