From a5a6eab3809e4defc66c6f1947df9764c98175aa Mon Sep 17 00:00:00 2001 From: EHotwagner Date: Sat, 3 Oct 2026 10:14:24 +0200 Subject: [PATCH] fix: replace sparse owned damage with coalesced authoritative state --- .../roadmaps/barc-01-coordinator-admission.md | 36 ++++ src/circuit/grpc/StateUpdateProjection.h | 16 +- src/circuit/module/GrpcGatewayModule.cpp | 81 +++++---- tests/unit/state_update_projection_test.cc | 169 ++++++++++++++++++ 4 files changed, 263 insertions(+), 39 deletions(-) diff --git a/docs/roadmaps/barc-01-coordinator-admission.md b/docs/roadmaps/barc-01-coordinator-admission.md index 42a8b238..9dafd993 100644 --- a/docs/roadmaps/barc-01-coordinator-admission.md +++ b/docs/roadmaps/barc-01-coordinator-admission.md @@ -20,3 +20,39 @@ Validation on 2026-09-28 from protected `master` `66483515`: - `coordinator_command_admission_test` passed 100 consecutive executions. - `CoordinatorClient.cpp` passed a standalone C++20 syntax compile against the generated protobuf/gRPC headers. + +## BARC-01.5f — bounded owned-damage replacement source window + +2026-10-03 source follow-up, retaining the FSBar-owned BARC-01.5f horizon. +Owned damage now requests the existing coalesced engine-thread full entity/economy +replacement. Only handled sparse owned/enemy damage and enemy destruction arms +are removed from the coordinator projection when that replacement is scheduled; +legacy bytes, dispatch/economy order and unsupported lifecycle/unknown arms remain +intact. Resulting owned health still comes from the engine wrapper in +`SnapshotBuilder::FillOwnUnits`; no damage subtraction or protocol change occurs. + +The production planner/projector and snapshot publication gate have focused +compiled coverage: 128 damage notifications coalesce to one replacement; mixed +dispatch/economy retain order; sparse-only sequence 35 is skipped and complete +replacement 36 can recover; deferred scheduling retains the replacement request; +failed build/serialization or enqueue cannot grant a new basis; recorded identity +is the exact enqueued snapshot identity. Controlled engine observations serialize +owned unit 42 with health 83 through the actual protobuf implementation. The +optional `HIGHBAR_CONTROLLED_FIXTURE_DIR` output from +`state_update_projection_test` emits legacy/projected/replacement `.pb` files for +the subsequent actual FSBar typed-reducer join. These are **controlled fixtures**, +not reconstructed native payload evidence: the retained native packet lacks raw +StateDelta bytes. This test does not execute the engine manager walk. + +Qualification: focused C++ test compilation/execution and gateway syntax compile +against retained Recoil 2026.07.04 engine/SDK headers. Full ABI-matched plugin +rebuild, source/build/configuration/loaded custody, consumer metadata ordering and +native operation remain separate integrator gates. No publication or native +operation is granted; native useful play remains **0/6**. Creation, finish, +ownership, visibility entry and owned removal remain outside this source window. +Telemetry begin returned `not-configured`; native usage remains **Unknown**. +The exact-schema focused build ran **14 passing tests**; removing owned-damage +projection from a private header copy reproduced both sparse-only and burst test +failures. The gateway syntax check passed in 5.05 seconds (existing `LOG` macro +redefinition warning); the focused exact-schema compilation took 12.35 seconds +with one compiler process. Neither check rebuilt or loaded a native plugin. diff --git a/src/circuit/grpc/StateUpdateProjection.h b/src/circuit/grpc/StateUpdateProjection.h index ae0e9bca..3835facb 100644 --- a/src/circuit/grpc/StateUpdateProjection.h +++ b/src/circuit/grpc/StateUpdateProjection.h @@ -34,7 +34,18 @@ class StateUpdateOrder { bool replacement_pending_ = false; }; -// Copies the source identity and every delta arm except the two sparse enemy +// The builder may throw (including serialization failure). A failed build or +// enqueue never grants a new basis; record receives the exact enqueued update. +// Callers retain their existing exception/disabled-state boundary. +template +bool PublishSnapshotBeforeBasis(Build build, Enqueue enqueue, Record record) { + auto update = build(); + if (!enqueue(update)) return false; + record(update); + return true; +} + +// Copies the source identity and every delta arm except the handled sparse damage/destroy // events whose wire shape cannot update current health/presence truthfully. // Returns false when no coordinator delta remains; the following complete // snapshot is then the coordinator's recovery baseline across the skipped seq. @@ -47,7 +58,8 @@ inline bool BuildCoordinatorDeltaProjection( projection->set_frame(source.frame()); projection->set_send_monotonic_ns(source.send_monotonic_ns()); for (const auto& event : source.delta().events()) { - if (event.kind_case() == ::highbar::v1::DeltaEvent::kEnemyDamaged + if (event.kind_case() == ::highbar::v1::DeltaEvent::kUnitDamaged + || event.kind_case() == ::highbar::v1::DeltaEvent::kEnemyDamaged || event.kind_case() == ::highbar::v1::DeltaEvent::kEnemyDestroyed) { continue; } diff --git a/src/circuit/module/GrpcGatewayModule.cpp b/src/circuit/module/GrpcGatewayModule.cpp index 498090ba..2714bc4e 100644 --- a/src/circuit/module/GrpcGatewayModule.cpp +++ b/src/circuit/module/GrpcGatewayModule.cpp @@ -1329,6 +1329,9 @@ void CGrpcGatewayModule::OnUnitDamagedFull(CCircuitUnit* unit, SetVec3(ev->mutable_direction(), dir); ev->set_weapon_def_id(weaponDefId); ev->set_is_paralyzer(paralyzer); + // Damage is not resulting health (repair, regeneration and paralysis can + // intervene). Read current engine facts in the coalesced end-frame snapshot. + state_update_order_.RequestFullStateReplacement(); }); } @@ -1624,7 +1627,7 @@ void CGrpcGatewayModule::FlushDelta(bool project_complete_world_state) { // Client-mode consumers require complete world facts at damage and // destroy boundaries. Preserve the exact legacy delta in ring/DeltaBus, - // but remove only those two sparse arms from the coordinator projection; + // but remove only handled sparse arms from the coordinator projection; // the complete replacement snapshot follows at seq+1. if (coordinator_client_) { if (project_complete_world_state) { @@ -1690,44 +1693,48 @@ void CGrpcGatewayModule::BroadcastSnapshot(std::uint32_t effective_cadence_frame try { const std::uint64_t t0 = NowMicros(); - ::highbar::v1::StateUpdate update; - update.set_seq(++seq_); - update.set_frame(FrameForWire(circuit)); - - // Build the snapshot. BuildIncremental omits StaticMap — it was - // already delivered in HelloResponse and on any StreamState - // resume-from-empty path; per-tick resends would be wasted bytes. - auto* snap = update.mutable_snapshot(); - *snap = snapshot_->BuildIncremental(); - snap->set_effective_cadence_frames(effective_cadence_frames); - snap->set_frame_number(FrameForWire(circuit)); - - // Constitution V: stamp CLOCK_MONOTONIC_ns at the moment we hand - // the frame to the fan-out. Same pattern as CoordinatorClient. - const auto snapshot_emitted_at = grpc::LiveControlState::Clock::now(); - { - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - update.set_send_monotonic_ns( - static_cast(ts.tv_sec) * 1000000000ULL - + static_cast(ts.tv_nsec)); - } + grpc::LiveControlState::Clock::time_point snapshot_emitted_at; + grpc::PublishSnapshotBeforeBasis([&]() { + ::highbar::v1::StateUpdate update; + update.set_seq(++seq_); + update.set_frame(FrameForWire(circuit)); + + // Build the snapshot. BuildIncremental omits StaticMap — it was + // already delivered in HelloResponse and on any StreamState + // resume-from-empty path; per-tick resends would be wasted bytes. + auto* snap = update.mutable_snapshot(); + *snap = snapshot_->BuildIncremental(); + snap->set_effective_cadence_frames(effective_cadence_frames); + snap->set_frame_number(FrameForWire(circuit)); + + // Constitution V: stamp CLOCK_MONOTONIC_ns at the moment we hand + // the frame to the fan-out. Same pattern as CoordinatorClient. + snapshot_emitted_at = grpc::LiveControlState::Clock::now(); + { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + update.set_send_monotonic_ns( + static_cast(ts.tv_sec) * 1000000000ULL + + static_cast(ts.tv_nsec)); + } - auto payload = std::make_shared(); - if (!update.SerializeToString(payload.get())) { - throw std::runtime_error("SerializeToString failed"); - } - auto frozen = std::const_pointer_cast(payload); + auto payload = std::make_shared(); + if (!update.SerializeToString(payload.get())) { + throw std::runtime_error("SerializeToString failed"); + } + auto frozen = std::const_pointer_cast(payload); - { - std::unique_lock lock(state_mutex_); - ring_->Push(seq_, frozen); - } - delta_bus_->Publish(frozen); + { + std::unique_lock lock(state_mutex_); + ring_->Push(seq_, frozen); + } + delta_bus_->Publish(frozen); - if (coordinator_client_) { - const bool enqueued = coordinator_client_->PushStateUpdate(update); - if (enqueued && live_control_state_) { + return update; + }, [&](const ::highbar::v1::StateUpdate& update) { + return coordinator_client_ && coordinator_client_->PushStateUpdate(update); + }, [&](const ::highbar::v1::StateUpdate& update) { + if (live_control_state_) { auto units = live_control_state_->SnapshotUnitMetadata(); if (units) { ::highbar::v1::LiveSnapshotMetadata metadata; @@ -1741,7 +1748,7 @@ void CGrpcGatewayModule::BroadcastSnapshot(std::uint32_t effective_cadence_frame BuildAndReportTacticalSnapshot(basis); } } - } + }); if (counters_ != nullptr) { counters_->RecordFrameFlushUs(NowMicros() - t0); diff --git a/tests/unit/state_update_projection_test.cc b/tests/unit/state_update_projection_test.cc index 56ef8dfa..9710e7c1 100644 --- a/tests/unit/state_update_projection_test.cc +++ b/tests/unit/state_update_projection_test.cc @@ -4,10 +4,12 @@ #include +#include #include #include #include #include +#include namespace { @@ -46,6 +48,7 @@ TEST(StateUpdateProjection, LegacyBytesStayExactAndCoordinatorKeepsFeedback) { legacy.set_seq(113); legacy.set_frame(900); legacy.mutable_delta()->add_events()->mutable_enemy_damaged()->set_enemy_id(77); + legacy.mutable_delta()->add_events()->mutable_unit_damaged()->set_unit_id(42); legacy.mutable_delta()->add_events()->mutable_command_dispatch()->set_frame(900); legacy.mutable_delta()->add_events()->mutable_economy_tick()->set_metal(250.0f); legacy.mutable_delta()->add_events()->mutable_unit_idle()->set_unit_id(42); @@ -92,6 +95,172 @@ TEST(StateUpdateProjection, SparseOnlyDestroyDoesNotReachCoordinatorAsDelta) { EXPECT_FALSE(BuildCoordinatorDeltaProjection(legacy, &projected)); } +// Controlled engine observations, not a replay of retained native payloads. +// The retained packet has sequence/arm observations but no raw StateDelta. +TEST(StateUpdateProjection, OwnedDamageBurstSerializesAuthoritativeReplacement) { + StateUpdateOrder order; + ::highbar::v1::StateUpdate legacy; + legacy.set_seq(35); + legacy.set_frame(900); + legacy.set_send_monotonic_ns(900000); + for (int i = 0; i < 128; ++i) { + auto* event = legacy.mutable_delta()->add_events()->mutable_unit_damaged(); + event->set_unit_id(42); + event->set_damage(25.0f); + event->set_is_paralyzer(i % 2 == 0); + order.RequestFullStateReplacement(); + } + legacy.mutable_delta()->add_events()->mutable_command_dispatch()->set_frame(900); + legacy.mutable_delta()->add_events()->mutable_economy_tick()->set_metal(250.0f); + const auto original = legacy.SerializeAsString(); + const auto plan = order.Plan(true, true); + ASSERT_FALSE(plan.snapshot_before_delta); + ASSERT_TRUE(plan.flush_delta); + ASSERT_TRUE(plan.snapshot_after_delta); + ASSERT_FALSE(order.ReplacementPending()); + ::highbar::v1::StateUpdate projected; + ASSERT_TRUE(BuildCoordinatorDeltaProjection(legacy, &projected)); + ASSERT_EQ(projected.delta().events_size(), 2); + EXPECT_EQ(projected.seq(), 35u); + EXPECT_EQ(projected.frame(), 900u); + EXPECT_EQ(projected.send_monotonic_ns(), 900000u); + EXPECT_EQ(projected.delta().events(0).kind_case(), ::highbar::v1::DeltaEvent::kCommandDispatch); + EXPECT_EQ(projected.delta().events(1).kind_case(), ::highbar::v1::DeltaEvent::kEconomyTick); + EXPECT_EQ(legacy.SerializeAsString(), original); + // The authoritative controlled observation is 83, regardless of 128 damage + // notifications, repair/regeneration, or paralyzer damage. No subtraction. + ::highbar::v1::StateUpdate replacement; + replacement.set_seq(36); + replacement.set_frame(900); + replacement.set_send_monotonic_ns(900001); + auto* snapshot = replacement.mutable_snapshot(); + snapshot->set_frame_number(900); + snapshot->set_effective_cadence_frames(1); + auto* unit = snapshot->add_own_units(); + unit->set_unit_id(42); + unit->set_team_id(0); + unit->set_def_id(7); + unit->set_health(83.0f); + unit->set_max_health(100.0f); + unit->mutable_position()->set_x(100.0f); + unit->mutable_position()->set_z(200.0f); + snapshot->mutable_economy()->set_metal(250.0f); + ::highbar::v1::StateUpdate decoded; + ASSERT_TRUE(decoded.ParseFromString(replacement.SerializeAsString())); + EXPECT_EQ(decoded.snapshot().own_units(0).health(), 83.0f); + EXPECT_EQ(decoded.snapshot().own_units_size(), 1); + EXPECT_FALSE(decoded.snapshot().has_static_map()); + const auto next = order.Plan(false, false); + EXPECT_FALSE(next.snapshot_before_delta); + EXPECT_FALSE(next.snapshot_after_delta); + EXPECT_FALSE(next.flush_delta); + if (const char* output = std::getenv("HIGHBAR_CONTROLLED_FIXTURE_DIR")) { + std::filesystem::create_directories(output); + const auto write = [&](const char* name, const auto& update) { + std::ofstream file(std::filesystem::path(output) / name, std::ios::binary); + ASSERT_TRUE(file.good()); + ASSERT_TRUE(update.SerializeToOstream(&file)); + }; + auto baseline = replacement; + baseline.set_seq(34); + baseline.set_frame(899); + baseline.set_send_monotonic_ns(899001); + baseline.mutable_snapshot()->set_frame_number(899); + baseline.mutable_snapshot()->mutable_own_units(0)->set_health(100.0f); + write("owned-damage-baseline.pb", baseline); + write("owned-damage-legacy.pb", legacy); + write("owned-damage-projected.pb", projected); + write("owned-damage-replacement.pb", replacement); + } +} + +TEST(StateUpdateProjection, FailedBuildOrEnqueueCannotAdvanceBasis) { + using ::circuit::grpc::PublishSnapshotBeforeBasis; + int enqueue_calls = 0; + std::uint64_t basis = 34; + const auto enqueue = [&](const auto&) { ++enqueue_calls; return false; }; + const auto record = [&](const auto& update) { basis = update.seq(); }; + EXPECT_THROW(PublishSnapshotBeforeBasis([]() -> ::highbar::v1::StateUpdate { + throw std::runtime_error("controlled build/serialization failure"); + }, enqueue, record), std::runtime_error); + EXPECT_EQ(enqueue_calls, 0); + EXPECT_EQ(basis, 34u); + EXPECT_FALSE(PublishSnapshotBeforeBasis([]() { + ::highbar::v1::StateUpdate update; + update.set_seq(36); + update.mutable_snapshot(); + return update; + }, enqueue, record)); + EXPECT_EQ(enqueue_calls, 1); + EXPECT_EQ(basis, 34u); +} + +TEST(StateUpdateProjection, BasisUsesExactEnqueuedSnapshotIdentity) { + using ::circuit::grpc::PublishSnapshotBeforeBasis; + std::string enqueued_bytes; + ::highbar::v1::StateUpdate recorded; + ASSERT_TRUE(PublishSnapshotBeforeBasis([]() { + ::highbar::v1::StateUpdate update; + update.set_seq(36); + update.set_frame(900); + update.set_send_monotonic_ns(900001); + update.mutable_snapshot()->set_frame_number(900); + return update; + }, [&](const auto& update) { + enqueued_bytes = update.SerializeAsString(); + return true; + }, [&](const auto& update) { recorded = update; })); + EXPECT_EQ(recorded.SerializeAsString(), enqueued_bytes); + EXPECT_EQ(recorded.seq(), 36u); + EXPECT_EQ(recorded.frame(), 900u); + EXPECT_EQ(recorded.send_monotonic_ns(), 900001u); +} + +TEST(StateUpdateProjection, OwnedDamageOnlySkipsSparseSequence) { + ::highbar::v1::StateUpdate legacy; + legacy.set_seq(35); + legacy.mutable_delta()->add_events()->mutable_unit_damaged()->set_unit_id(42); + ::highbar::v1::StateUpdate projected; + EXPECT_FALSE(BuildCoordinatorDeltaProjection(legacy, &projected)); + EXPECT_EQ(projected.delta().events_size(), 0); +} + +TEST(StateUpdateProjection, UnscheduledReplacementRemainsPending) { + StateUpdateOrder order; + order.RequestFullStateReplacement(); + const auto deferred = order.Plan(false, true); + EXPECT_FALSE(deferred.snapshot_before_delta); + EXPECT_FALSE(deferred.snapshot_after_delta); + EXPECT_TRUE(deferred.flush_delta); + EXPECT_TRUE(order.ReplacementPending()); + EXPECT_TRUE(order.Plan(true, false).snapshot_after_delta); +} + +TEST(StateUpdateProjection, LifecycleArmsRemainVisibleForConsumerRefusal) { + ::highbar::v1::StateUpdate source; + source.mutable_delta()->add_events()->mutable_unit_damaged()->set_unit_id(42); + source.mutable_delta()->add_events()->mutable_unit_created()->set_unit_id(43); + source.mutable_delta()->add_events()->mutable_unit_destroyed()->set_unit_id(44); + ::highbar::v1::StateUpdate projected; + ASSERT_TRUE(BuildCoordinatorDeltaProjection(source, &projected)); + ASSERT_EQ(projected.delta().events_size(), 2); + EXPECT_EQ(projected.delta().events(0).kind_case(), ::highbar::v1::DeltaEvent::kUnitCreated); + EXPECT_EQ(projected.delta().events(1).kind_case(), ::highbar::v1::DeltaEvent::kUnitDestroyed); +} + +TEST(StateUpdateProjection, OwnedCallbackRequestsReplacementWithoutInferringHealth) { + const auto root = std::filesystem::path(__FILE__).parent_path().parent_path().parent_path(); + const auto source = ReadSource(root / "src/circuit/module/GrpcGatewayModule.cpp"); + const auto begin = source.find("void CGrpcGatewayModule::OnUnitDamagedFull"); + const auto end = source.find("int CGrpcGatewayModule::UnitDestroyed", begin); + ASSERT_NE(begin, std::string::npos); + ASSERT_NE(end, std::string::npos); + const auto callback = source.substr(begin, end - begin); + EXPECT_NE(callback.find("mutable_unit_damaged()"), std::string::npos); + EXPECT_NE(callback.find("RequestFullStateReplacement();"), std::string::npos); + EXPECT_EQ(callback.find("set_health"), std::string::npos); +} + TEST(StateUpdateProjection, DamageBoundaryRefreshesSpringCacheBeforeReplacement) { const auto root = std::filesystem::path(__FILE__).parent_path().parent_path().parent_path(); const auto source = ReadSource(root / "src/circuit/module/GrpcGatewayModule.cpp");