From 2c88494d2da369b5eb02dcc1c723036c57ba81c7 Mon Sep 17 00:00:00 2001 From: EHotwagner Date: Thu, 1 Oct 2026 09:01:21 +0200 Subject: [PATCH 1/7] Freeze stock tactical queue contract --- contracts/barc-stock-queue-v1/contract.json | 127 ++++++++++++++++++++ contracts/barc-stock-queue-v1/vectors.json | 34 ++++++ proto/highbar/live_control.proto | 14 +++ src/circuit/grpc/TacticalNativeState.cpp | 26 ++++ src/circuit/grpc/TacticalNativeState.h | 29 +++++ src/circuit/module/GrpcGatewayModule.cpp | 7 +- tests/contract/stock_queue_contract_test.py | 34 ++++++ tests/unit/tactical_native_state_test.cc | 34 ++++++ 8 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 contracts/barc-stock-queue-v1/contract.json create mode 100644 contracts/barc-stock-queue-v1/vectors.json create mode 100755 tests/contract/stock_queue_contract_test.py diff --git a/contracts/barc-stock-queue-v1/contract.json b/contracts/barc-stock-queue-v1/contract.json new file mode 100644 index 00000000..01900215 --- /dev/null +++ b/contracts/barc-stock-queue-v1/contract.json @@ -0,0 +1,127 @@ +{ + "schema": "barc.stock-queue-contract/1", + "owner": "FS-GG/HighBarV3", + "consumer": "FS-GG/FSBarV2", + "stockEngine": { + "sourceCommit": "2639eedac7d1fd67d793ec93ebd27f014f336a14", + "headlessSha256": "e4f63c1a391f9ddfbb4d1da225d9533b1d56c65133687d036422a7380c84e833", + "release": "2025.06.19" + }, + "profiles": { + "legacy": { + "name": "barc-live-tactical-v1", + "revision": 1, + "acceptedEvidenceSchemeNumbers": [ + 0, + 1 + ], + "defaultZeroMeaning": "full-native-tuple-v1" + }, + "stock": { + "name": "barc-live-tactical-stock-v1", + "revision": 2, + "requiredEvidenceSchemeNumber": 2 + } + }, + "evidenceSchemes": { + "unspecified": 0, + "fullNativeTupleV1": 1, + "stockLuaSupportedFieldsV1": 2 + }, + "stockFields": [ + "domain", + "id", + "options.coded", + "tag", + "float32params" + ], + "unavailableFields": [ + "timeout" + ], + "catalogueBinding": [ + "profile", + "revision", + "catalogueId", + "catalogueRevision", + "engineVersion", + "gameName", + "gameVersion", + "gameContentSha256" + ], + "revisionBinding": [ + "evidenceScheme", + "profile", + "revision", + "catalogueBinding", + "actorId", + "actorLifetime", + "queueDomain", + "completeOrderedRows" + ], + "revisionAlgorithm": { + "name": "barc-stock-queue-revision-sha256-u64be-v1", + "input": "canonical ASCII response plus profile, catalogue binding and actor lifetime envelope", + "projection": "first eight SHA-256 bytes as unsigned big-endian uint64", + "zeroRule": "map zero to one" + }, + "factoryProductionPolicies": { + "append": "supported", + "rejectIfBusy": "supported only for an observed empty production queue", + "replace": "unsupported including empty queue; refuse without append fallback" + }, + "bridge": { + "id": "barc-stock-queue-reader-v1", + "requestMagic": "BARC_QUEUE_REQUEST/1", + "responseMagic": "BARC_QUEUE_RESPONSE/1", + "encoding": "7-bit ASCII without NUL", + "length": "eight decimal digits equal total bytes including terminal newline", + "terminal": "end\\n", + "requestFields": [ + "bridge", + "domain", + "unit" + ], + "responseFields": [ + "bridge", + "request-sha256", + "status", + "domain", + "unit", + "reason only when unavailable", + "count", + "ordered row records only when ok" + ], + "rowGrammar": "row=||||", + "float32": "exactly eight lowercase IEEE-754 binary32 hex digits; finite only; 80000000 is negative zero and must round-trip or the bridge refuses the value", + "limits": { + "maxInputBytes": 8192, + "maxResponseBytes": 8192, + "maxEntries": 64, + "overflowProbeEntries": 65, + "maxParamsPerEntry": 16, + "maxTotalParams": 256, + "maxLineBytes": 512 + }, + "domains": [ + "production", + "rally" + ], + "statuses": [ + "ok", + "unavailable" + ], + "unavailableReasons": [ + "missing-bridge", + "wrong-version", + "wrong-team", + "not-factory", + "unsupported-domain", + "malformed", + "overflow", + "nonfinite-float", + "negative-zero-unsupported", + "oversize" + ] + }, + "vectorsFile": "vectors.json" +} diff --git a/contracts/barc-stock-queue-v1/vectors.json b/contracts/barc-stock-queue-v1/vectors.json new file mode 100644 index 00000000..6eb99ad7 --- /dev/null +++ b/contracts/barc-stock-queue-v1/vectors.json @@ -0,0 +1,34 @@ +{ + "schema": "barc.stock-queue-bridge-vectors/1", + "requestSha256": "b01b44c1eaea587730eae794c0b87e4e05dddecfb508abd295ac1e138a0f5960", + "vectors": [ + { + "name": "production-request", + "kind": "request", + "ascii": "BARC_QUEUE_REQUEST/1\nlength=00000101\nbridge=barc-stock-queue-reader-v1\ndomain=production\nunit=42\nend\n", + "byteLength": 101, + "sha256": "b01b44c1eaea587730eae794c0b87e4e05dddecfb508abd295ac1e138a0f5960" + }, + { + "name": "empty-production", + "kind": "response", + "ascii": "BARC_QUEUE_RESPONSE/1\nlength=00000200\nbridge=barc-stock-queue-reader-v1\nrequest-sha256=b01b44c1eaea587730eae794c0b87e4e05dddecfb508abd295ac1e138a0f5960\nstatus=ok\ndomain=production\nunit=42\ncount=0\nend\n", + "byteLength": 200, + "sha256": "996539730370dcee4240a0d66c24a94fd9cda77ef5fea3aff07eed90cb7409bb" + }, + { + "name": "one-production-with-negative-zero", + "kind": "response", + "ascii": "BARC_QUEUE_RESPONSE/1\nlength=00000244\nbridge=barc-stock-queue-reader-v1\nrequest-sha256=b01b44c1eaea587730eae794c0b87e4e05dddecfb508abd295ac1e138a0f5960\nstatus=ok\ndomain=production\nunit=42\ncount=1\nrow=production|-710|32|41|3f800000,80000000\nend\n", + "byteLength": 244, + "sha256": "b9294f24faaad63777f99c44dd6201dabf41ec32772b862d657698d3c5d32ec5" + }, + { + "name": "wrong-team-unavailable", + "kind": "response", + "ascii": "BARC_QUEUE_RESPONSE/1\nlength=00000227\nbridge=barc-stock-queue-reader-v1\nrequest-sha256=b01b44c1eaea587730eae794c0b87e4e05dddecfb508abd295ac1e138a0f5960\nstatus=unavailable\ndomain=production\nunit=42\nreason=wrong-team\ncount=0\nend\n", + "byteLength": 227, + "sha256": "63861e8f1682fbd8e8551a24a4c39d1217a8b98e269bd44aceaf266c144acb6e" + } + ] +} diff --git a/proto/highbar/live_control.proto b/proto/highbar/live_control.proto index eb4127aa..9d285aaa 100644 --- a/proto/highbar/live_control.proto +++ b/proto/highbar/live_control.proto @@ -303,6 +303,15 @@ enum NativeQueueDomain { NATIVE_QUEUE_DOMAIN_FACTORY_RALLY = 3; } +// Queue revisions are meaningful only within their negotiated evidence +// scheme. Tactical v1 historically omitted this field; zero therefore keeps +// its full-native-tuple-v1 meaning only for that exact legacy profile. +enum NativeQueueEvidenceScheme { + NATIVE_QUEUE_EVIDENCE_SCHEME_UNSPECIFIED = 0; + NATIVE_QUEUE_EVIDENCE_SCHEME_FULL_NATIVE_TUPLE_V1 = 1; + NATIVE_QUEUE_EVIDENCE_SCHEME_STOCK_LUA_SUPPORTED_FIELDS_V1 = 2; +} + enum NativeTacticalDescriptorKind { NATIVE_TACTICAL_DESCRIPTOR_KIND_UNSPECIFIED = 0; NATIVE_TACTICAL_DESCRIPTOR_BUILD = 1; @@ -446,6 +455,11 @@ message NativeObservedQueue { repeated NativeObservedQueueEntry entries = 3; bool complete = 4; optional bool repeat = 5; + // barc-live-tactical-v1 revision 1 accepts zero (its historical default) + // or FULL_NATIVE_TUPLE_V1. barc-live-tactical-stock-v1 revision 2 requires + // STOCK_LUA_SUPPORTED_FIELDS_V1; zero, unknown and cross-profile values are + // unavailable and cannot authorize an action. + NativeQueueEvidenceScheme evidence_scheme = 6; } message TacticalSnapshotMetadata { diff --git a/src/circuit/grpc/TacticalNativeState.cpp b/src/circuit/grpc/TacticalNativeState.cpp index 9183081d..2f3e3791 100644 --- a/src/circuit/grpc/TacticalNativeState.cpp +++ b/src/circuit/grpc/TacticalNativeState.cpp @@ -40,6 +40,32 @@ void AdvanceLifetime(std::uint64_t* lifetime) { } // namespace +bool TacticalQueueEvidenceMatches(const char* profile, + std::uint32_t revision, QueueEvidenceScheme scheme) { + if (profile == nullptr) return false; + if (std::strcmp(profile, kFullTupleTacticalProfile) == 0 + && revision == kFullTupleTacticalRevision) { + return scheme == QueueEvidenceScheme::Unspecified + || scheme == QueueEvidenceScheme::FullNativeTupleV1; + } + return std::strcmp(profile, kStockTacticalProfile) == 0 + && revision == kStockTacticalRevision + && scheme == QueueEvidenceScheme::StockLuaSupportedFieldsV1; +} + +bool StockFactoryProductionPolicyAllows(StockFactoryQueuePolicy policy, + bool observed_empty) { + switch (policy) { + case StockFactoryQueuePolicy::Append: + return true; + case StockFactoryQueuePolicy::RejectIfBusy: + return observed_empty; + case StockFactoryQueuePolicy::Replace: + return false; + } + return false; +} + bool SupportsRallyQueueApi(const char* hash, const char* additional) { return hash != nullptr && additional != nullptr && std::strcmp(hash, kRallyQueueEngineHash) == 0 diff --git a/src/circuit/grpc/TacticalNativeState.h b/src/circuit/grpc/TacticalNativeState.h index ff434089..45c058cc 100644 --- a/src/circuit/grpc/TacticalNativeState.h +++ b/src/circuit/grpc/TacticalNativeState.h @@ -10,6 +10,35 @@ namespace circuit::grpc { +inline constexpr const char* kFullTupleTacticalProfile = + "barc-live-tactical-v1"; +inline constexpr std::uint32_t kFullTupleTacticalRevision = 1; +inline constexpr const char* kStockTacticalProfile = + "barc-live-tactical-stock-v1"; +inline constexpr std::uint32_t kStockTacticalRevision = 2; + +enum class QueueEvidenceScheme : std::uint32_t { + Unspecified = 0, + FullNativeTupleV1 = 1, + StockLuaSupportedFieldsV1 = 2, +}; + +// Zero retains its historical full-tuple meaning only for tactical v1. +// Stock and unknown/cross-scheme combinations fail closed. +bool TacticalQueueEvidenceMatches(const char* profile, std::uint32_t revision, + QueueEvidenceScheme scheme); + +enum class StockFactoryQueuePolicy : std::uint32_t { + Replace = 1, + Append = 2, + RejectIfBusy = 3, +}; + +// Stock FactoryProduce Replace is unsupported even for an empty queue. There +// is no Replace-to-Append alias. +bool StockFactoryProductionPolicyAllows(StockFactoryQueuePolicy policy, + bool observed_empty); + // External tactical orders and Circuit's autonomous task scheduler share the // same Spring command queue. The ownership transition must happen before the // accepted effect is emitted so a later autonomous update cannot insert an diff --git a/src/circuit/module/GrpcGatewayModule.cpp b/src/circuit/module/GrpcGatewayModule.cpp index 0e61025d..209284bf 100644 --- a/src/circuit/module/GrpcGatewayModule.cpp +++ b/src/circuit/module/GrpcGatewayModule.cpp @@ -587,8 +587,8 @@ void CGrpcGatewayModule::EnsureCoordinatorClientStarted(const char* reason) { capabilities.set_supports_stop(true); capabilities.set_supports_move(true); capabilities.set_supports_attack_visible_unit(true); capabilities.set_max_reported_units(live_max_reported_units_); auto* tactical = capabilities.mutable_tactical(); - tactical->set_profile("barc-live-tactical-v1"); - tactical->set_revision(1); + tactical->set_profile(grpc::kFullTupleTacticalProfile); + tactical->set_revision(grpc::kFullTupleTacticalRevision); tactical->set_max_catalogue_entries(kTacticalMaxCatalogueEntries); tactical->set_max_catalogue_page_entries(kTacticalPageEntries); tactical->set_max_build_options_per_actor(kTacticalMaxBuildOptions); @@ -710,7 +710,8 @@ void CGrpcGatewayModule::BuildAndReportTacticalCatalogue() { const std::size_t page_count = std::max(1, (native.size()+kTacticalPageEntries-1)/kTacticalPageEntries); for (std::size_t page_index=0; page_indexset_engine_version(grpc::kEngineReleaseId); page.mutable_content()->set_game_name(game_name); page.mutable_content()->set_game_version(game_version); page.mutable_content()->set_game_content_sha256(content_hash); diff --git a/tests/contract/stock_queue_contract_test.py b/tests/contract/stock_queue_contract_test.py new file mode 100755 index 00000000..ec9eeeab --- /dev/null +++ b/tests/contract/stock_queue_contract_test.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +import hashlib,json,re,sys +from pathlib import Path +repo=Path(__file__).resolve().parents[2] +packet=repo/'contracts/barc-stock-queue-v1' +contract=json.loads((packet/'contract.json').read_text()) +vectors=json.loads((packet/'vectors.json').read_text()) +assert contract['schema']=='barc.stock-queue-contract/1' +assert contract['profiles']['legacy']=={'name':'barc-live-tactical-v1','revision':1,'acceptedEvidenceSchemeNumbers':[0,1],'defaultZeroMeaning':'full-native-tuple-v1'} +assert contract['profiles']['stock']['name']=='barc-live-tactical-stock-v1' and contract['profiles']['stock']['revision']==2 +assert contract['profiles']['stock']['requiredEvidenceSchemeNumber']==2 +assert contract['factoryProductionPolicies']['replace'].startswith('unsupported including empty queue') +assert contract['unavailableFields']==['timeout'] +assert contract['bridge']['limits']=={ + 'maxInputBytes':8192,'maxResponseBytes':8192,'maxEntries':64, + 'overflowProbeEntries':65,'maxParamsPerEntry':16, + 'maxTotalParams':256,'maxLineBytes':512} +assert contract['bridge']['float32'].startswith('exactly eight lowercase IEEE-754 binary32 hex digits') +by={v['name']:v for v in vectors['vectors']} +for value in by.values(): + raw=value['ascii'].encode('ascii') + assert b'\0' not in raw and len(raw)<=8192 and raw.endswith(b'end\n') + assert value['byteLength']==len(raw) and value['sha256']==hashlib.sha256(raw).hexdigest() + lines=value['ascii'].splitlines() + assert re.fullmatch(r'length=[0-9]{8}',lines[1]) and int(lines[1][7:])==len(raw) +assert by['production-request']['sha256']==vectors['requestSha256'] +for name in ['empty-production','one-production-with-negative-zero','wrong-team-unavailable']: + assert f'request-sha256={vectors["requestSha256"]}' in by[name]['ascii'] +assert 'row=production|-710|32|41|3f800000,80000000\n' in by['one-production-with-negative-zero']['ascii'] +proto=(repo/'proto/highbar/live_control.proto').read_text() +assert 'NATIVE_QUEUE_EVIDENCE_SCHEME_FULL_NATIVE_TUPLE_V1 = 1;' in proto +assert 'NATIVE_QUEUE_EVIDENCE_SCHEME_STOCK_LUA_SUPPORTED_FIELDS_V1 = 2;' in proto +assert re.search(r'NativeQueueEvidenceScheme evidence_scheme = 6;',proto) +print('stock queue contract vectors: PASS') diff --git a/tests/unit/tactical_native_state_test.cc b/tests/unit/tactical_native_state_test.cc index 9f8f09fe..8af0296b 100644 --- a/tests/unit/tactical_native_state_test.cc +++ b/tests/unit/tactical_native_state_test.cc @@ -8,6 +8,40 @@ namespace { using namespace circuit::grpc; +TEST(TacticalNativeState, QueueEvidenceIsBoundToExactProfileAndRevision) { + EXPECT_TRUE(TacticalQueueEvidenceMatches(kFullTupleTacticalProfile, + kFullTupleTacticalRevision, QueueEvidenceScheme::Unspecified)); + EXPECT_TRUE(TacticalQueueEvidenceMatches(kFullTupleTacticalProfile, + kFullTupleTacticalRevision, QueueEvidenceScheme::FullNativeTupleV1)); + EXPECT_TRUE(TacticalQueueEvidenceMatches(kStockTacticalProfile, + kStockTacticalRevision, QueueEvidenceScheme::StockLuaSupportedFieldsV1)); + EXPECT_FALSE(TacticalQueueEvidenceMatches(kStockTacticalProfile, + kStockTacticalRevision, QueueEvidenceScheme::Unspecified)); + EXPECT_FALSE(TacticalQueueEvidenceMatches(kStockTacticalProfile, + kStockTacticalRevision, QueueEvidenceScheme::FullNativeTupleV1)); + EXPECT_FALSE(TacticalQueueEvidenceMatches(kFullTupleTacticalProfile, + kFullTupleTacticalRevision, QueueEvidenceScheme::StockLuaSupportedFieldsV1)); + EXPECT_FALSE(TacticalQueueEvidenceMatches(kStockTacticalProfile, 1, + QueueEvidenceScheme::StockLuaSupportedFieldsV1)); + EXPECT_FALSE(TacticalQueueEvidenceMatches("unknown", 2, + QueueEvidenceScheme::StockLuaSupportedFieldsV1)); +} + +TEST(TacticalNativeState, StockFactoryReplaceNeverAliasesAppend) { + EXPECT_TRUE(StockFactoryProductionPolicyAllows( + StockFactoryQueuePolicy::Append, false)); + EXPECT_TRUE(StockFactoryProductionPolicyAllows( + StockFactoryQueuePolicy::Append, true)); + EXPECT_TRUE(StockFactoryProductionPolicyAllows( + StockFactoryQueuePolicy::RejectIfBusy, true)); + EXPECT_FALSE(StockFactoryProductionPolicyAllows( + StockFactoryQueuePolicy::RejectIfBusy, false)); + EXPECT_FALSE(StockFactoryProductionPolicyAllows( + StockFactoryQueuePolicy::Replace, false)); + EXPECT_FALSE(StockFactoryProductionPolicyAllows( + StockFactoryQueuePolicy::Replace, true)); +} + TEST(TacticalNativeState, RallyQueueApiRequiresExactPreexistingVersionIdentity) { EXPECT_TRUE(SupportsRallyQueueApi( kRallyQueueEngineHash, kRallyQueueEngineAdditional)); From 2a7ae7fbed81ba02d278be23fbb81967a925ccff Mon Sep 17 00:00:00 2001 From: EHotwagner Date: Thu, 1 Oct 2026 09:14:01 +0200 Subject: [PATCH 2/7] Add stock queue observer content bridge --- .../barc-stock-observer/CONTENT-METADATA.json | 11 + .../Gadgets/barc_stock_queue_reader.lua | 276 ++++++++++++++++++ data/barc-stock-observer/modinfo.lua | 11 + scripts/pack-barc-stock-observer.py | 78 +++++ tests/stock-observer/mock_spring.lua | 50 ++++ tests/stock-observer/test_stock_observer.py | 127 ++++++++ 6 files changed, 553 insertions(+) create mode 100644 data/barc-stock-observer/CONTENT-METADATA.json create mode 100644 data/barc-stock-observer/LuaRules/Gadgets/barc_stock_queue_reader.lua create mode 100644 data/barc-stock-observer/modinfo.lua create mode 100644 scripts/pack-barc-stock-observer.py create mode 100644 tests/stock-observer/mock_spring.lua create mode 100644 tests/stock-observer/test_stock_observer.py diff --git a/data/barc-stock-observer/CONTENT-METADATA.json b/data/barc-stock-observer/CONTENT-METADATA.json new file mode 100644 index 00000000..740a8286 --- /dev/null +++ b/data/barc-stock-observer/CONTENT-METADATA.json @@ -0,0 +1,11 @@ +{ + "schema": "barc.stock-observer-content/1", + "bridge": "barc-stock-queue-reader-v1", + "profile": "barc-live-tactical-stock-v1", + "revision": 2, + "evidenceScheme": "STOCK_LUA_SUPPORTED_FIELDS_V1", + "baseGameDependency": "Beyond All Reason test-29926-0571aa8", + "baseGameRelease": "2025.06.19", + "baseGameHeadlessSha256": "e4f63c1a391f9ddfbb4d1da225d9533b1d56c65133687d036422a7380c84e833", + "stockEngineSourceCommit": "2639eedac7d1fd67d793ec93ebd27f014f336a14" +} diff --git a/data/barc-stock-observer/LuaRules/Gadgets/barc_stock_queue_reader.lua b/data/barc-stock-observer/LuaRules/Gadgets/barc_stock_queue_reader.lua new file mode 100644 index 00000000..7551c4e6 --- /dev/null +++ b/data/barc-stock-observer/LuaRules/Gadgets/barc_stock_queue_reader.lua @@ -0,0 +1,276 @@ +function gadget:GetInfo() + return { + name = "BARC Stock Queue Reader", + desc = "Bounded team-scoped stock production and rally observation", + author = "FS-GG", + version = "1", + date = "2026-10-01", + license = "MIT", + layer = 0, + enabled = true, + } +end + +if not gadgetHandler:IsSyncedCode() then + return +end + +local REQUEST_ROUTE = "BARC_QUEUE_REQUEST/" +local REQUEST_MAGIC = "BARC_QUEUE_REQUEST/1" +local RESPONSE_MAGIC = "BARC_QUEUE_RESPONSE/1" +local BRIDGE = "barc-stock-queue-reader-v1" +local MAX_BYTES = 8192 +local MAX_LINE_BYTES = 512 +local MAX_ENTRIES = 64 +local PROBE_ENTRIES = 65 +local MAX_PARAMS = 16 +local MAX_TOTAL_PARAMS = 256 +local TWO32 = 4294967296 +local TWO31 = 2147483648 +local HEX = "0123456789abcdef" + +local function u32(value) + return value % TWO32 +end + +local function pureBand(a, b) + local result, place = 0, 1 + a, b = u32(a), u32(b) + for _ = 1, 32 do + local aa, bb = a % 2, b % 2 + if aa == 1 and bb == 1 then result = result + place end + a, b, place = math.floor(a / 2), math.floor(b / 2), place * 2 + end + return result +end + +local function pureBxor(a, b) + local result, place = 0, 1 + a, b = u32(a), u32(b) + for _ = 1, 32 do + local aa, bb = a % 2, b % 2 + if aa ~= bb then result = result + place end + a, b, place = math.floor(a / 2), math.floor(b / 2), place * 2 + end + return result +end + +local bitlib = rawget(_G, "bit") or rawget(_G, "bit32") +local function band(a, b) + if bitlib then return u32(bitlib.band(a, b)) end + return pureBand(a, b) +end +local function bxor2(a, b) + if bitlib then return u32(bitlib.bxor(a, b)) end + return pureBxor(a, b) +end +local function bxor(a, b, c) + local result = bxor2(a, b) + return c == nil and result or bxor2(result, c) +end +local function bnot(a) + if bitlib then return u32(bitlib.bnot(a)) end + return 4294967295 - u32(a) +end +local function rshift(a, count) + if bitlib then return u32(bitlib.rshift(a, count)) end + return math.floor(u32(a) / (2 ^ count)) +end +local function ror(a, count) + if bitlib and bitlib.ror then return u32(bitlib.ror(a, count)) end + if bitlib and bitlib.rrotate then return u32(bitlib.rrotate(a, count)) end + return u32(rshift(a, count) + (u32(a) % (2 ^ count)) * (2 ^ (32 - count))) +end + +local function hex32(value) + local chars = {} + value = u32(value) + for shift = 28, 0, -4 do + local nibble = math.floor(value / (2 ^ shift)) % 16 + chars[#chars + 1] = HEX:sub(nibble + 1, nibble + 1) + end + return table.concat(chars) +end + +local SHA_K = { + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2, +} + +local function sha256(message) + local bytes = {} + for index = 1, #message do bytes[index] = message:byte(index) end + local bitLength = #bytes * 8 + bytes[#bytes + 1] = 0x80 + while (#bytes % 64) ~= 56 do bytes[#bytes + 1] = 0 end + local high = math.floor(bitLength / TWO32) + local low = bitLength % TWO32 + for shift = 24, 0, -8 do bytes[#bytes + 1] = math.floor(high / (2 ^ shift)) % 256 end + for shift = 24, 0, -8 do bytes[#bytes + 1] = math.floor(low / (2 ^ shift)) % 256 end + + local h = {0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19} + for offset = 1, #bytes, 64 do + local w = {} + for index = 0, 15 do + local base = offset + index * 4 + w[index] = bytes[base] * 16777216 + bytes[base + 1] * 65536 + bytes[base + 2] * 256 + bytes[base + 3] + end + for index = 16, 63 do + local x, y = w[index - 15], w[index - 2] + local s0 = bxor(ror(x, 7), ror(x, 18), rshift(x, 3)) + local s1 = bxor(ror(y, 17), ror(y, 19), rshift(y, 10)) + w[index] = u32(w[index - 16] + s0 + w[index - 7] + s1) + end + local a,b,c,d,e,f,g,hh = h[1],h[2],h[3],h[4],h[5],h[6],h[7],h[8] + for index = 0, 63 do + local s1 = bxor(ror(e, 6), ror(e, 11), ror(e, 25)) + local choice = bxor(band(e, f), band(bnot(e), g)) + local t1 = u32(hh + s1 + choice + SHA_K[index + 1] + w[index]) + local s0 = bxor(ror(a, 2), ror(a, 13), ror(a, 22)) + local majority = bxor(band(a, b), band(a, c), band(b, c)) + local t2 = u32(s0 + majority) + hh,g,f,e,d,c,b,a = g,f,e,u32(d + t1),c,b,a,u32(t1 + t2) + end + h[1],h[2],h[3],h[4] = u32(h[1]+a),u32(h[2]+b),u32(h[3]+c),u32(h[4]+d) + h[5],h[6],h[7],h[8] = u32(h[5]+e),u32(h[6]+f),u32(h[7]+g),u32(h[8]+hh) + end + local parts = {} + for index = 1, 8 do parts[index] = hex32(h[index]) end + return table.concat(parts) +end + +local function integerInRange(value, minimum, maximum) + return type(value) == "number" and value == math.floor(value) and value >= minimum and value <= maximum +end + +local negativeZeroSupported = (1 / (-0.0)) == -math.huge +local function float32Hex(value) + if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then + return nil, "nonfinite-float" + end + local negative = value < 0 or (value == 0 and negativeZeroSupported and (1 / value) == -math.huge) + local magnitude = math.abs(value) + if magnitude == 0 then + if not negativeZeroSupported then return nil, "negative-zero-unsupported" end + return negative and "80000000" or "00000000" + end + local fraction, exponent = math.frexp(magnitude) + local exponentBits, mantissa + if exponent > -126 then + exponentBits = exponent + 126 + mantissa = math.floor(((fraction * 2 - 1) * 8388608) + 0.5) + if mantissa == 8388608 then exponentBits, mantissa = exponentBits + 1, 0 end + if exponentBits >= 255 then return nil, "nonfinite-float" end + else + exponentBits = 0 + mantissa = math.floor((magnitude * (2 ^ 149)) + 0.5) + if mantissa <= 0 or mantissa >= 8388608 then return nil, "nonfinite-float" end + end + local bits = (negative and TWO31 or 0) + exponentBits * 8388608 + mantissa + return hex32(bits) +end + +local function splitLines(message) + if message:sub(-1) ~= "\n" then return nil end + local lines, position = {}, 1 + while position <= #message do + local newline = message:find("\n", position, true) + if not newline then return nil end + if newline - position + 1 > MAX_LINE_BYTES then return nil end + lines[#lines + 1] = message:sub(position, newline - 1) + position = newline + 1 + end + return lines +end + +local function parseRequest(message) + if type(message) ~= "string" or #message > MAX_BYTES or message:find("\0", 1, true) then return nil end + for index = 1, #message do if message:byte(index) > 127 then return nil end end + local lines = splitLines(message) + if not lines or #lines ~= 6 or lines[6] ~= "end" then return nil end + local length = lines[2]:match("^length=(%d%d%d%d%d%d%d%d)$") + local bridge = lines[3]:match("^bridge=([%w%-]+)$") + local domain = lines[4]:match("^domain=([%l]+)$") + local unitText = lines[5]:match("^unit=(%-?%d+)$") + local unit = unitText and tonumber(unitText) or nil + if not length or tonumber(length) ~= #message or not bridge or not domain or not integerInRange(unit, 1, 2147483647) then return nil end + if domain ~= "production" and domain ~= "rally" then return nil end + local reason + if lines[1] ~= REQUEST_MAGIC then reason = lines[1]:sub(1, #REQUEST_ROUTE) == REQUEST_ROUTE and "wrong-version" or "malformed" + elseif bridge ~= BRIDGE then reason = "missing-bridge" end + return { bridge=bridge, domain=domain, unit=unit, reason=reason } +end + +local function response(requestHash, domain, unit, status, reason, rows) + local lines = { RESPONSE_MAGIC, "length=00000000", "bridge=" .. BRIDGE, "request-sha256=" .. requestHash, + "status=" .. status, "domain=" .. domain, "unit=" .. tostring(unit) } + if reason then lines[#lines + 1] = "reason=" .. reason end + lines[#lines + 1] = "count=" .. tostring(#rows) + for _, row in ipairs(rows) do lines[#lines + 1] = "row=" .. row end + lines[#lines + 1] = "end" + local document = table.concat(lines, "\n") .. "\n" + document = document:gsub("length=00000000", string.format("length=%08d", #document), 1) + if #document > MAX_BYTES then return nil end + for _, line in ipairs(lines) do if #line + 1 > MAX_LINE_BYTES then return nil end end + return document +end + +local function unavailable(message, request, reason) + return response(sha256(message), request.domain, request.unit, "unavailable", reason, {}) +end + +local function encodeRows(domain, commands) + if type(commands) ~= "table" then return nil, "malformed" end + if #commands > MAX_ENTRIES then return nil, "overflow" end + local rows, totalParams = {}, 0 + for index = 1, #commands do + local command = commands[index] + local options = type(command) == "table" and command.options or nil + local params = type(command) == "table" and command.params or nil + if type(options) ~= "table" or type(params) ~= "table" + or not integerInRange(command.id, -2147483648, 2147483647) + or not integerInRange(options.coded, 0, 65535) + or not integerInRange(command.tag, -2147483648, 2147483647) then return nil, "malformed" end + if #params > MAX_PARAMS then return nil, "overflow" end + totalParams = totalParams + #params + if totalParams > MAX_TOTAL_PARAMS then return nil, "overflow" end + local encoded = {} + for paramIndex = 1, #params do + local bits, reason = float32Hex(params[paramIndex]) + if not bits then return nil, reason end + encoded[paramIndex] = bits + end + local row = table.concat({domain, tostring(command.id), tostring(options.coded), tostring(command.tag), table.concat(encoded, ",")}, "|") + if #row + 5 > MAX_LINE_BYTES then return nil, "oversize" end + rows[index] = row + end + return rows +end + +function gadget:RecvSkirmishAIMessage(aiTeam, message) + if type(message) ~= "string" or message:sub(1, #REQUEST_ROUTE) ~= REQUEST_ROUTE then return nil end + local request = parseRequest(message) + if not request then return nil end + if request.reason then return unavailable(message, request, request.reason) end + if not Spring.ValidUnitID(request.unit) or Spring.GetUnitTeam(request.unit) ~= aiTeam then + return unavailable(message, request, "wrong-team") + end + local unitDefID = Spring.GetUnitDefID(request.unit) + if not unitDefID or not UnitDefs[unitDefID] or UnitDefs[unitDefID].isFactory ~= true then + return unavailable(message, request, "not-factory") + end + local commands + if request.domain == "production" then commands = Spring.GetFactoryCommands(request.unit, PROBE_ENTRIES) + else commands = Spring.GetUnitCommands(request.unit, PROBE_ENTRIES) end + local rows, reason = encodeRows(request.domain, commands) + if not rows then return unavailable(message, request, reason) end + local result = response(sha256(message), request.domain, request.unit, "ok", nil, rows) + if result then return result end + return unavailable(message, request, "oversize") +end diff --git a/data/barc-stock-observer/modinfo.lua b/data/barc-stock-observer/modinfo.lua new file mode 100644 index 00000000..5b4c57ae --- /dev/null +++ b/data/barc-stock-observer/modinfo.lua @@ -0,0 +1,11 @@ +return { + name = "HighBar BARC Stock Queue Observer", + shortName = "BARCSQO", + version = "1", + mutator = "BARC stock queue observation bridge", + game = "Beyond All Reason", + shortGame = "BAR", + description = "Team-scoped read-only stock production and rally queue observation", + modtype = 1, + depend = { "Beyond All Reason test-29926-0571aa8" }, +} diff --git a/scripts/pack-barc-stock-observer.py b/scripts/pack-barc-stock-observer.py new file mode 100644 index 00000000..13155988 --- /dev/null +++ b/scripts/pack-barc-stock-observer.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Build the BARC stock observer as a deterministic, archive-only game overlay.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import stat +import tempfile +import zipfile + + +ALLOWED = ( + "CONTENT-METADATA.json", + "LuaRules/Gadgets/barc_stock_queue_reader.lua", + "modinfo.lua", +) +FIXED_TIME = (1980, 1, 1, 0, 0, 0) + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source", type=Path, default=Path("data/barc-stock-observer")) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--inventory", type=Path, required=True) + args = parser.parse_args() + source = args.source.resolve(strict=True) + if args.output.suffix != ".sdz": + parser.error("--output must use the .sdz archive extension") + discovered = tuple(sorted(str(path.relative_to(source)).replace(os.sep, "/") for path in source.rglob("*") if path.is_file())) + if discovered != ALLOWED: + raise SystemExit(f"source inventory differs: {discovered!r}") + rows = [] + payloads: dict[str, bytes] = {} + for relative in ALLOWED: + path = source / relative + info = path.lstat() + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + raise SystemExit(f"refusing non-regular source: {relative}") + payloads[relative] = path.read_bytes() + rows.append({"path": relative, "bytes": len(payloads[relative]), "sha256": sha256(payloads[relative])}) + + args.output.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=f".{args.output.name}.", dir=args.output.parent) + os.close(fd) + try: + with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for relative in ALLOWED: + entry = zipfile.ZipInfo(relative, FIXED_TIME) + entry.create_system = 3 + entry.external_attr = (0o100644 << 16) + entry.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(entry, payloads[relative], compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) + archive_bytes = Path(temporary).read_bytes() + os.chmod(temporary, 0o644) + os.replace(temporary, args.output) + finally: + if os.path.exists(temporary): os.unlink(temporary) + + inventory = { + "schema": "barc.stock-observer-package-inventory/1", + "archive": {"name": args.output.name, "bytes": len(archive_bytes), "sha256": sha256(archive_bytes)}, + "files": rows, + } + args.inventory.parent.mkdir(parents=True, exist_ok=True) + args.inventory.write_text(json.dumps(inventory, indent=2) + "\n", encoding="ascii") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/stock-observer/mock_spring.lua b/tests/stock-observer/mock_spring.lua new file mode 100644 index 00000000..fd4c9184 --- /dev/null +++ b/tests/stock-observer/mock_spring.lua @@ -0,0 +1,50 @@ +local requestPath, caseName = arg[1], arg[2] +local file = assert(io.open(requestPath, "rb")) +local request = file:read("*a") +file:close() + +gadget = {} +gadgetHandler = { IsSyncedCode = function() return true end } +UnitDefs = { [7] = { isFactory = caseName ~= "not-factory" } } + +local function command(index, paramCount) + local params = {} + for i = 1, paramCount or 0 do params[i] = 1 end + return { id = -700 - index, options = { coded = index == 1 and 32 or 65535 }, tag = 40 + index, params = params } +end + +local function commands() + if caseName == "empty" or caseName == "wrong-team" or caseName == "not-factory" then return {} end + if caseName == "negative-zero" then return {{id=-710,options={coded=32},tag=41,params={1,-0.0}}} end + if caseName == "nonfinite" then return {{id=-710,options={coded=32},tag=41,params={math.huge}}} end + if caseName == "overflow65" then local out={} for i=1,65 do out[i]=command(i,0) end return out end + if caseName == "entries64" then local out={} for i=1,64 do out[i]=command(i,0) end return out end + if caseName == "params17" then return {command(1,17)} end + if caseName == "params16" then return {command(1,16)} end + if caseName == "total257" then local out={} for i=1,16 do out[i]=command(i,16) end out[17]=command(17,1) return out end + if caseName == "total256" then local out={} for i=1,16 do out[i]=command(i,16) end return out end + if caseName == "bad-command" then return {{id=-710,options={coded=-1},tag=41,params={}}} end + if caseName == "nil-commands" then return nil end + if caseName == "rally" then return {command(1,1)} end + return {command(1,0)} +end + +Spring = { + ValidUnitID = function(unit) return unit == 42 end, + GetUnitTeam = function() return caseName == "wrong-team" and 2 or 1 end, + GetUnitDefID = function() return 7 end, + GetFactoryCommands = function(_, count) + assert(caseName ~= "rally", "production getter used for rally") + assert(count == 65, "missing overflow probe") + return commands() + end, + GetUnitCommands = function(_, count) + assert(caseName == "rally", "rally getter used for production") + assert(count == 65, "missing overflow probe") + return commands() + end, +} + +assert(loadfile("data/barc-stock-observer/LuaRules/Gadgets/barc_stock_queue_reader.lua"))() +local result = gadget:RecvSkirmishAIMessage(1, request) +if result == nil then io.write("__NIL__") else io.write(result) end diff --git a/tests/stock-observer/test_stock_observer.py b/tests/stock-observer/test_stock_observer.py new file mode 100644 index 00000000..77ab8711 --- /dev/null +++ b/tests/stock-observer/test_stock_observer.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +import zipfile + + +ROOT = Path(__file__).resolve().parents[2] +VECTORS = json.loads((ROOT / "contracts/barc-stock-queue-v1/vectors.json").read_text()) +VECTOR = {item["name"]: item for item in VECTORS["vectors"]} +HARNESS = ROOT / "tests/stock-observer/mock_spring.lua" +PACKER = ROOT / "scripts/pack-barc-stock-observer.py" + + +def with_length(lines: list[str]) -> bytes: + document = "\n".join(lines) + "\n" + document = document.replace("length=00000000", f"length={len(document.encode('ascii')):08d}", 1) + return document.encode("ascii") + + +def request(domain="production", unit=42, bridge="barc-stock-queue-reader-v1", version="1") -> bytes: + return with_length([f"BARC_QUEUE_REQUEST/{version}", "length=00000000", f"bridge={bridge}", f"domain={domain}", f"unit={unit}", "end"]) + + +def run_lua(payload: bytes, case: str) -> bytes: + with tempfile.NamedTemporaryFile() as source: + source.write(payload); source.flush() + completed = subprocess.run(["lua", str(HARNESS), source.name, case], cwd=ROOT, check=True, capture_output=True) + return completed.stdout + + +def fields(response: bytes) -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + for line in response.decode("ascii").splitlines()[1:]: + if "=" in line: + key, value = line.split("=", 1); result.setdefault(key, []).append(value) + return result + + +class StockObserverTests(unittest.TestCase): + def test_contract_vectors_are_byte_exact(self): + self.assertEqual(request(), VECTOR["production-request"]["ascii"].encode()) + self.assertEqual(run_lua(request(), "empty"), VECTOR["empty-production"]["ascii"].encode()) + self.assertEqual(run_lua(request(), "negative-zero"), VECTOR["one-production-with-negative-zero"]["ascii"].encode()) + self.assertEqual(run_lua(request(), "wrong-team"), VECTOR["wrong-team-unavailable"]["ascii"].encode()) + + def test_domain_selects_exact_stock_reader_and_preserves_coded_options(self): + response = run_lua(request("rally"), "rally") + parsed = fields(response) + self.assertEqual(parsed["status"], ["ok"]) + self.assertEqual(parsed["domain"], ["rally"]) + self.assertEqual(parsed["row"], ["rally|-701|32|41|3f800000"]) + + def test_team_and_factory_scope_are_closed(self): + self.assertEqual(fields(run_lua(request(), "wrong-team"))["reason"], ["wrong-team"]) + self.assertEqual(fields(run_lua(request(), "not-factory"))["reason"], ["not-factory"]) + + def test_entry_and_parameter_bounds_refuse_without_truncation(self): + for case in ("overflow65", "params17", "total257"): + parsed = fields(run_lua(request(), case)) + self.assertEqual(parsed["status"], ["unavailable"]) + self.assertEqual(parsed["reason"], ["overflow"]) + self.assertEqual(parsed["count"], ["0"]) + + def test_exact_entry_and_parameter_boundaries_are_complete(self): + for case, count in (("entries64", "64"), ("params16", "1"), ("total256", "16")): + parsed = fields(run_lua(request(), case)) + self.assertEqual(parsed["status"], ["ok"]) + self.assertEqual(parsed["count"], [count]) + self.assertEqual(len(parsed["row"]), int(count)) + + def test_missing_or_invalid_stock_command_shape_refuses(self): + for case in ("bad-command", "nil-commands"): + parsed = fields(run_lua(request(), case)) + self.assertEqual(parsed["status"], ["unavailable"]) + self.assertEqual(parsed["reason"], ["malformed"]) + + def test_nonfinite_float_refuses(self): + parsed = fields(run_lua(request(), "nonfinite")) + self.assertEqual(parsed["reason"], ["nonfinite-float"]) + + def test_version_and_bridge_refusals_keep_exact_request_binding(self): + for payload, reason in ((request(version="2"), "wrong-version"), (request(bridge="other"), "missing-bridge")): + parsed = fields(run_lua(payload, "empty")) + self.assertEqual(parsed["reason"], [reason]) + self.assertEqual(parsed["request-sha256"], [hashlib.sha256(payload).hexdigest()]) + self.assertEqual(parsed["domain"], ["production"]); self.assertEqual(parsed["unit"], ["42"]) + + def test_malformed_nul_oversize_and_unknown_domain_have_no_response(self): + malformed = request().replace(b"bridge=", b"unit=", 1) + for payload in (malformed, request() + b"\0", b"BARC_QUEUE_REQUEST/1\n" + b"x" * 8192, request("economy")): + self.assertEqual(run_lua(payload, "empty"), b"__NIL__") + + def test_every_response_is_bounded_ascii_and_self_lengths(self): + for case in ("empty", "negative-zero", "wrong-team", "overflow65", "params17", "total257", "nonfinite"): + response = run_lua(request(), case) + self.assertLessEqual(len(response), 8192); self.assertNotIn(b"\0", response) + self.assertTrue(all(byte < 128 for byte in response)); self.assertTrue(response.endswith(b"end\n")) + parsed = fields(response); self.assertEqual(int(parsed["length"][0]), len(response)) + self.assertTrue(all(len(line) + 1 <= 512 for line in response.splitlines())) + + def test_package_is_deterministic_archive_only_and_pins_base_dependency(self): + metadata = json.loads((ROOT / "data/barc-stock-observer/CONTENT-METADATA.json").read_text()) + self.assertEqual(metadata["baseGameDependency"], "Beyond All Reason test-29926-0571aa8") + before = {p: hashlib.sha256((ROOT / "data/barc-stock-observer" / p).read_bytes()).hexdigest() for p in ( + "CONTENT-METADATA.json", "LuaRules/Gadgets/barc_stock_queue_reader.lua", "modinfo.lua")} + with tempfile.TemporaryDirectory() as temporary: + hashes=[] + for index in (1,2): + archive=Path(temporary)/f"barc-stock-observer-{index}.sdz"; inventory=Path(temporary)/f"inventory-{index}.json" + subprocess.run([sys.executable,str(PACKER),"--source",str(ROOT/"data/barc-stock-observer"),"--output",str(archive),"--inventory",str(inventory)],check=True,cwd=ROOT) + hashes.append(hashlib.sha256(archive.read_bytes()).hexdigest()) + with zipfile.ZipFile(archive) as package: + self.assertEqual(package.namelist(), ["CONTENT-METADATA.json","LuaRules/Gadgets/barc_stock_queue_reader.lua","modinfo.lua"]) + self.assertNotIn("LuaRules/Gadgets/barc_stock_queue_reader.lua", [p.name for p in archive.parent.iterdir()]) + self.assertEqual(hashes[0], hashes[1]) + after = {p: hashlib.sha256((ROOT / "data/barc-stock-observer" / p).read_bytes()).hexdigest() for p in before} + self.assertEqual(before, after) + + +if __name__ == "__main__": unittest.main() From 8d380c6155211f0a8f3005557a275671b6d0ccd3 Mon Sep 17 00:00:00 2001 From: EHotwagner Date: Thu, 1 Oct 2026 09:17:20 +0200 Subject: [PATCH 3/7] Clarify stock queue revision evidence --- contracts/barc-stock-queue-v1/contract.json | 81 ++++++++++++++++++++- contracts/barc-stock-queue-v1/vectors.json | 26 ++++++- proto/highbar/live_control.proto | 7 +- tests/contract/stock_queue_contract_test.py | 24 +++++- 4 files changed, 130 insertions(+), 8 deletions(-) diff --git a/contracts/barc-stock-queue-v1/contract.json b/contracts/barc-stock-queue-v1/contract.json index 01900215..ccf5c2be 100644 --- a/contracts/barc-stock-queue-v1/contract.json +++ b/contracts/barc-stock-queue-v1/contract.json @@ -60,9 +60,84 @@ ], "revisionAlgorithm": { "name": "barc-stock-queue-revision-sha256-u64be-v1", - "input": "canonical ASCII response plus profile, catalogue binding and actor lifetime envelope", - "projection": "first eight SHA-256 bytes as unsigned big-endian uint64", - "zeroRule": "map zero to one" + "preimageMagic": "UTF-8 bytes barc-stock-queue-revision/1 followed by one NUL byte", + "fieldEncoding": "ordered fields: one-byte tag, four-byte unsigned big-endian byte length, then raw value bytes; repeated row tag 14 preserves row order; the preimage is binary and has no terminator", + "stringEncoding": "strict UTF-8; lengths count encoded bytes", + "scalarEncoding": "evidence scheme, tactical revision, actor id and row count are unsigned 32-bit big-endian raw values; catalogue revision and actor lifetime are unsigned 64-bit big-endian raw values", + "byteEncoding": "catalogue id and game content SHA-256 are raw bytes, not hex text", + "rowEncoding": "tag 14 contains the canonical ASCII bridge row payload after row= and before newline; domain,id,options.coded,tag,float32params; no timeout", + "orderedTags": [ + { + "tag": 1, + "field": "evidenceScheme" + }, + { + "tag": 2, + "field": "profile" + }, + { + "tag": 3, + "field": "revision" + }, + { + "tag": 4, + "field": "catalogueId" + }, + { + "tag": 5, + "field": "catalogueRevision" + }, + { + "tag": 6, + "field": "engineVersion" + }, + { + "tag": 7, + "field": "gameName" + }, + { + "tag": 8, + "field": "gameVersion" + }, + { + "tag": 9, + "field": "gameContentSha256" + }, + { + "tag": 10, + "field": "actorId" + }, + { + "tag": 11, + "field": "actorLifetime" + }, + { + "tag": 12, + "field": "domain" + }, + { + "tag": 13, + "field": "rowCount" + }, + { + "tag": 14, + "field": "orderedRow", + "repeated": true + } + ], + "fixedValues": { + "evidenceScheme": 2, + "profile": "barc-live-tactical-stock-v1", + "revision": 2 + }, + "domainValues": [ + "actor-order", + "production", + "rally" + ], + "validation": "all fixed values must match; actor id is any uint32 including zero; catalogue revision and actor lifetime are nonzero; catalogue id and content SHA-256 are exact bound bytes; every row domain equals the envelope domain; unavailable/incomplete observations mint no revision", + "projection": "SHA-256 over the complete binary preimage; first eight digest bytes interpreted as unsigned big-endian uint64", + "zeroRule": "if the projected uint64 is zero, use one" }, "factoryProductionPolicies": { "append": "supported", diff --git a/contracts/barc-stock-queue-v1/vectors.json b/contracts/barc-stock-queue-v1/vectors.json index 6eb99ad7..6944ea75 100644 --- a/contracts/barc-stock-queue-v1/vectors.json +++ b/contracts/barc-stock-queue-v1/vectors.json @@ -30,5 +30,29 @@ "byteLength": 227, "sha256": "63861e8f1682fbd8e8551a24a4c39d1217a8b98e269bd44aceaf266c144acb6e" } - ] + ], + "revisionVector": { + "name": "one-production-bound-revision", + "context": { + "evidenceScheme": 2, + "profile": "barc-live-tactical-stock-v1", + "revision": 2, + "catalogueIdHex": "000102030405060708090a0b0c0d0e0f", + "catalogueRevision": "9007199254741107", + "engineVersion": "2025.06.19", + "gameName": "BAR", + "gameVersion": "test-29926-0571aa8", + "gameContentSha256Hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "actorId": 42, + "actorLifetime": "9007199254741105", + "domain": "production", + "rows": [ + "production|-710|32|41|3f800000,80000000" + ] + }, + "preimageByteLength": 285, + "preimageHex": "626172632d73746f636b2d71756575652d7265766973696f6e2f3100010000000400000002020000001b626172632d6c6976652d746163746963616c2d73746f636b2d76310300000004000000020400000010000102030405060708090a0b0c0d0e0f05000000080020000000000073060000000a323032352e30362e313907000000034241520800000012746573742d32393932362d303537316161380900000020000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0a000000040000002a0b0000000800200000000000710c0000000a70726f64756374696f6e0d00000004000000010e0000002770726f64756374696f6e7c2d3731307c33327c34317c33663830303030302c3830303030303030", + "sha256": "fbd1b79418f394d4e8a8e847c155d43c65e071b76375f9682266453933302c37", + "projectedUint64": "18145486220354098388" + } } diff --git a/proto/highbar/live_control.proto b/proto/highbar/live_control.proto index 9d285aaa..cf3effe6 100644 --- a/proto/highbar/live_control.proto +++ b/proto/highbar/live_control.proto @@ -448,9 +448,10 @@ message NativeActorTacticalMetadata { message NativeObservedQueue { NativeQueueDomain domain = 1; - // Revision covers the complete ordered native tuple - // (type,id,options,tag,timeout,params), even though this wire projection is - // normalized and never grants raw options or arbitrary custom parameters. + // The negotiated evidence scheme defines the revision preimage. Legacy + // FULL_NATIVE_TUPLE_V1 covers (type,id,options,tag,timeout,params). Stock + // STOCK_LUA_SUPPORTED_FIELDS_V1 covers domain,id,options.coded,tag and + // finite float32 params; timeout is unavailable and is never fabricated. uint64 revision = 2; repeated NativeObservedQueueEntry entries = 3; bool complete = 4; diff --git a/tests/contract/stock_queue_contract_test.py b/tests/contract/stock_queue_contract_test.py index ec9eeeab..125c02eb 100755 --- a/tests/contract/stock_queue_contract_test.py +++ b/tests/contract/stock_queue_contract_test.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -import hashlib,json,re,sys +import hashlib,json,re,struct,sys from pathlib import Path repo=Path(__file__).resolve().parents[2] packet=repo/'contracts/barc-stock-queue-v1' @@ -27,8 +27,30 @@ for name in ['empty-production','one-production-with-negative-zero','wrong-team-unavailable']: assert f'request-sha256={vectors["requestSha256"]}' in by[name]['ascii'] assert 'row=production|-710|32|41|3f800000,80000000\n' in by['one-production-with-negative-zero']['ascii'] +revision=vectors['revisionVector'];context=revision['context'] +preimage=bytearray(b'barc-stock-queue-revision/1\0') +def field(tag,value): + preimage.append(tag);preimage.extend(struct.pack('>I',len(value)));preimage.extend(value) +field(1,struct.pack('>I',context['evidenceScheme'])) +field(2,context['profile'].encode('utf-8')) +field(3,struct.pack('>I',context['revision'])) +field(4,bytes.fromhex(context['catalogueIdHex'])) +field(5,struct.pack('>Q',int(context['catalogueRevision']))) +field(6,context['engineVersion'].encode('utf-8')) +field(7,context['gameName'].encode('utf-8')) +field(8,context['gameVersion'].encode('utf-8')) +field(9,bytes.fromhex(context['gameContentSha256Hex'])) +field(10,struct.pack('>I',context['actorId'])) +field(11,struct.pack('>Q',int(context['actorLifetime']))) +field(12,context['domain'].encode('ascii')) +field(13,struct.pack('>I',len(context['rows']))) +for row in context['rows']: field(14,row.encode('ascii')) +digest=hashlib.sha256(preimage).digest() +assert len(preimage)==revision['preimageByteLength'] and preimage.hex()==revision['preimageHex'] +assert digest.hex()==revision['sha256'] and str(int.from_bytes(digest[:8],'big') or 1)==revision['projectedUint64'] proto=(repo/'proto/highbar/live_control.proto').read_text() assert 'NATIVE_QUEUE_EVIDENCE_SCHEME_FULL_NATIVE_TUPLE_V1 = 1;' in proto assert 'NATIVE_QUEUE_EVIDENCE_SCHEME_STOCK_LUA_SUPPORTED_FIELDS_V1 = 2;' in proto assert re.search(r'NativeQueueEvidenceScheme evidence_scheme = 6;',proto) +assert 'timeout is unavailable and is never fabricated' in proto print('stock queue contract vectors: PASS') From 0e0e84fb1d8362b49630afd5a958ba49411b3aac Mon Sep 17 00:00:00 2001 From: EHotwagner Date: Thu, 1 Oct 2026 09:36:02 +0200 Subject: [PATCH 4/7] Add stock Recoil tactical queue reader --- CMakeLists.txt | 34 ++- scripts/build-barc-stock-plugin.sh | 62 ++++++ src/circuit/grpc/CommandDispatch.cpp | 86 +++++++- src/circuit/grpc/CommandDispatch.h | 12 +- src/circuit/grpc/StockQueueReader.cpp | 270 +++++++++++++++++++++++ src/circuit/grpc/StockQueueReader.h | 71 ++++++ src/circuit/grpc/TacticalNativeState.cpp | 147 ++++++++++++ src/circuit/grpc/TacticalNativeState.h | 60 +++++ src/circuit/module/GrpcGatewayModule.cpp | 162 ++++++++++++-- src/circuit/module/GrpcGatewayModule.h | 4 + tests/unit/stock_queue_reader_test.cc | 82 +++++++ tests/unit/tactical_native_state_test.cc | 52 +++++ 12 files changed, 1018 insertions(+), 24 deletions(-) create mode 100755 scripts/build-barc-stock-plugin.sh create mode 100644 src/circuit/grpc/StockQueueReader.cpp create mode 100644 src/circuit/grpc/StockQueueReader.h create mode 100644 tests/unit/stock_queue_reader_test.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index da79607d..ca6c504f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,7 +51,8 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) add_library(highbar_command_queue_test_support STATIC "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit/grpc/CommandQueue.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit/grpc/LiveControlState.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit/grpc/Counters.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit/grpc/Counters.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit/grpc/StockQueueReader.cpp") # The tactical helper lands in the native implementation lane. Keep this # contract checkpoint independently buildable while registering the helper # as soon as the coherent join supplies it. @@ -107,6 +108,17 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) add_test(NAME tactical_native_state_test COMMAND tactical_native_state_test) endif () + if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/unit/stock_queue_reader_test.cc") + add_executable(stock_queue_reader_test + "${CMAKE_CURRENT_SOURCE_DIR}/tests/unit/stock_queue_reader_test.cc" + "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit/grpc/StockQueueReader.cpp") + target_include_directories(stock_queue_reader_test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit") + target_link_libraries(stock_queue_reader_test PRIVATE + GTest::gtest GTest::gtest_main) + add_test(NAME stock_queue_reader_test COMMAND stock_queue_reader_test) + endif () + if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/unit/tactical_feature_capacity_test.cc") add_executable(tactical_feature_capacity_test "${CMAKE_CURRENT_SOURCE_DIR}/tests/unit/tactical_feature_capacity_test.cc") @@ -162,6 +174,13 @@ endif () set(CMAKE_CXX_STANDARD 20) +option(HIGHBAR_STOCK_RECOIL + "Build the stock Recoil 2025.06.19 tactical profile without appended callbacks" OFF) +if (HIGHBAR_STOCK_RECOIL AND HIGHBAR_FACTORY_REPLACEMENT_V1) + message(FATAL_ERROR + "HIGHBAR_STOCK_RECOIL cannot be combined with the custom factory replacement extension") +endif () + set(mySourceDirRel "src/") # Common values are "" or "src" set(additionalSources ${CMAKE_SOURCE_DIR}/rts/System/StringUtil.cpp) set(additionalSources ${additionalSources} ${CMAKE_SOURCE_DIR}/rts/System/Misc/SpringTime.cpp) @@ -186,6 +205,10 @@ else (CIRCUIT_DEBUG) set(additionalCompileFlags "-Wall") # set(additionalCompileFlags "-Wall -fno-omit-frame-pointer -O1") endif (CIRCUIT_DEBUG) +if (HIGHBAR_STOCK_RECOIL) + set(additionalCompileFlags + "${additionalCompileFlags} -DHIGHBAR_STOCK_RECOIL=1") +endif () # set(CMAKE_MODULE_LINKER_FLAGS "${CMAKE_MODULE_LINKER_FLAGS} -static-libgcc -static-libstdc++ -lasan") @@ -307,6 +330,11 @@ if (BUILD_Cpp_AIWRAPPER) set(HIGHBAR_ENGINE_RELEASE_ID "${CMAKE_MATCH_1}") string(REGEX MATCH "sha256[ \t]*=[ \t]*\"([^\"]+)\"" _ "${_highbar_pin_content}") set(HIGHBAR_ENGINE_SHA256 "${CMAKE_MATCH_1}") + if (HIGHBAR_STOCK_RECOIL) + set(HIGHBAR_ENGINE_RELEASE_ID "2025.06.19") + set(HIGHBAR_ENGINE_SHA256 + "e4f63c1a391f9ddfbb4d1da225d9533b1d56c65133687d036422a7380c84e833") + endif () if (NOT HIGHBAR_ENGINE_RELEASE_ID OR NOT HIGHBAR_ENGINE_SHA256) message(FATAL_ERROR "HighBarV3: failed to parse release_id/sha256 from ${HIGHBAR_PIN_FILE}") @@ -362,7 +390,8 @@ constexpr const char* kEngineSha256 = "@HIGHBAR_ENGINE_SHA256@"; add_library(highbar_command_queue_test_support STATIC "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit/grpc/CommandQueue.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit/grpc/LiveControlState.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit/grpc/Counters.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit/grpc/Counters.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit/grpc/StockQueueReader.cpp") if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit/grpc/TacticalNativeState.cpp") target_sources(highbar_command_queue_test_support PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/circuit/grpc/TacticalNativeState.cpp") @@ -454,6 +483,7 @@ constexpr const char* kEngineSha256 = "@HIGHBAR_ENGINE_SHA256@"; OR _test_name STREQUAL "coordinator_command_admission_test" OR _test_name STREQUAL "live_control_state_test" OR _test_name STREQUAL "tactical_native_state_test" + OR _test_name STREQUAL "stock_queue_reader_test" OR _test_name STREQUAL "tactical_feature_capacity_test") target_link_libraries("${_test_name}" PRIVATE highbar_command_queue_test_support) diff --git a/scripts/build-barc-stock-plugin.sh b/scripts/build-barc-stock-plugin.sh new file mode 100755 index 00000000..14a97e3a --- /dev/null +++ b/scripts/build-barc-stock-plugin.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "usage: $0 RECOIL_SOURCE HIGHBAR_SOURCE BUILD_DIRECTORY" >&2 + exit 2 +fi + +recoil_source=$(realpath "$1") +highbar_source=$(realpath "$2") +build_dir=$(realpath -m "$3") +expected_recoil_commit=2639eedac7d1fd67d793ec93ebd27f014f336a14 +expected_recoil_tree=94c1d8ad62b20285b19c1b5f3360a81fbbc54468 +expected_contract_sha=28afbc6703843a617db5e3f59a5fcf55a671c993f0585766f1ab2f71218333dc +cmake_bin=${BARC_CMAKE_BIN:-cmake} +sysroot=${BARC_SYSROOT:-/tmp/barc-native-assets/sysroot/usr} +jobs=${BARC_BUILD_JOBS:-4} + +if (( jobs < 1 || jobs > 8 )); then + echo "BARC_BUILD_JOBS must be between 1 and 8" >&2 + exit 2 +fi +if [[ $(git -C "${recoil_source}" rev-parse HEAD) != "${expected_recoil_commit}" \ + || $(git -C "${recoil_source}" rev-parse HEAD^{tree}) != "${expected_recoil_tree}" ]]; then + echo "Recoil source is not the admitted stock 2639 commit/tree" >&2 + exit 1 +fi +if [[ -n $(git -C "${recoil_source}" status --porcelain --untracked-files=no --ignore-submodules=all) ]]; then + echo "Recoil source has tracked changes" >&2 + exit 1 +fi +if [[ $(realpath "${recoil_source}/AI/Skirmish/BARb") != "${highbar_source}" ]]; then + echo "AI/Skirmish/BARb must resolve to the supplied HighBar source" >&2 + exit 1 +fi +contract_sha=$(sha256sum "${highbar_source}/contracts/barc-stock-queue-v1/contract.json" | cut -d' ' -f1) +if [[ ${contract_sha} != "${expected_contract_sha}" ]]; then + echo "HighBar stock contract does not match the independently accepted bytes" >&2 + exit 1 +fi + +"${cmake_bin}" -S "${recoil_source}" -B "${build_dir}" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DAI_TYPES=NATIVE \ + -DBUILD_spring-headless=OFF \ + -DBUILD_spring-dedicated=OFF \ + -DBUILD_spring-legacy=OFF \ + -DCMAKE_PREFIX_PATH="${sysroot}" \ + -DSDL2_DIR="${sysroot}/lib/x86_64-linux-gnu/cmake/SDL2" \ + -DIL_INCLUDE_DIR="${sysroot}/include/IL" \ + -DIL_LIBRARIES="${sysroot}/lib/x86_64-linux-gnu/libIL.so" \ + -DOPENAL_INCLUDE_DIR="${sysroot}/include" \ + -DOPENAL_LIBRARY="${sysroot}/lib/x86_64-linux-gnu/libopenal.so" \ + -DCMAKE_PROJECT_INCLUDE="${highbar_source}/engine-patches/barc-01.5/gcc16-rmlui.cmake" \ + -DCMAKE_EXE_LINKER_FLAGS=-fuse-ld=gold \ + -DHIGHBAR_STOCK_RECOIL=ON \ + -DHIGHBAR_BUILD_TESTS=ON + +ninja -C "${build_dir}" -j"${jobs}" \ + BARb stock_queue_reader_test tactical_native_state_test +ctest --test-dir "${build_dir}" --output-on-failure \ + -R '^(stock_queue_reader_test|tactical_native_state_test)$' diff --git a/src/circuit/grpc/CommandDispatch.cpp b/src/circuit/grpc/CommandDispatch.cpp index d745124e..48a3e02a 100644 --- a/src/circuit/grpc/CommandDispatch.cpp +++ b/src/circuit/grpc/CommandDispatch.cpp @@ -5,6 +5,7 @@ #include "grpc/CommandDispatch.h" #include "grpc/FactoryProductionPolicy.h" #include "grpc/GrpcLog.h" +#include "grpc/StockQueueReader.h" #include "grpc/TacticalNativeState.h" #include "CircuitAI.h" @@ -28,7 +29,9 @@ #include #include #include +#ifndef HIGHBAR_STOCK_RECOIL #include +#endif #include "spring/SpringCallback.h" #include "spring/SpringEngine.h" #include "spring/SpringMap.h" @@ -38,6 +41,7 @@ #include "Sim/Units/CommandAI/Command.h" #include +#include #include #include #include @@ -68,6 +72,31 @@ std::optional> ReadCurrentTacticalQueue( ::circuit::CCircuitAI* ai, springai::Unit* unit, ::highbar::v1::NativeQueueDomain domain) { if (ai == nullptr || unit == nullptr) return std::nullopt; +#ifdef HIGHBAR_STOCK_RECOIL + if (domain == ::highbar::v1::NATIVE_QUEUE_DOMAIN_FACTORY_PRODUCTION + || domain == ::highbar::v1::NATIVE_QUEUE_DOMAIN_FACTORY_RALLY) { + auto* lua = ai->GetLua(); + if (lua == nullptr) return std::nullopt; + const auto stock_domain = + domain == ::highbar::v1::NATIVE_QUEUE_DOMAIN_FACTORY_PRODUCTION + ? StockQueueDomain::Production : StockQueueDomain::Rally; + const auto read = ReadStockQueue(stock_domain, unit->GetUnitId(), + [&](const char* data, std::size_t size) { + return lua->CallRules(data, static_cast(size)); + }); + if (read.status != StockQueueReadStatus::Complete) return std::nullopt; + std::vector entries; + entries.reserve(read.entries.size()); + for (const auto& entry : read.entries) { + entries.push_back({0, entry.command_id, entry.options, + entry.tag, 0, entry.params}); + } + return entries; + } + if (domain != ::highbar::v1::NATIVE_QUEUE_DOMAIN_ACTOR_ORDER) { + return std::nullopt; + } +#endif auto materialize = [](auto& commands) -> std::optional> { if (commands.size() > kMaximumTacticalQueueEntries) { @@ -83,13 +112,20 @@ std::optional> ReadCurrentTacticalQueue( } entries.push_back({command->GetType(), command->GetId(), static_cast(command->GetOptions()), - command->GetTag(), command->GetTimeOut(), command->GetParams()}); + command->GetTag(), +#ifdef HIGHBAR_STOCK_RECOIL + 0, +#else + command->GetTimeOut(), +#endif + command->GetParams()}); } utils::free_clear(commands); return entries; }; try { + #ifndef HIGHBAR_STOCK_RECOIL auto* engine = ai->GetEngine(); const bool typed_api = engine != nullptr && SupportsRallyQueueApi(engine->GetVersionHash(), @@ -104,6 +140,7 @@ std::optional> ReadCurrentTacticalQueue( auto commands = unit->GetCurrentCommandsByType(queue_type); return materialize(commands); } + #endif if (domain == ::highbar::v1::NATIVE_QUEUE_DOMAIN_FACTORY_RALLY || domain == ::highbar::v1::NATIVE_QUEUE_DOMAIN_UNSPECIFIED) { return std::nullopt; @@ -121,6 +158,8 @@ bool DispatchTacticalCommand(::circuit::CCircuitAI* ai, ::circuit::CCircuitUnit* unit, const ::highbar::v1::NativeTacticalCommand& command, const FeatureLifetimeLedger* feature_lifetimes, + const StockQueueRevisionContext* stock_queue_context, + const std::function& post_control_fence, TacticalDispatchRefusalReason* refusal_reason) { auto refuse = [&](TacticalDispatchRefusalReason reason) { if (refusal_reason != nullptr) *refusal_reason = reason; @@ -147,8 +186,25 @@ bool DispatchTacticalCommand(::circuit::CCircuitAI* ai, required_tag = edit.insert().before_native_tag(); } } - if (!NativeQueueMatchesExpected( - *current_queue, command.expected_queue_revision(), required_tag)) { + auto matches_expected = [&](const std::vector& queue) { +#ifdef HIGHBAR_STOCK_RECOIL + if (stock_queue_context == nullptr) return false; + std::vector stock; + stock.reserve(queue.size()); + for (const auto& entry : queue) { + stock.push_back({entry.command_id, entry.options, entry.tag, entry.params}); + } + if (ComputeStockQueueRevision(*stock_queue_context, stock) + != command.expected_queue_revision()) return false; + return !required_tag.has_value() + || std::any_of(stock.begin(), stock.end(), [&](const auto& entry) { + return entry.tag == *required_tag; + }); +#else + return NativeQueueMatchesExpected(queue,command.expected_queue_revision(),required_tag); +#endif + }; + if (!matches_expected(*current_queue)) { return refuse(TacticalDispatchRefusalReason::kQueueChanged); } auto friendly = [&](const ::highbar::v1::NativeUnitReference& ref) { @@ -164,8 +220,14 @@ bool DispatchTacticalCommand(::circuit::CCircuitAI* ai, // UnitControl could install a player task. const bool already_controlled = task == nullptr || task->GetType() == IUnitTask::Type::PLAYER; - return DispatchAfterTacticalControlFence(already_controlled, + return DispatchAfterTacticalControlAndFinalFence(already_controlled, [&] { return ai->UnitControl(unit, false); }, + [&] { + if (post_control_fence && !post_control_fence()) return false; + const auto final_queue = ReadCurrentTacticalQueue( + ai, native, command.queue_domain()); + return final_queue.has_value() && matches_expected(*final_queue); + }, std::forward(effect)); }; switch (command.action_case()) { @@ -210,12 +272,28 @@ bool DispatchTacticalCommand(::circuit::CCircuitAI* ai, if (body.count() != 1) return false; auto* def = ai->GetCircuitDefSafe(body.definition_id()); if (def == nullptr || !unit->GetCircuitDef()->CanBuild(def)) return false; +#ifdef HIGHBAR_STOCK_RECOIL + const auto policy = + body.queue_policy() == ::highbar::v1::NATIVE_QUEUE_POLICY_APPEND + ? StockFactoryQueuePolicy::Append + : body.queue_policy() == ::highbar::v1::NATIVE_QUEUE_POLICY_REJECT_IF_BUSY + ? StockFactoryQueuePolicy::RejectIfBusy + : StockFactoryQueuePolicy::Replace; + if (!StockFactoryProductionPolicyAllows(policy, current_queue->empty())) { + return false; + } + return dispatch_under_external_control([&] { + unit->CmdBuild(def, native->GetPos(), UNIT_NO_FACING, + TacticalOptions(body.queue_policy())); + }); +#else return DispatchFactoryProductionIfAllowed( body.queue_policy(), current_queue->empty(), [&] { return dispatch_under_external_control([&] { unit->CmdBuild(def, native->GetPos(), UNIT_NO_FACING, 0); }); }); +#endif } case ::highbar::v1::NativeTacticalCommand::kGuard:{auto* target=friendly(command.guard().target());if(target==nullptr||target->IsDead())return false;return dispatch_under_external_control([&]{native->Guard(target->GetUnit(),TacticalOptions(command.guard().queue_policy()));});} case ::highbar::v1::NativeTacticalCommand::kRepair:{auto* target=friendly(command.repair().target());if(target==nullptr||target->IsDead())return false;return dispatch_under_external_control([&]{unit->CmdRepair(target,TacticalOptions(command.repair().queue_policy()));});} diff --git a/src/circuit/grpc/CommandDispatch.h b/src/circuit/grpc/CommandDispatch.h index 2bbdb484..ea4001b2 100644 --- a/src/circuit/grpc/CommandDispatch.h +++ b/src/circuit/grpc/CommandDispatch.h @@ -23,6 +23,7 @@ #include "grpc/TacticalDispatchDiagnostic.h" #include +#include #include namespace circuit { @@ -34,6 +35,7 @@ class CEnemyInfo; namespace circuit::grpc { class FeatureLifetimeLedger; +struct StockQueueRevisionContext; constexpr int EngineFacingForNativeBuild( ::highbar::v1::NativeBuildFacing facing) { @@ -109,9 +111,11 @@ bool DispatchCommand(::circuit::CCircuitAI* ai, // `refusal_reason` is meaningful only when dispatch returns false. It feeds // the existing opt-in coordinator trace with a fixed, payload-free reason label. bool DispatchTacticalCommand(::circuit::CCircuitAI* ai, - ::circuit::CCircuitUnit* unit, - const ::highbar::v1::NativeTacticalCommand& command, - const FeatureLifetimeLedger* feature_lifetimes, - TacticalDispatchRefusalReason* refusal_reason = nullptr); + ::circuit::CCircuitUnit* unit, + const ::highbar::v1::NativeTacticalCommand& command, + const FeatureLifetimeLedger* feature_lifetimes, + const StockQueueRevisionContext* stock_queue_context, + const std::function& post_control_fence, + TacticalDispatchRefusalReason* refusal_reason = nullptr); } // namespace circuit::grpc diff --git a/src/circuit/grpc/StockQueueReader.cpp b/src/circuit/grpc/StockQueueReader.cpp new file mode 100644 index 00000000..92a7a338 --- /dev/null +++ b/src/circuit/grpc/StockQueueReader.cpp @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include "grpc/StockQueueReader.h" + +#include +#include +#include +#include +#include +#include + +namespace circuit::grpc { +namespace { + +constexpr std::array kSha256Constants = { + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2}; + +std::array ComputeSha256(const std::string& input) { + auto rotate_right = [](std::uint32_t value, int count) { + return (value >> count) | (value << (32 - count)); + }; + std::vector data(input.begin(), input.end()); + const std::uint64_t bit_count = static_cast(data.size()) * 8; + data.push_back(0x80); + while ((data.size() % 64) != 56) data.push_back(0); + for (int index = 7; index >= 0; --index) { + data.push_back(static_cast(bit_count >> (index * 8))); + } + std::uint32_t hash[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, + 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19}; + for (std::size_t offset = 0; offset < data.size(); offset += 64) { + std::uint32_t words[64]{}; + for (int index = 0; index < 16; ++index) { + words[index] = (static_cast(data[offset + index * 4]) << 24) + | (static_cast(data[offset + index * 4 + 1]) << 16) + | (static_cast(data[offset + index * 4 + 2]) << 8) + | data[offset + index * 4 + 3]; + } + for (int index = 16; index < 64; ++index) { + const auto s0 = rotate_right(words[index - 15], 7) + ^ rotate_right(words[index - 15], 18) ^ (words[index - 15] >> 3); + const auto s1 = rotate_right(words[index - 2], 17) + ^ rotate_right(words[index - 2], 19) ^ (words[index - 2] >> 10); + words[index] = words[index - 16] + s0 + words[index - 7] + s1; + } + std::uint32_t a = hash[0], b = hash[1], c = hash[2], d = hash[3]; + std::uint32_t e = hash[4], f = hash[5], g = hash[6], h = hash[7]; + for (int index = 0; index < 64; ++index) { + const auto s1 = rotate_right(e, 6) ^ rotate_right(e, 11) ^ rotate_right(e, 25); + const auto choose = (e & f) ^ ((~e) & g); + const auto first = h + s1 + choose + kSha256Constants[index] + words[index]; + const auto s0 = rotate_right(a, 2) ^ rotate_right(a, 13) ^ rotate_right(a, 22); + const auto majority = (a & b) ^ (a & c) ^ (b & c); + const auto second = s0 + majority; + h = g; g = f; f = e; e = d + first; + d = c; c = b; b = a; a = first + second; + } + hash[0] += a; hash[1] += b; hash[2] += c; hash[3] += d; + hash[4] += e; hash[5] += f; hash[6] += g; hash[7] += h; + } + std::array output{}; + for (int word = 0; word < 8; ++word) { + for (int byte = 0; byte < 4; ++byte) { + output[word * 4 + byte] = + static_cast(hash[word] >> (24 - byte * 8)); + } + } + return output; +} + +bool SevenBitAsciiWithoutNul(const std::string& value) { + for (unsigned char byte : value) if (byte == 0 || byte > 0x7f) return false; + return true; +} + +template +bool ParseInteger(std::string_view value, T* output) { + if (value.empty()) return false; + T parsed{}; + const auto result = std::from_chars(value.data(), value.data() + value.size(), parsed); + if (result.ec != std::errc{} || result.ptr != value.data() + value.size()) return false; + *output = parsed; + return true; +} + +bool SplitExact(std::string_view value, char separator, + std::vector* output) { + output->clear(); + std::size_t start = 0; + while (start <= value.size()) { + const auto end = value.find(separator, start); + output->push_back(value.substr(start, end == std::string_view::npos + ? value.size() - start : end - start)); + if (end == std::string_view::npos) break; + start = end + 1; + } + return true; +} + +bool ParseFloatBits(std::string_view value, float* output) { + if (value.size() != 8) return false; + std::uint32_t bits = 0; + for (char character : value) { + bits <<= 4; + if (character >= '0' && character <= '9') bits |= character - '0'; + else if (character >= 'a' && character <= 'f') bits |= character - 'a' + 10; + else return false; + } + const float parsed = std::bit_cast(bits); + if (!std::isfinite(parsed)) return false; + *output = parsed; + return true; +} + +std::vector Lines(const std::string& value) { + std::vector result; + std::size_t start = 0; + while (start < value.size()) { + const auto end = value.find('\n', start); + if (end == std::string::npos) return {}; + result.emplace_back(value.data() + start, end - start); + start = end + 1; + } + return result; +} + +bool Field(std::string_view line, std::string_view name, std::string_view* value) { + if (line.size() <= name.size() + 1 || line.substr(0, name.size()) != name + || line[name.size()] != '=') return false; + *value = line.substr(name.size() + 1); + return true; +} + +} // namespace + +const char* StockQueueDomainName(StockQueueDomain domain) { + return domain == StockQueueDomain::Production ? "production" : "rally"; +} + +std::string Sha256Hex(const std::string& value) { + static constexpr char kHex[] = "0123456789abcdef"; + const auto bytes = Sha256Digest(value); + std::string output; + output.reserve(64); + for (auto byte : bytes) { + output.push_back(kHex[byte >> 4]); + output.push_back(kHex[byte & 15]); + } + return output; +} + +std::array Sha256Digest(const std::string& value) { + return ComputeSha256(value); +} + +std::string BuildStockQueueRequest(StockQueueDomain domain, + std::int32_t unit_id) { + if (unit_id < 0) return {}; + std::string request = "BARC_QUEUE_REQUEST/1\nlength=00000000\nbridge="; + request += kStockQueueBridgeId; + request += "\ndomain="; + request += StockQueueDomainName(domain); + request += "\nunit=" + std::to_string(unit_id) + "\nend\n"; + if (request.size() > kStockQueueMaximumBytes || request.size() > 99999999) { + return {}; + } + const auto length = std::to_string(request.size()); + request.replace(28 + 8 - length.size(), length.size(), length); + return request; +} + +StockQueueReadResult ParseStockQueueResponse(const std::string& response, + const std::string& exact_request, StockQueueDomain expected_domain, + std::int32_t expected_unit_id) { + StockQueueReadResult result; + result.domain = expected_domain; + result.unit_id = expected_unit_id; + if (response.empty() || response.size() > kStockQueueMaximumBytes + || !SevenBitAsciiWithoutNul(response) || response.back() != '\n') return result; + const auto lines = Lines(response); + if (lines.size() < 8 || lines[0] != "BARC_QUEUE_RESPONSE/1" + || lines.back() != "end") return result; + for (auto line : lines) { + if (line.size() > kStockQueueMaximumLineBytes) return result; + } + std::string_view field; + if (!Field(lines[1], "length", &field) || field.size() != 8) return result; + std::size_t declared = 0; + if (!ParseInteger(field, &declared) || declared != response.size()) return result; + if (!Field(lines[2], "bridge", &field) || field != kStockQueueBridgeId) return result; + if (!Field(lines[3], "request-sha256", &field) + || field != Sha256Hex(exact_request)) return result; + std::string_view status; + if (!Field(lines[4], "status", &status) + || (status != "ok" && status != "unavailable")) return result; + if (!Field(lines[5], "domain", &field) + || field != StockQueueDomainName(expected_domain)) return result; + std::int32_t unit = -1; + if (!Field(lines[6], "unit", &field) || !ParseInteger(field, &unit) + || unit != expected_unit_id) return result; + std::size_t cursor = 7; + if (status == "unavailable") { + if (lines.size() != 10 || !Field(lines[cursor++], "reason", &field) + || field.empty() || field.size() > 64) return result; + result.unavailable_reason = std::string(field); + } + std::size_t count = 0; + if (!Field(lines[cursor++], "count", &field) || !ParseInteger(field, &count) + || count > kStockQueueMaximumEntries) return result; + if (status == "unavailable") { + if (count != 0 || cursor + 1 != lines.size()) return result; + result.status = StockQueueReadStatus::Unavailable; + result.canonical_response = response; + return result; + } + if (lines.size() != cursor + count + 1) return result; + result.entries.reserve(count); + std::size_t total_parameters = 0; + for (std::size_t index = 0; index < count; ++index) { + if (!Field(lines[cursor++], "row", &field)) return StockQueueReadResult{}; + std::vector parts; + SplitExact(field, '|', &parts); + if (parts.size() != 5 || parts[0] != StockQueueDomainName(expected_domain)) { + return StockQueueReadResult{}; + } + StockQueueEntry entry; + if (!ParseInteger(parts[1], &entry.command_id) + || !ParseInteger(parts[2], &entry.options) + || !ParseInteger(parts[3], &entry.tag)) return StockQueueReadResult{}; + if (!parts[4].empty()) { + std::vector parameters; + SplitExact(parts[4], ',', ¶meters); + if (parameters.size() > kStockQueueMaximumParametersPerEntry) { + return StockQueueReadResult{}; + } + for (auto encoded : parameters) { + float value = 0; + if (!ParseFloatBits(encoded, &value)) return StockQueueReadResult{}; + entry.params.push_back(value); + } + } + total_parameters += entry.params.size(); + if (total_parameters > kStockQueueMaximumParameters) return StockQueueReadResult{}; + result.entries.push_back(std::move(entry)); + } + result.status = StockQueueReadStatus::Complete; + result.canonical_response = response; + return result; +} + +StockQueueReadResult ReadStockQueue(StockQueueDomain domain,std::int32_t unit_id, + const StockQueueCallRules& call_rules) { + if (!call_rules) return {}; + const auto request = BuildStockQueueRequest(domain, unit_id); + if (request.empty()) return {}; + try { + return ParseStockQueueResponse(call_rules(request.data(), request.size()), + request, domain, unit_id); + } catch (...) { + return {}; + } +} + +} // namespace circuit::grpc diff --git a/src/circuit/grpc/StockQueueReader.h b/src/circuit/grpc/StockQueueReader.h new file mode 100644 index 00000000..9af5d9f7 --- /dev/null +++ b/src/circuit/grpc/StockQueueReader.h @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace circuit::grpc { + +inline constexpr const char* kStockQueueBridgeId = + "barc-stock-queue-reader-v1"; +inline constexpr std::size_t kStockQueueMaximumBytes = 8192; +inline constexpr std::size_t kStockQueueMaximumEntries = 64; +inline constexpr std::size_t kStockQueueMaximumParametersPerEntry = 16; +inline constexpr std::size_t kStockQueueMaximumParameters = 256; +inline constexpr std::size_t kStockQueueMaximumLineBytes = 512; + +enum class StockQueueDomain { + Production, + Rally, +}; + +struct StockQueueEntry { + std::int32_t command_id = 0; + std::uint16_t options = 0; + std::int32_t tag = 0; + std::vector params; +}; + +enum class StockQueueReadStatus { + Complete, + Unavailable, + Malformed, +}; + +struct StockQueueReadResult { + StockQueueReadStatus status = StockQueueReadStatus::Malformed; + StockQueueDomain domain = StockQueueDomain::Production; + std::int32_t unit_id = -1; + std::string unavailable_reason; + std::string canonical_response; + std::vector entries; +}; + +using StockQueueCallRules = + std::function; + +const char* StockQueueDomainName(StockQueueDomain domain); +std::string BuildStockQueueRequest(StockQueueDomain domain, + std::int32_t unit_id); +std::array Sha256Digest(const std::string& value); +std::string Sha256Hex(const std::string& value); + +StockQueueReadResult ParseStockQueueResponse( + const std::string& response, + const std::string& exact_request, + StockQueueDomain expected_domain, + std::int32_t expected_unit_id); + +// This is the one bridge reader used for both observation and the final +// post-control-acquisition fence. Callers supply the actual LuaRules callback; +// no retry, paging, fallback or mutable cache is hidden here. +StockQueueReadResult ReadStockQueue( + StockQueueDomain domain, + std::int32_t unit_id, + const StockQueueCallRules& call_rules); + +} // namespace circuit::grpc diff --git a/src/circuit/grpc/TacticalNativeState.cpp b/src/circuit/grpc/TacticalNativeState.cpp index 2f3e3791..32c2bcb1 100644 --- a/src/circuit/grpc/TacticalNativeState.cpp +++ b/src/circuit/grpc/TacticalNativeState.cpp @@ -2,6 +2,7 @@ #include "grpc/TacticalNativeState.h" #include +#include #include #include #include @@ -38,6 +39,91 @@ void AdvanceLifetime(std::uint64_t* lifetime) { if (*lifetime == 0) ++*lifetime; } +void AddU32Be(std::string* output, std::uint32_t value) { + for (int shift : {24, 16, 8, 0}) + output->push_back(static_cast(value >> shift)); +} + +void AddU64Be(std::string* output, std::uint64_t value) { + for (int shift : {56, 48, 40, 32, 24, 16, 8, 0}) + output->push_back(static_cast(value >> shift)); +} + +void AddField(std::string* output, unsigned char tag, + const std::string& value) { + output->push_back(static_cast(tag)); + AddU32Be(output, static_cast(value.size())); + output->append(value); +} + +std::string U32Bytes(std::uint32_t value) { + std::string result; + AddU32Be(&result, value); + return result; +} + +std::string U64Bytes(std::uint64_t value) { + std::string result; + AddU64Be(&result, value); + return result; +} + +bool StrictUtf8(const std::string& value) { + // Bound identities are produced by protobuf/engine strings. Reject NUL and + // malformed UTF-8 rather than hashing different replacement behavior. + std::size_t index = 0; + while (index < value.size()) { + const auto lead = static_cast(value[index]); + if (lead == 0) return false; + std::size_t count = 0; + std::uint32_t code = 0; + if (lead < 0x80) { ++index; continue; } + if ((lead & 0xe0) == 0xc0) { + count = 1; code = lead & 0x1f; + if (code < 2) return false; + } else if ((lead & 0xf0) == 0xe0) { + count = 2; code = lead & 0x0f; + } else if ((lead & 0xf8) == 0xf0) { + count = 3; code = lead & 0x07; + if (code > 4) return false; + } + else return false; + if (index + count >= value.size()) return false; + for (std::size_t offset = 1; offset <= count; ++offset) { + const auto next = static_cast(value[index + offset]); + if ((next & 0xc0) != 0x80) return false; + code = (code << 6) | (next & 0x3f); + } + if ((count == 2 && code < 0x800) || (count == 3 && code < 0x10000) + || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return false; + index += count + 1; + } + return true; +} + +std::string HexFloat(std::uint32_t bits) { + static constexpr char kHex[] = "0123456789abcdef"; + std::string result(8, '0'); + for (int index = 7; index >= 0; --index) { + result[index] = kHex[bits & 15]; + bits >>= 4; + } + return result; +} + +bool CanonicalStockRow(const std::string& domain, + const StockQueueEntry& entry, std::string* output) { + if (entry.params.size() > kStockQueueMaximumParametersPerEntry) return false; + *output = domain + "|" + std::to_string(entry.command_id) + "|" + + std::to_string(entry.options) + "|" + std::to_string(entry.tag) + "|"; + for (std::size_t index = 0; index < entry.params.size(); ++index) { + if (!std::isfinite(entry.params[index])) return false; + if (index > 0) output->push_back(','); + output->append(HexFloat(std::bit_cast(entry.params[index]))); + } + return output->size() <= kStockQueueMaximumLineBytes - 4; +} + } // namespace bool TacticalQueueEvidenceMatches(const char* profile, @@ -66,12 +152,73 @@ bool StockFactoryProductionPolicyAllows(StockFactoryQueuePolicy policy, return false; } +std::string BuildStockQueueRevisionPreimage( + const StockQueueRevisionContext& context, + const std::vector& entries) { + if (context.profile != kStockTacticalProfile + || context.revision != kStockTacticalRevision + || context.evidence_scheme != QueueEvidenceScheme::StockLuaSupportedFieldsV1 + || context.catalogue_id.empty() || context.catalogue_revision == 0 + || context.game_content_sha256.size() != 32 || context.actor_lifetime == 0 + || (context.domain != "actor-order" && context.domain != "production" + && context.domain != "rally") + || !StrictUtf8(context.profile) || !StrictUtf8(context.engine_version) + || !StrictUtf8(context.game_name) || !StrictUtf8(context.game_version) + || entries.size() > kStockQueueMaximumEntries) return {}; + std::vector rows; + rows.reserve(entries.size()); + std::size_t parameter_count = 0; + for (const auto& entry : entries) { + parameter_count += entry.params.size(); + if (parameter_count > kStockQueueMaximumParameters) return {}; + std::string row; + if (!CanonicalStockRow(context.domain, entry, &row)) return {}; + rows.push_back(std::move(row)); + } + std::string output("barc-stock-queue-revision/1\0",28); + AddField(&output, 1, U32Bytes(static_cast(context.evidence_scheme))); + AddField(&output, 2, context.profile); + AddField(&output, 3, U32Bytes(context.revision)); + AddField(&output, 4, context.catalogue_id); + AddField(&output, 5, U64Bytes(context.catalogue_revision)); + AddField(&output, 6, context.engine_version); + AddField(&output, 7, context.game_name); + AddField(&output, 8, context.game_version); + AddField(&output, 9, context.game_content_sha256); + AddField(&output, 10, U32Bytes(context.actor_id)); + AddField(&output, 11, U64Bytes(context.actor_lifetime)); + AddField(&output, 12, context.domain); + AddField(&output, 13, U32Bytes(static_cast(rows.size()))); + for (const auto& row : rows) AddField(&output, 14, row); + return output; +} + +std::uint64_t ComputeStockQueueRevision( + const StockQueueRevisionContext& context, + const std::vector& entries) { + const auto preimage = BuildStockQueueRevisionPreimage(context, entries); + if (preimage.empty()) return 0; + const auto digest = Sha256Digest(preimage); + std::uint64_t revision = 0; + for (std::size_t index = 0; index < 8; ++index) { + revision = (revision << 8) | digest[index]; + } + return revision == 0 ? 1 : revision; +} + bool SupportsRallyQueueApi(const char* hash, const char* additional) { return hash != nullptr && additional != nullptr && std::strcmp(hash, kRallyQueueEngineHash) == 0 && std::strcmp(additional, kRallyQueueEngineAdditional) == 0; } +bool SupportsStockRecoilProfile(const char* major, const char* hash, + const char* branch, const char* additional) { + return major != nullptr && hash != nullptr && branch != nullptr + && additional != nullptr && std::strcmp(major, "2025") == 0 + && hash[0] == '\0' && branch[0] == '\0' && additional[0] == '\0'; +} + FeatureLifetimeLedger::Entry* FeatureLifetimeLedger::Find(std::uint32_t id) { for (auto& item : entries_) { if (item.first == id) return &item.second; diff --git a/src/circuit/grpc/TacticalNativeState.h b/src/circuit/grpc/TacticalNativeState.h index 45c058cc..67c375d8 100644 --- a/src/circuit/grpc/TacticalNativeState.h +++ b/src/circuit/grpc/TacticalNativeState.h @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-2.0-only #pragma once +#include "grpc/StockQueueReader.h" + #include #include #include @@ -16,6 +18,15 @@ inline constexpr std::uint32_t kFullTupleTacticalRevision = 1; inline constexpr const char* kStockTacticalProfile = "barc-live-tactical-stock-v1"; inline constexpr std::uint32_t kStockTacticalRevision = 2; +#ifdef HIGHBAR_STOCK_RECOIL +inline constexpr bool kStockTacticalBuild = true; +inline constexpr const char* kConfiguredTacticalProfile = kStockTacticalProfile; +inline constexpr std::uint32_t kConfiguredTacticalRevision = kStockTacticalRevision; +#else +inline constexpr bool kStockTacticalBuild = false; +inline constexpr const char* kConfiguredTacticalProfile = kFullTupleTacticalProfile; +inline constexpr std::uint32_t kConfiguredTacticalRevision = kFullTupleTacticalRevision; +#endif enum class QueueEvidenceScheme : std::uint32_t { Unspecified = 0, @@ -39,6 +50,32 @@ enum class StockFactoryQueuePolicy : std::uint32_t { bool StockFactoryProductionPolicyAllows(StockFactoryQueuePolicy policy, bool observed_empty); +struct StockQueueRevisionContext { + std::string profile; + std::uint32_t revision = 0; + QueueEvidenceScheme evidence_scheme = QueueEvidenceScheme::Unspecified; + std::string catalogue_id; + std::uint64_t catalogue_revision = 0; + std::string engine_version; + std::string game_name; + std::string game_version; + std::string game_content_sha256; + std::uint32_t actor_id = 0; + std::uint64_t actor_lifetime = 0; + std::string domain; +}; + +// Binary, length-delimited preimage frozen by +// contracts/barc-stock-queue-v1/contract.json. Empty means the exact stock +// binding or a row is invalid; incomplete/unavailable observations never mint +// a revision. +std::string BuildStockQueueRevisionPreimage( + const StockQueueRevisionContext& context, + const std::vector& entries); +std::uint64_t ComputeStockQueueRevision( + const StockQueueRevisionContext& context, + const std::vector& entries); + // External tactical orders and Circuit's autonomous task scheduler share the // same Spring command queue. The ownership transition must happen before the // accepted effect is emitted so a later autonomous update cannot insert an @@ -54,6 +91,22 @@ bool DispatchAfterTacticalControlFence(bool already_controlled, return true; } +// Stock queue evidence must be read again after Circuit has yielded control. +// Keep that final fence between control acquisition and the effect so a stale +// observation can never become an accepted command. +template +bool DispatchAfterTacticalControlAndFinalFence(bool already_controlled, + AcquireControl&& acquire_control, FinalFence&& final_fence, + DispatchEffect&& dispatch_effect) { + if (!already_controlled + && !std::forward(acquire_control)()) { + return false; + } + if (!std::forward(final_fence)()) return false; + std::forward(dispatch_effect)(); + return true; +} + inline constexpr const char* kRallyQueueEngineHash = "7555c83"; inline constexpr const char* kRallyQueueEngineAdditional = "BARC-01.5-rally-api-v1 Headless"; @@ -64,6 +117,13 @@ inline constexpr const char* kRallyQueueEngineAdditional = // callback table is out-of-bounds. bool SupportsRallyQueueApi(const char* hash, const char* additional); +// Stock release callbacks expose no SCM hash/branch/additional suffix. This +// closed tuple prevents a custom/development artifact from negotiating the +// stock profile; the external artifact packet additionally binds the full +// executable SHA-256 because the callback ABI does not expose it. +bool SupportsStockRecoilProfile(const char* major, const char* hash, + const char* branch, const char* additional); + // Engine-neutral projections of the Spring command and feature callbacks. // Keeping these types independent of protobuf lets the engine thread take one // coherent sample before a wire representation is selected. diff --git a/src/circuit/module/GrpcGatewayModule.cpp b/src/circuit/module/GrpcGatewayModule.cpp index 209284bf..ea799550 100644 --- a/src/circuit/module/GrpcGatewayModule.cpp +++ b/src/circuit/module/GrpcGatewayModule.cpp @@ -18,6 +18,7 @@ #include "grpc/HighBarService.h" #include "grpc/GrpcLog.h" #include "grpc/RingBuffer.h" +#include "grpc/StockQueueReader.h" #include "grpc/OrderStateTracker.h" #include "grpc/LiveControlState.h" #include "grpc/TacticalNativeState.h" @@ -45,7 +46,9 @@ #include #include #include +#ifndef HIGHBAR_STOCK_RECOIL #include +#endif #include #include #include @@ -586,17 +589,28 @@ void CGrpcGatewayModule::EnsureCoordinatorClientStarted(const char* reason) { capabilities.set_terrain_elevation_available(false); capabilities.set_supports_stop(true); capabilities.set_supports_move(true); capabilities.set_supports_attack_visible_unit(true); capabilities.set_max_reported_units(live_max_reported_units_); - auto* tactical = capabilities.mutable_tactical(); - tactical->set_profile(grpc::kFullTupleTacticalProfile); - tactical->set_revision(grpc::kFullTupleTacticalRevision); - tactical->set_max_catalogue_entries(kTacticalMaxCatalogueEntries); - tactical->set_max_catalogue_page_entries(kTacticalPageEntries); - tactical->set_max_build_options_per_actor(kTacticalMaxBuildOptions); - tactical->set_max_queue_entries_per_actor(kTacticalMaxQueueEntries); - tactical->set_max_feature_references(grpc::kTacticalFeatureCapacity); - tactical->set_max_factory_production_count(1); - tactical->set_max_area_radius_world_units(kTacticalMaxAreaRadius); - tactical->set_max_command_descriptors_per_actor(kTacticalMaxDescriptors); + tactical_profile_available_ = true; +#ifdef HIGHBAR_STOCK_RECOIL + auto* configured_engine = circuit->GetEngine(); + tactical_profile_available_ = configured_engine != nullptr + && grpc::SupportsStockRecoilProfile(configured_engine->GetVersionMajor(), + configured_engine->GetVersionHash(), configured_engine->GetVersionBranch(), + configured_engine->GetVersionAdditional()); +#endif + auto* tactical = tactical_profile_available_ + ? capabilities.mutable_tactical() : nullptr; + if (tactical != nullptr) { + tactical->set_profile(grpc::kConfiguredTacticalProfile); + tactical->set_revision(grpc::kConfiguredTacticalRevision); + tactical->set_max_catalogue_entries(kTacticalMaxCatalogueEntries); + tactical->set_max_catalogue_page_entries(kTacticalPageEntries); + tactical->set_max_build_options_per_actor(kTacticalMaxBuildOptions); + tactical->set_max_queue_entries_per_actor(kTacticalMaxQueueEntries); + tactical->set_max_feature_references(grpc::kTacticalFeatureCapacity); + tactical->set_max_factory_production_count(1); + tactical->set_max_area_radius_world_units(kTacticalMaxAreaRadius); + tactical->set_max_command_descriptors_per_actor(kTacticalMaxDescriptors); + } AppendCoordinatorTrace( "live capabilities report match_bytes=" + std::to_string(live_control_state_->MatchIncarnation().size()) + " actors=" + std::to_string(capabilities.max_actor_count()) @@ -615,6 +629,7 @@ void CGrpcGatewayModule::EnsureCoordinatorClientStarted(const char* reason) { } void CGrpcGatewayModule::BuildAndReportTacticalCatalogue() { + if (!tactical_profile_available_) return; if (!coordinator_client_) { TraceTacticalSuppression({grpc::TacticalSuppressionReason::SourceUnavailable, grpc::TacticalSuppressionSource::CoordinatorClient, 0, 1}); @@ -693,6 +708,9 @@ void CGrpcGatewayModule::BuildAndReportTacticalCatalogue() { append_identity(grpc::kEngineReleaseId); append_identity(game_name); append_identity(game_version); for (const auto& definition : native) content_seed += definition.SerializeAsString(); const std::string content_hash = Sha256(content_seed); + tactical_game_name_ = game_name; + tactical_game_version_ = game_version; + tactical_game_content_sha256_ = content_hash; tactical_catalogue_id_ = content_hash.substr(0, 16); tactical_catalogue_revision_ = StableRevision(content_hash + content_seed); tactical_catalogue_complete_ = catalogue_valid && !native.empty(); @@ -710,8 +728,8 @@ void CGrpcGatewayModule::BuildAndReportTacticalCatalogue() { const std::size_t page_count = std::max(1, (native.size()+kTacticalPageEntries-1)/kTacticalPageEntries); for (std::size_t page_index=0; page_indexset_engine_version(grpc::kEngineReleaseId); page.mutable_content()->set_game_name(game_name); page.mutable_content()->set_game_version(game_version); page.mutable_content()->set_game_content_sha256(content_hash); @@ -727,7 +745,7 @@ void CGrpcGatewayModule::BuildAndReportTacticalCatalogue() { void CGrpcGatewayModule::BuildAndReportTacticalSnapshot( const ::highbar::v1::NativeObservationBasis& basis) { - if (!tactical_catalogue_complete_) { + if (!tactical_profile_available_ || !tactical_catalogue_complete_) { TraceTacticalSuppression({grpc::TacticalSuppressionReason::CatalogueIncomplete, grpc::TacticalSuppressionSource::None, 0, 1}); return; @@ -759,10 +777,12 @@ void CGrpcGatewayModule::BuildAndReportTacticalSnapshot( } // Both calls below predate the appended rally callbacks. Do not read // an appended callback-table field until this exact identity is established. +#ifndef HIGHBAR_STOCK_RECOIL auto* engine = circuit->GetEngine(); const bool rally_api_available = engine != nullptr && grpc::SupportsRallyQueueApi(engine->GetVersionHash(), engine->GetVersionAdditional()); +#endif auto* economy_manager = circuit->GetEconomyManager(); std::unique_ptr economy(callback->GetEconomy()); auto* econ = snapshot.mutable_economy(); @@ -889,6 +909,28 @@ void CGrpcGatewayModule::BuildAndReportTacticalSnapshot( std::optional> ordinary_entries; std::optional> production_entries; std::optional> rally_entries; +#ifdef HIGHBAR_STOCK_RECOIL + auto read_stock_bridge = [&](grpc::StockQueueDomain domain) + -> std::optional> { + auto* lua = circuit->GetLua(); + if (lua == nullptr) return std::nullopt; + const auto read = grpc::ReadStockQueue(domain, + static_cast(unit_id), + [&](const char* data, std::size_t size) { + return lua->CallRules(data, static_cast(size)); + }); + if (read.status != grpc::StockQueueReadStatus::Complete) { + return std::nullopt; + } + std::vector result; + result.reserve(read.entries.size()); + for (const auto& entry : read.entries) { + result.push_back({0, entry.command_id, entry.options, + entry.tag, 0, entry.params}); + } + return result; + }; +#else auto read_typed_queue = [&](int queue_type) -> std::optional> { if (!rally_api_available) return std::nullopt; @@ -918,6 +960,28 @@ void CGrpcGatewayModule::BuildAndReportTacticalSnapshot( return std::nullopt; } }; +#endif +#ifdef HIGHBAR_STOCK_RECOIL + if (factory) { + production_entries = read_stock_bridge(grpc::StockQueueDomain::Production); + rally_entries = read_stock_bridge(grpc::StockQueueDomain::Rally); + } else { + auto commands = unit->GetCurrentCommands(); + if (commands.size() <= kTacticalMaxQueueEntries) { + std::vector entries; + entries.reserve(commands.size()); + bool valid = true; + for (auto* command : commands) { + if (command == nullptr) { valid = false; break; } + entries.push_back({command->GetType(), command->GetId(), + static_cast(command->GetOptions()), + command->GetTag(), 0, command->GetParams()}); + } + if (valid) ordinary_entries = std::move(entries); + } + utils::free_clear(commands); + } +#else if (rally_api_available) { if (factory) { production_entries = read_typed_queue(2); @@ -942,6 +1006,7 @@ void CGrpcGatewayModule::BuildAndReportTacticalSnapshot( } utils::free_clear(commands); } +#endif if (factory && rally_entries.has_value()) { auto* rally_descriptor = descriptor_for( ::highbar::v1::NATIVE_TACTICAL_DESCRIPTOR_SET_RALLY); @@ -965,7 +1030,37 @@ void CGrpcGatewayModule::BuildAndReportTacticalSnapshot( queue->set_domain(domain); queue->set_complete(entries.has_value()); if (!entries.has_value()) return; +#ifdef HIGHBAR_STOCK_RECOIL + grpc::StockQueueRevisionContext context; + context.profile = grpc::kStockTacticalProfile; + context.revision = grpc::kStockTacticalRevision; + context.evidence_scheme = + grpc::QueueEvidenceScheme::StockLuaSupportedFieldsV1; + context.catalogue_id = tactical_catalogue_id_; + context.catalogue_revision = tactical_catalogue_revision_; + context.engine_version = grpc::kEngineReleaseId; + context.game_name = tactical_game_name_; + context.game_version = tactical_game_version_; + context.game_content_sha256 = tactical_game_content_sha256_; + context.actor_id = static_cast(unit_id); + context.actor_lifetime = actor_out->actor().lifetime(); + context.domain = domain == ::highbar::v1::NATIVE_QUEUE_DOMAIN_ACTOR_ORDER + ? "actor-order" + : domain == ::highbar::v1::NATIVE_QUEUE_DOMAIN_FACTORY_PRODUCTION + ? "production" : "rally"; + std::vector stock_entries; + stock_entries.reserve(entries->size()); + for (const auto& entry : *entries) { + stock_entries.push_back({entry.command_id, entry.options, + entry.tag, entry.params}); + } + const auto revision = grpc::ComputeStockQueueRevision(context, stock_entries); + if (revision == 0) { queue->set_complete(false); return; } + queue->set_revision(revision); + queue->set_evidence_scheme(::highbar::v1::NATIVE_QUEUE_EVIDENCE_SCHEME_STOCK_LUA_SUPPORTED_FIELDS_V1); +#else queue->set_revision(grpc::ComputeNativeQueueRevision(*entries)); +#endif if (include_repeat && repeat_mode.has_value()) queue->set_repeat(*repeat_mode); for (const auto& command : *entries) { auto* out = queue->add_entries(); @@ -2167,9 +2262,48 @@ void CGrpcGatewayModule::DrainCommandQueue() { } grpc::TacticalDispatchRefusalReason tactical_refusal = grpc::TacticalDispatchRefusalReason::kUnsupportedOrInvalidArm; + grpc::StockQueueRevisionContext stock_context; + const grpc::StockQueueRevisionContext* stock_context_pointer = nullptr; +#ifdef HIGHBAR_STOCK_RECOIL + if (entry.live_tactical_command) { + const auto& tactical = *entry.live_tactical_command; + stock_context.profile = grpc::kStockTacticalProfile; + stock_context.revision = grpc::kStockTacticalRevision; + stock_context.evidence_scheme = + grpc::QueueEvidenceScheme::StockLuaSupportedFieldsV1; + stock_context.catalogue_id = tactical.catalogue_id(); + stock_context.catalogue_revision = tactical.catalogue_revision(); + stock_context.engine_version = grpc::kEngineReleaseId; + stock_context.game_name = tactical_game_name_; + stock_context.game_version = tactical_game_version_; + stock_context.game_content_sha256 = tactical_game_content_sha256_; + stock_context.actor_id = entry.live_actor.id(); + stock_context.actor_lifetime = entry.live_actor.lifetime(); + stock_context.domain = tactical.queue_domain() + == ::highbar::v1::NATIVE_QUEUE_DOMAIN_ACTOR_ORDER + ? "actor-order" + : tactical.queue_domain() + == ::highbar::v1::NATIVE_QUEUE_DOMAIN_FACTORY_PRODUCTION + ? "production" : "rally"; + stock_context_pointer = &stock_context; + } +#endif + auto post_control_fence = [&] { + const auto now = std::chrono::steady_clock::now(); + if (now >= entry.live_basis_deadline + || now >= entry.live_command_deadline + || now >= entry.live_lease_deadline) return false; + auto* current = circuit->GetTeamUnit( + static_cast(entry.live_actor.id())); + return current == fresh_actor && !fresh_actor->IsDead() + && live_control_state_->OwnedLifetime(entry.live_actor.id()) + == entry.live_actor.lifetime(); + }; const bool applied = entry.live_tactical_command ? grpc::DispatchTacticalCommand(circuit, fresh_actor, *entry.live_tactical_command, tactical_feature_lifetimes_.get(), + stock_context_pointer, + post_control_fence, &tactical_refusal) : grpc::DispatchCommand(circuit, fresh_actor, cmd, fresh_target); if (!applied) { diff --git a/src/circuit/module/GrpcGatewayModule.h b/src/circuit/module/GrpcGatewayModule.h index 74837d46..c364493a 100644 --- a/src/circuit/module/GrpcGatewayModule.h +++ b/src/circuit/module/GrpcGatewayModule.h @@ -249,6 +249,10 @@ class CGrpcGatewayModule final : public IModule { std::string tactical_catalogue_id_; std::uint64_t tactical_catalogue_revision_ = 0; bool tactical_catalogue_complete_ = false; + bool tactical_profile_available_ = false; + std::string tactical_game_name_; + std::string tactical_game_version_; + std::string tactical_game_content_sha256_; std::string bound_address_; diff --git a/tests/unit/stock_queue_reader_test.cc b/tests/unit/stock_queue_reader_test.cc new file mode 100644 index 00000000..15f5823d --- /dev/null +++ b/tests/unit/stock_queue_reader_test.cc @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include "grpc/StockQueueReader.h" + +#include +#include + +namespace { +using namespace circuit::grpc; + +constexpr const char* kRequest = + "BARC_QUEUE_REQUEST/1\nlength=00000101\nbridge=barc-stock-queue-reader-v1\n" + "domain=production\nunit=42\nend\n"; +constexpr const char* kComplete = + "BARC_QUEUE_RESPONSE/1\nlength=00000244\nbridge=barc-stock-queue-reader-v1\n" + "request-sha256=b01b44c1eaea587730eae794c0b87e4e05dddecfb508abd295ac1e138a0f5960\n" + "status=ok\ndomain=production\nunit=42\ncount=1\n" + "row=production|-710|32|41|3f800000,80000000\nend\n"; +constexpr const char* kEmpty = + "BARC_QUEUE_RESPONSE/1\nlength=00000200\nbridge=barc-stock-queue-reader-v1\n" + "request-sha256=b01b44c1eaea587730eae794c0b87e4e05dddecfb508abd295ac1e138a0f5960\n" + "status=ok\ndomain=production\nunit=42\ncount=0\nend\n"; +constexpr const char* kUnavailable = + "BARC_QUEUE_RESPONSE/1\nlength=00000227\nbridge=barc-stock-queue-reader-v1\n" + "request-sha256=b01b44c1eaea587730eae794c0b87e4e05dddecfb508abd295ac1e138a0f5960\n" + "status=unavailable\ndomain=production\nunit=42\nreason=wrong-team\ncount=0\nend\n"; + +TEST(StockQueueReader, CanonicalRequestAndCompleteVectorAreExact) { + EXPECT_EQ(BuildStockQueueRequest(StockQueueDomain::Production, 42), kRequest); + EXPECT_EQ(Sha256Hex(kRequest), + "b01b44c1eaea587730eae794c0b87e4e05dddecfb508abd295ac1e138a0f5960"); + EXPECT_EQ(Sha256Hex(kEmpty), + "996539730370dcee4240a0d66c24a94fd9cda77ef5fea3aff07eed90cb7409bb"); + EXPECT_EQ(Sha256Hex(kComplete), + "b9294f24faaad63777f99c44dd6201dabf41ec32772b862d657698d3c5d32ec5"); + EXPECT_EQ(Sha256Hex(kUnavailable), + "63861e8f1682fbd8e8551a24a4c39d1217a8b98e269bd44aceaf266c144acb6e"); + EXPECT_EQ(ParseStockQueueResponse(kEmpty, kRequest, + StockQueueDomain::Production, 42).status, StockQueueReadStatus::Complete); + const auto result=ParseStockQueueResponse(kComplete,kRequest, + StockQueueDomain::Production,42); + ASSERT_EQ(result.status,StockQueueReadStatus::Complete); + ASSERT_EQ(result.entries.size(),1u); + EXPECT_EQ(result.entries[0].command_id,-710); + EXPECT_EQ(result.entries[0].options,32); + EXPECT_EQ(result.entries[0].tag,41); + ASSERT_EQ(result.entries[0].params.size(),2u); + EXPECT_EQ(std::bit_cast(result.entries[0].params[0]),0x3f800000u); + EXPECT_EQ(std::bit_cast(result.entries[0].params[1]),0x80000000u); +} + +TEST(StockQueueReader, UnavailableIsExplicitAndCannotMintRows) { + const auto result=ParseStockQueueResponse(kUnavailable,kRequest, + StockQueueDomain::Production,42); + EXPECT_EQ(result.status,StockQueueReadStatus::Unavailable); + EXPECT_EQ(result.unavailable_reason,"wrong-team"); + EXPECT_TRUE(result.entries.empty()); +} + +TEST(StockQueueReader, RefusesEchoFramingAndBoundViolations) { + auto malformed=[](std::string value){return ParseStockQueueResponse(value,kRequest, + StockQueueDomain::Production,42).status==StockQueueReadStatus::Malformed;}; + std::string value=kComplete;value.replace(value.find("unit=42"),7,"unit=43");EXPECT_TRUE(malformed(value)); + value=kComplete;value.replace(value.find("domain=production"),17,"domain=rally ");EXPECT_TRUE(malformed(value)); + value=kComplete;value.replace(value.find("length=00000244"),15,"length=00000243");EXPECT_TRUE(malformed(value)); + value=kComplete;value[value.find("status=ok")+2]='\0';EXPECT_TRUE(malformed(value)); + value=kComplete;value.replace(value.find("3f800000"),8,"7f800000");EXPECT_TRUE(malformed(value)); + value=kComplete;value.replace(value.find("count=1"),7,"count=65");EXPECT_TRUE(malformed(value)); + value.insert(value.find("end\n"),std::string(kStockQueueMaximumLineBytes+1,'x')+"\n");EXPECT_TRUE(malformed(value)); +} + +TEST(StockQueueReader, CallsLuaExactlyOnceWithoutRetryOrFallback) { + int calls=0;std::string observed; + const auto result=ReadStockQueue(StockQueueDomain::Production,42, + [&](const char* data,std::size_t size){++calls;observed.assign(data,size);return std::string(kComplete);}); + EXPECT_EQ(calls,1);EXPECT_EQ(observed,kRequest); + EXPECT_EQ(result.status,StockQueueReadStatus::Complete); + const auto failed=ReadStockQueue(StockQueueDomain::Production,42, + [&](const char*,std::size_t){++calls;return std::string();}); + EXPECT_EQ(calls,2);EXPECT_EQ(failed.status,StockQueueReadStatus::Malformed); +} + +} // namespace diff --git a/tests/unit/tactical_native_state_test.cc b/tests/unit/tactical_native_state_test.cc index 8af0296b..a803b642 100644 --- a/tests/unit/tactical_native_state_test.cc +++ b/tests/unit/tactical_native_state_test.cc @@ -3,6 +3,7 @@ #include +#include #include namespace { @@ -42,6 +43,57 @@ TEST(TacticalNativeState, StockFactoryReplaceNeverAliasesAppend) { StockFactoryQueuePolicy::Replace, true)); } +TEST(TacticalNativeState, FinalQueueFenceRunsAfterControlAndBeforeEffect) { + std::vector events; + EXPECT_TRUE(DispatchAfterTacticalControlAndFinalFence(false, + [&] { events.push_back(1); return true; }, + [&] { events.push_back(2); return true; }, + [&] { events.push_back(3); })); + EXPECT_EQ(events, (std::vector{1, 2, 3})); + + events.clear(); + EXPECT_FALSE(DispatchAfterTacticalControlAndFinalFence(false, + [&] { events.push_back(1); return true; }, + [&] { events.push_back(2); return false; }, + [&] { events.push_back(3); })); + EXPECT_EQ(events, (std::vector{1, 2})); +} + +TEST(TacticalNativeState, StockProfileRequiresClosedReleaseTuple) { + EXPECT_TRUE(SupportsStockRecoilProfile("2025", "", "", "")); + EXPECT_FALSE(SupportsStockRecoilProfile("2025", "custom", "", "")); + EXPECT_FALSE(SupportsStockRecoilProfile("2026", "", "", "")); + EXPECT_FALSE(SupportsStockRecoilProfile(nullptr, "", "", "")); +} + +StockQueueRevisionContext GoldenStockContext() { + StockQueueRevisionContext context; + context.profile=kStockTacticalProfile;context.revision=kStockTacticalRevision; + context.evidence_scheme=QueueEvidenceScheme::StockLuaSupportedFieldsV1; + for(int value=0;value<16;++value)context.catalogue_id.push_back(static_cast(value)); + context.catalogue_revision=9007199254741107ULL;context.engine_version="2025.06.19"; + context.game_name="BAR";context.game_version="test-29926-0571aa8"; + for(int value=0;value<32;++value)context.game_content_sha256.push_back(static_cast(value)); + context.actor_id=42;context.actor_lifetime=9007199254741105ULL;context.domain="production"; + return context; +} + +TEST(TacticalNativeState, StockRevisionMatchesFrozenGoldenAndEveryBinding) { + const std::vector rows{{-710,32,41,{1.0f,-0.0f}}}; + const auto context=GoldenStockContext();const auto preimage=BuildStockQueueRevisionPreimage(context,rows); + EXPECT_EQ(preimage.size(),285u); + EXPECT_EQ(Sha256Hex(preimage),"fbd1b79418f394d4e8a8e847c155d43c65e071b76375f9682266453933302c37"); + EXPECT_EQ(ComputeStockQueueRevision(context,rows),18145486220354098388ULL); + auto changed=context;changed.actor_lifetime++;EXPECT_NE(ComputeStockQueueRevision(changed,rows),ComputeStockQueueRevision(context,rows)); + changed=context;changed.catalogue_id[0]^=1;EXPECT_NE(ComputeStockQueueRevision(changed,rows),ComputeStockQueueRevision(context,rows)); + changed=context;changed.game_content_sha256[0]^=1;EXPECT_NE(ComputeStockQueueRevision(changed,rows),ComputeStockQueueRevision(context,rows)); + changed=context;changed.domain="rally";EXPECT_NE(ComputeStockQueueRevision(changed,rows),ComputeStockQueueRevision(context,rows)); + changed=context;changed.profile=kFullTupleTacticalProfile;EXPECT_EQ(ComputeStockQueueRevision(changed,rows),0u); + changed=context;changed.evidence_scheme=QueueEvidenceScheme::FullNativeTupleV1;EXPECT_EQ(ComputeStockQueueRevision(changed,rows),0u); + auto reordered=rows;reordered.push_back({-711,0,42,{}});std::reverse(reordered.begin(),reordered.end()); + EXPECT_NE(ComputeStockQueueRevision(context,reordered),ComputeStockQueueRevision(context,rows)); +} + TEST(TacticalNativeState, RallyQueueApiRequiresExactPreexistingVersionIdentity) { EXPECT_TRUE(SupportsRallyQueueApi( kRallyQueueEngineHash, kRallyQueueEngineAdditional)); From 564f84e7beec91cc602a3ecca67a60ea9c23e917 Mon Sep 17 00:00:00 2001 From: EHotwagner Date: Thu, 1 Oct 2026 09:38:10 +0200 Subject: [PATCH 5/7] Test stock queue parser bounds --- tests/unit/stock_queue_reader_test.cc | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/unit/stock_queue_reader_test.cc b/tests/unit/stock_queue_reader_test.cc index 15f5823d..6fdef4f7 100644 --- a/tests/unit/stock_queue_reader_test.cc +++ b/tests/unit/stock_queue_reader_test.cc @@ -24,6 +24,26 @@ constexpr const char* kUnavailable = "request-sha256=b01b44c1eaea587730eae794c0b87e4e05dddecfb508abd295ac1e138a0f5960\n" "status=unavailable\ndomain=production\nunit=42\nreason=wrong-team\ncount=0\nend\n"; +std::string BoundedResponse(std::size_t count, std::size_t parameters_per_row) { + std::string response = + "BARC_QUEUE_RESPONSE/1\nlength=00000000\nbridge=barc-stock-queue-reader-v1\n" + "request-sha256=b01b44c1eaea587730eae794c0b87e4e05dddecfb508abd295ac1e138a0f5960\n" + "status=ok\ndomain=production\nunit=42\ncount=" + std::to_string(count) + "\n"; + for (std::size_t row = 0; row < count; ++row) { + response += "row=production|1|0|" + std::to_string(row) + "|"; + for (std::size_t parameter = 0; parameter < parameters_per_row; ++parameter) { + if (parameter != 0) response += ','; + response += "00000000"; + } + response += '\n'; + } + response += "end\n"; + const auto raw_length = std::to_string(response.size()); + const std::string length(8 - raw_length.size(), '0'); + response.replace(response.find("length=") + 7, 8, length + raw_length); + return response; +} + TEST(StockQueueReader, CanonicalRequestAndCompleteVectorAreExact) { EXPECT_EQ(BuildStockQueueRequest(StockQueueDomain::Production, 42), kRequest); EXPECT_EQ(Sha256Hex(kRequest), @@ -68,6 +88,19 @@ TEST(StockQueueReader, RefusesEchoFramingAndBoundViolations) { value.insert(value.find("end\n"),std::string(kStockQueueMaximumLineBytes+1,'x')+"\n");EXPECT_TRUE(malformed(value)); } +TEST(StockQueueReader, EnforcesEntryAndParameterLimitsAtExactBoundary) { + auto parse = [](const std::string& value) { + return ParseStockQueueResponse(value, kRequest, + StockQueueDomain::Production, 42).status; + }; + EXPECT_EQ(parse(BoundedResponse(64, 0)), StockQueueReadStatus::Complete); + EXPECT_EQ(parse(BoundedResponse(65, 0)), StockQueueReadStatus::Malformed); + EXPECT_EQ(parse(BoundedResponse(1, 16)), StockQueueReadStatus::Complete); + EXPECT_EQ(parse(BoundedResponse(1, 17)), StockQueueReadStatus::Malformed); + EXPECT_EQ(parse(BoundedResponse(16, 16)), StockQueueReadStatus::Complete); + EXPECT_EQ(parse(BoundedResponse(17, 16)), StockQueueReadStatus::Malformed); +} + TEST(StockQueueReader, CallsLuaExactlyOnceWithoutRetryOrFallback) { int calls=0;std::string observed; const auto result=ReadStockQueue(StockQueueDomain::Production,42, From c1471bb2fae0c680f0bdecb62ad3d051b8ef422e Mon Sep 17 00:00:00 2001 From: EHotwagner Date: Thu, 1 Oct 2026 09:40:12 +0200 Subject: [PATCH 6/7] fix: make stock observer Lua float compatible --- .../barc-stock-observer/CONTENT-METADATA.json | 10 +- .../Gadgets/barc_stock_queue_reader.lua | 220 ++++++++++-------- tests/stock-observer/mock_spring.lua | 19 +- .../run_stock_lua51_float_abi.py | 90 +++++++ .../stock_lua51_float_runner.cpp | 50 ++++ tests/stock-observer/test_stock_observer.py | 24 ++ 6 files changed, 312 insertions(+), 101 deletions(-) create mode 100755 tests/stock-observer/run_stock_lua51_float_abi.py create mode 100644 tests/stock-observer/stock_lua51_float_runner.cpp diff --git a/data/barc-stock-observer/CONTENT-METADATA.json b/data/barc-stock-observer/CONTENT-METADATA.json index 740a8286..2413f247 100644 --- a/data/barc-stock-observer/CONTENT-METADATA.json +++ b/data/barc-stock-observer/CONTENT-METADATA.json @@ -2,10 +2,16 @@ "schema": "barc.stock-observer-content/1", "bridge": "barc-stock-queue-reader-v1", "profile": "barc-live-tactical-stock-v1", - "revision": 2, + "revision": 3, "evidenceScheme": "STOCK_LUA_SUPPORTED_FIELDS_V1", "baseGameDependency": "Beyond All Reason test-29926-0571aa8", "baseGameRelease": "2025.06.19", "baseGameHeadlessSha256": "e4f63c1a391f9ddfbb4d1da225d9533b1d56c65133687d036422a7380c84e833", - "stockEngineSourceCommit": "2639eedac7d1fd67d793ec93ebd27f014f336a14" + "stockEngineSourceCommit": "2639eedac7d1fd67d793ec93ebd27f014f336a14", + "stockCompatibility": { + "actorMinimum": 0, + "floatEncoding": "SYNCED_VFS_PACKF32_BYTES", + "requestDigest": "SHA256_BYTE_LIMBS", + "syncedGlobals": "NO_RAW_ACCESS" + } } diff --git a/data/barc-stock-observer/LuaRules/Gadgets/barc_stock_queue_reader.lua b/data/barc-stock-observer/LuaRules/Gadgets/barc_stock_queue_reader.lua index 7551c4e6..3c862412 100644 --- a/data/barc-stock-observer/LuaRules/Gadgets/barc_stock_queue_reader.lua +++ b/data/barc-stock-observer/LuaRules/Gadgets/barc_stock_queue_reader.lua @@ -11,6 +11,8 @@ function gadget:GetInfo() } end +-- Stock AICallback::CallRules selects LuaRules' synced handle. The generic +-- RecvSkirmishAIMessage comment does not change that explicit dispatch route. if not gadgetHandler:IsSyncedCode() then return end @@ -25,83 +27,107 @@ local MAX_ENTRIES = 64 local PROBE_ENTRIES = 65 local MAX_PARAMS = 16 local MAX_TOTAL_PARAMS = 256 -local TWO32 = 4294967296 -local TWO31 = 2147483648 local HEX = "0123456789abcdef" -local function u32(value) - return value % TWO32 -end - -local function pureBand(a, b) +local function byteBand(a, b) local result, place = 0, 1 - a, b = u32(a), u32(b) - for _ = 1, 32 do - local aa, bb = a % 2, b % 2 - if aa == 1 and bb == 1 then result = result + place end + for _ = 1, 8 do + if a % 2 == 1 and b % 2 == 1 then result = result + place end a, b, place = math.floor(a / 2), math.floor(b / 2), place * 2 end return result end -local function pureBxor(a, b) +local function byteBxor(a, b) local result, place = 0, 1 - a, b = u32(a), u32(b) - for _ = 1, 32 do - local aa, bb = a % 2, b % 2 - if aa ~= bb then result = result + place end + for _ = 1, 8 do + if a % 2 ~= b % 2 then result = result + place end a, b, place = math.floor(a / 2), math.floor(b / 2), place * 2 end return result end -local bitlib = rawget(_G, "bit") or rawget(_G, "bit32") -local function band(a, b) - if bitlib then return u32(bitlib.band(a, b)) end - return pureBand(a, b) +local function wordFromHex(value) + local word = {} + for index = 1, 8, 2 do word[#word + 1] = assert(tonumber(value:sub(index, index + 1), 16)) end + return word end -local function bxor2(a, b) - if bitlib then return u32(bitlib.bxor(a, b)) end - return pureBxor(a, b) + +local function wordXor(a, b, c) + local result = {} + for index = 1, 4 do + local value = byteBxor(a[index], b[index]) + result[index] = c and byteBxor(value, c[index]) or value + end + return result end -local function bxor(a, b, c) - local result = bxor2(a, b) - return c == nil and result or bxor2(result, c) + +local function wordAnd(a, b) + return {byteBand(a[1], b[1]), byteBand(a[2], b[2]), byteBand(a[3], b[3]), byteBand(a[4], b[4])} +end + +local function wordNot(a) + return {255 - a[1], 255 - a[2], 255 - a[3], 255 - a[4]} end -local function bnot(a) - if bitlib then return u32(bitlib.bnot(a)) end - return 4294967295 - u32(a) + +local function wordShiftRight(a, count) + local result, whole, bits = {}, math.floor(count / 8), count % 8 + for index = 1, 4 do + local source = index - whole + local value = source >= 1 and math.floor(a[source] / (2 ^ bits)) or 0 + if bits > 0 and source > 1 then value = value + (a[source - 1] % (2 ^ bits)) * (2 ^ (8 - bits)) end + result[index] = value + end + return result end -local function rshift(a, count) - if bitlib then return u32(bitlib.rshift(a, count)) end - return math.floor(u32(a) / (2 ^ count)) + +local function wordShiftLeft(a, count) + local result, whole, bits = {}, math.floor(count / 8), count % 8 + for index = 1, 4 do + local source = index + whole + local value = source <= 4 and (a[source] * (2 ^ bits)) % 256 or 0 + if bits > 0 and source < 4 then value = value + math.floor(a[source + 1] / (2 ^ (8 - bits))) end + result[index] = value + end + return result end -local function ror(a, count) - if bitlib and bitlib.ror then return u32(bitlib.ror(a, count)) end - if bitlib and bitlib.rrotate then return u32(bitlib.rrotate(a, count)) end - return u32(rshift(a, count) + (u32(a) % (2 ^ count)) * (2 ^ (32 - count))) + +local function wordRotateRight(a, count) + return wordXor(wordShiftRight(a, count), wordShiftLeft(a, 32 - count)) end -local function hex32(value) +local function wordAdd(...) + local values, result, carry = {...}, {}, 0 + for index = 4, 1, -1 do + local total = carry + for valueIndex = 1, #values do total = total + values[valueIndex][index] end + result[index], carry = total % 256, math.floor(total / 256) + end + return result +end + +local function wordHex(value) local chars = {} - value = u32(value) - for shift = 28, 0, -4 do - local nibble = math.floor(value / (2 ^ shift)) % 16 - chars[#chars + 1] = HEX:sub(nibble + 1, nibble + 1) + for index = 1, 4 do + local byte = value[index] + chars[#chars + 1] = HEX:sub(math.floor(byte / 16) + 1, math.floor(byte / 16) + 1) + chars[#chars + 1] = HEX:sub((byte % 16) + 1, (byte % 16) + 1) end return table.concat(chars) end -local SHA_K = { - 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, - 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, - 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, - 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, - 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, - 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, - 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, - 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2, +local SHA_K_HEX = { + "428a2f98","71374491","b5c0fbcf","e9b5dba5","3956c25b","59f111f1","923f82a4","ab1c5ed5", + "d807aa98","12835b01","243185be","550c7dc3","72be5d74","80deb1fe","9bdc06a7","c19bf174", + "e49b69c1","efbe4786","0fc19dc6","240ca1cc","2de92c6f","4a7484aa","5cb0a9dc","76f988da", + "983e5152","a831c66d","b00327c8","bf597fc7","c6e00bf3","d5a79147","06ca6351","14292967", + "27b70a85","2e1b2138","4d2c6dfc","53380d13","650a7354","766a0abb","81c2c92e","92722c85", + "a2bfe8a1","a81a664b","c24b8b70","c76c51a3","d192e819","d6990624","f40e3585","106aa070", + "19a4c116","1e376c08","2748774c","34b0bcb5","391c0cb3","4ed8aa4a","5b9cca4f","682e6ff3", + "748f82ee","78a5636f","84c87814","8cc70208","90befffa","a4506ceb","bef9a3f7","c67178f2", } +local SHA_K = {} +for index = 1, #SHA_K_HEX do SHA_K[index] = wordFromHex(SHA_K_HEX[index]) end local function sha256(message) local bytes = {} @@ -109,71 +135,79 @@ local function sha256(message) local bitLength = #bytes * 8 bytes[#bytes + 1] = 0x80 while (#bytes % 64) ~= 56 do bytes[#bytes + 1] = 0 end - local high = math.floor(bitLength / TWO32) - local low = bitLength % TWO32 - for shift = 24, 0, -8 do bytes[#bytes + 1] = math.floor(high / (2 ^ shift)) % 256 end - for shift = 24, 0, -8 do bytes[#bytes + 1] = math.floor(low / (2 ^ shift)) % 256 end + local lengthBytes = {} + for index = 8, 1, -1 do + lengthBytes[index] = bitLength % 256 + bitLength = math.floor(bitLength / 256) + end + for index = 1, 8 do bytes[#bytes + 1] = lengthBytes[index] end - local h = {0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19} + local h = {} + for index, value in ipairs({"6a09e667","bb67ae85","3c6ef372","a54ff53a","510e527f","9b05688c","1f83d9ab","5be0cd19"}) do + h[index] = wordFromHex(value) + end for offset = 1, #bytes, 64 do local w = {} for index = 0, 15 do local base = offset + index * 4 - w[index] = bytes[base] * 16777216 + bytes[base + 1] * 65536 + bytes[base + 2] * 256 + bytes[base + 3] + w[index] = {bytes[base], bytes[base + 1], bytes[base + 2], bytes[base + 3]} end for index = 16, 63 do local x, y = w[index - 15], w[index - 2] - local s0 = bxor(ror(x, 7), ror(x, 18), rshift(x, 3)) - local s1 = bxor(ror(y, 17), ror(y, 19), rshift(y, 10)) - w[index] = u32(w[index - 16] + s0 + w[index - 7] + s1) + local s0 = wordXor(wordRotateRight(x, 7), wordRotateRight(x, 18), wordShiftRight(x, 3)) + local s1 = wordXor(wordRotateRight(y, 17), wordRotateRight(y, 19), wordShiftRight(y, 10)) + w[index] = wordAdd(w[index - 16], s0, w[index - 7], s1) end local a,b,c,d,e,f,g,hh = h[1],h[2],h[3],h[4],h[5],h[6],h[7],h[8] for index = 0, 63 do - local s1 = bxor(ror(e, 6), ror(e, 11), ror(e, 25)) - local choice = bxor(band(e, f), band(bnot(e), g)) - local t1 = u32(hh + s1 + choice + SHA_K[index + 1] + w[index]) - local s0 = bxor(ror(a, 2), ror(a, 13), ror(a, 22)) - local majority = bxor(band(a, b), band(a, c), band(b, c)) - local t2 = u32(s0 + majority) - hh,g,f,e,d,c,b,a = g,f,e,u32(d + t1),c,b,a,u32(t1 + t2) + local s1 = wordXor(wordRotateRight(e, 6), wordRotateRight(e, 11), wordRotateRight(e, 25)) + local choice = wordXor(wordAnd(e, f), wordAnd(wordNot(e), g)) + local t1 = wordAdd(hh, s1, choice, SHA_K[index + 1], w[index]) + local s0 = wordXor(wordRotateRight(a, 2), wordRotateRight(a, 13), wordRotateRight(a, 22)) + local majority = wordXor(wordAnd(a, b), wordAnd(a, c), wordAnd(b, c)) + local t2 = wordAdd(s0, majority) + hh,g,f,e,d,c,b,a = g,f,e,wordAdd(d, t1),c,b,a,wordAdd(t1, t2) end - h[1],h[2],h[3],h[4] = u32(h[1]+a),u32(h[2]+b),u32(h[3]+c),u32(h[4]+d) - h[5],h[6],h[7],h[8] = u32(h[5]+e),u32(h[6]+f),u32(h[7]+g),u32(h[8]+hh) + h[1],h[2],h[3],h[4] = wordAdd(h[1],a),wordAdd(h[2],b),wordAdd(h[3],c),wordAdd(h[4],d) + h[5],h[6],h[7],h[8] = wordAdd(h[5],e),wordAdd(h[6],f),wordAdd(h[7],g),wordAdd(h[8],hh) end local parts = {} - for index = 1, 8 do parts[index] = hex32(h[index]) end + for index = 1, 8 do parts[index] = wordHex(h[index]) end return table.concat(parts) end -local function integerInRange(value, minimum, maximum) - return type(value) == "number" and value == math.floor(value) and value >= minimum and value <= maximum +local function packedHex(bytes, littleEndian) + if type(bytes) ~= "string" or #bytes ~= 4 then return nil end + local chars = {} + if littleEndian then + for index = 4, 1, -1 do chars[#chars + 1] = wordHex({0, 0, 0, bytes:byte(index)}):sub(7, 8) end + else + for index = 1, 4 do chars[#chars + 1] = wordHex({0, 0, 0, bytes:byte(index)}):sub(7, 8) end + end + return table.concat(chars) end -local negativeZeroSupported = (1 / (-0.0)) == -math.huge +local onePacked = VFS.PackF32(1.0) +local oneNativeHex = packedHex(onePacked, false) +local PACK_LITTLE_ENDIAN = oneNativeHex == "0000803f" +local PACK_ENDIAN_VALID = PACK_LITTLE_ENDIAN or oneNativeHex == "3f800000" +local PACK_ZERO_VALID = PACK_ENDIAN_VALID + and packedHex(VFS.PackF32(0.0), PACK_LITTLE_ENDIAN) == "00000000" + and packedHex(VFS.PackF32(-1 / math.huge), PACK_LITTLE_ENDIAN) == "80000000" + local function float32Hex(value) if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then return nil, "nonfinite-float" end - local negative = value < 0 or (value == 0 and negativeZeroSupported and (1 / value) == -math.huge) - local magnitude = math.abs(value) - if magnitude == 0 then - if not negativeZeroSupported then return nil, "negative-zero-unsupported" end - return negative and "80000000" or "00000000" - end - local fraction, exponent = math.frexp(magnitude) - local exponentBits, mantissa - if exponent > -126 then - exponentBits = exponent + 126 - mantissa = math.floor(((fraction * 2 - 1) * 8388608) + 0.5) - if mantissa == 8388608 then exponentBits, mantissa = exponentBits + 1, 0 end - if exponentBits >= 255 then return nil, "nonfinite-float" end - else - exponentBits = 0 - mantissa = math.floor((magnitude * (2 ^ 149)) + 0.5) - if mantissa <= 0 or mantissa >= 8388608 then return nil, "nonfinite-float" end - end - local bits = (negative and TWO31 or 0) + exponentBits * 8388608 + mantissa - return hex32(bits) + if not PACK_ENDIAN_VALID then return nil, "float-pack-unsupported" end + local encoded = packedHex(VFS.PackF32(value), PACK_LITTLE_ENDIAN) + if not encoded then return nil, "float-pack-unsupported" end + if value == 0 and not PACK_ZERO_VALID then return nil, "negative-zero-unsupported" end + return encoded +end + +local function integerInRange(value, minimum, maximum) + return type(value) == "number" and value == math.floor(value) and value >= minimum and value <= maximum end local function splitLines(message) @@ -199,7 +233,7 @@ local function parseRequest(message) local domain = lines[4]:match("^domain=([%l]+)$") local unitText = lines[5]:match("^unit=(%-?%d+)$") local unit = unitText and tonumber(unitText) or nil - if not length or tonumber(length) ~= #message or not bridge or not domain or not integerInRange(unit, 1, 2147483647) then return nil end + if not length or tonumber(length) ~= #message or not bridge or not domain or not integerInRange(unit, 0, 2147483647) then return nil end if domain ~= "production" and domain ~= "rally" then return nil end local reason if lines[1] ~= REQUEST_MAGIC then reason = lines[1]:sub(1, #REQUEST_ROUTE) == REQUEST_ROUTE and "wrong-version" or "malformed" diff --git a/tests/stock-observer/mock_spring.lua b/tests/stock-observer/mock_spring.lua index fd4c9184..89b94e0d 100644 --- a/tests/stock-observer/mock_spring.lua +++ b/tests/stock-observer/mock_spring.lua @@ -1,9 +1,13 @@ local requestPath, caseName = arg[1], arg[2] -local file = assert(io.open(requestPath, "rb")) -local request = file:read("*a") -file:close() +local request = TEST_REQUEST +if not request then + local file = assert(io.open(requestPath, "rb")) + request = file:read("*a") + file:close() +end gadget = {} +VFS = VFS or { PackF32 = function(value) return string.pack("f", value) end } gadgetHandler = { IsSyncedCode = function() return true end } UnitDefs = { [7] = { isFactory = caseName ~= "not-factory" } } @@ -15,7 +19,9 @@ end local function commands() if caseName == "empty" or caseName == "wrong-team" or caseName == "not-factory" then return {} end - if caseName == "negative-zero" then return {{id=-710,options={coded=32},tag=41,params={1,-0.0}}} end + if caseName == "negative-zero" then return {{id=-710,options={coded=32},tag=41,params={1,TEST_NEGATIVE_ZERO or -0.0}}} end + if caseName == "mantissa-low-bit" then return {{id=-710,options={coded=32},tag=41,params={1.0000001192092896}}} end + if caseName == "float-boundaries" then return {{id=-710,options={coded=32},tag=41,params={1.401298464324817e-45,1.1754942106924411e-38,3.4028234663852886e38}}} end if caseName == "nonfinite" then return {{id=-710,options={coded=32},tag=41,params={math.huge}}} end if caseName == "overflow65" then local out={} for i=1,65 do out[i]=command(i,0) end return out end if caseName == "entries64" then local out={} for i=1,64 do out[i]=command(i,0) end return out end @@ -30,8 +36,8 @@ local function commands() end Spring = { - ValidUnitID = function(unit) return unit == 42 end, - GetUnitTeam = function() return caseName == "wrong-team" and 2 or 1 end, + ValidUnitID = function(unit) return unit == 42 or unit == 0 end, + GetUnitTeam = function() return (caseName == "wrong-team" or caseName == "actor0-wrong-team") and 2 or 1 end, GetUnitDefID = function() return 7 end, GetFactoryCommands = function(_, count) assert(caseName ~= "rally", "production getter used for rally") @@ -45,6 +51,7 @@ Spring = { end, } +rawget, rawset, rawequal = nil, nil, nil assert(loadfile("data/barc-stock-observer/LuaRules/Gadgets/barc_stock_queue_reader.lua"))() local result = gadget:RecvSkirmishAIMessage(1, request) if result == nil then io.write("__NIL__") else io.write(result) end diff --git a/tests/stock-observer/run_stock_lua51_float_abi.py b/tests/stock-observer/run_stock_lua51_float_abi.py new file mode 100755 index 00000000..04bbafde --- /dev/null +++ b/tests/stock-observer/run_stock_lua51_float_abi.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Build exact stock Recoil Lua 5.1 with float numbers and run observer regressions.""" +from __future__ import annotations + +import argparse +import concurrent.futures +import hashlib +import json +import pathlib +import subprocess +import tempfile + +EXPECTED_RECOIL = "2639eedac7d1fd67d793ec93ebd27f014f336a14" +ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def request(domain="production", unit=42): + lines = ["BARC_QUEUE_REQUEST/1", "length=00000000", "bridge=barc-stock-queue-reader-v1", + f"domain={domain}", f"unit={unit}", "end"] + value = "\n".join(lines) + "\n" + return value.replace("length=00000000", f"length={len(value.encode()):08d}", 1).encode() + + +def fields(value: bytes): + result = {} + for line in value.decode("ascii").splitlines()[1:]: + if "=" in line: + key, item = line.split("=", 1) + result.setdefault(key, []).append(item) + return result + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--recoil-source", type=pathlib.Path, required=True) + args = parser.parse_args() + source = args.recoil_source.resolve() + revision = subprocess.run(["git", "-C", source, "rev-parse", "HEAD"], check=True, + text=True, capture_output=True).stdout.strip() + if revision != EXPECTED_RECOIL: + raise SystemExit(f"wrong Recoil source: {revision}") + lua = source / "rts/lib/lua" + config = (lua / "include/luaconf.h").read_text() + if "#define LUA_NUMBER\tfloat" not in config or "#define LUA_VERSION_NUM\t501" not in (lua / "include/lua.h").read_text(): + raise SystemExit("stock float Lua 5.1 ABI not present") + sources = sorted((lua / "src").glob("*.cpp")) + [ROOT / "tests/stock-observer/stock_lua51_float_runner.cpp"] + with tempfile.TemporaryDirectory(prefix="bar-stock-lua51-") as temporary: + build = pathlib.Path(temporary) + includes = [f"-I{lua / 'include'}", f"-I{source / 'rts/lib/streflop'}", f"-I{source / 'rts'}"] + common = ["g++", "-std=c++17", "-O2", "-DBUILDING_AI", "-DNOT_USING_STREFLOP", *includes] + def compile_one(item): + output = build / (item.stem + ".o") + subprocess.run([*common, "-c", item, "-o", output], check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + return output + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + objects = list(executor.map(compile_one, sources)) + runner = build / "lua51-float-runner" + subprocess.run(["g++", *objects, "-ldl", "-lm", "-o", runner], check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + harness = ROOT / "tests/stock-observer/mock_spring.lua" + def run(payload, case): + request_path = build / "request.bin" + request_path.write_bytes(payload) + return subprocess.run([runner, request_path, case, harness], cwd=ROOT, check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout + + vectors = json.loads((ROOT / "contracts/barc-stock-queue-v1/vectors.json").read_text())["vectors"] + vector = {item["name"]: item["ascii"].encode() for item in vectors} + canonical = request() + assert canonical == vector["production-request"] + assert run(canonical, "empty") == vector["empty-production"] + assert run(canonical, "negative-zero") == vector["one-production-with-negative-zero"] + assert run(canonical, "wrong-team") == vector["wrong-team-unavailable"] + assert fields(run(canonical, "mantissa-low-bit"))["row"][0].endswith("|3f800001") + assert fields(run(canonical, "float-boundaries"))["row"][0].endswith( + "|00000001,007fffff,7f7fffff") + actor_zero = fields(run(request(unit=0), "actor0")) + assert actor_zero["status"] == ["ok"] and actor_zero["unit"] == ["0"] + actor_zero_wrong = fields(run(request(unit=0), "actor0-wrong-team")) + assert actor_zero_wrong["reason"] == ["wrong-team"] + print(json.dumps({"accepted": True, "luaVersion": "5.1", "luaNumber": "float", + "recoilRevision": revision, "vectors": 4, + "numericVectors": ["3f800001", "00000001", "007fffff", "7f7fffff", "80000000"], + "sourceSha256": hashlib.sha256((ROOT / "data/barc-stock-observer/LuaRules/Gadgets/barc_stock_queue_reader.lua").read_bytes()).hexdigest()}, + sort_keys=True, separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/tests/stock-observer/stock_lua51_float_runner.cpp b/tests/stock-observer/stock_lua51_float_runner.cpp new file mode 100644 index 00000000..0a433046 --- /dev/null +++ b/tests/stock-observer/stock_lua51_float_runner.cpp @@ -0,0 +1,50 @@ +#include +#include +#include +#include +#include + +#include "lua.h" +#include "lauxlib.h" +#include "lualib.h" + +static int PackF32(lua_State* state) { + const float value = static_cast(luaL_checknumber(state, 1)); + char bytes[sizeof(value)]; + static_assert(sizeof(value) == 4, "binary32 float required"); + std::memcpy(bytes, &value, sizeof(value)); + lua_pushlstring(state, bytes, sizeof(bytes)); + return 1; +} + +int main(int argc, char** argv) { + if (argc != 4) return 64; + lua_State* state = luaL_newstate(); + if (state == nullptr) return 65; + luaL_openlibs(state); + std::ifstream input(argv[1], std::ios::binary); + const std::string request((std::istreambuf_iterator(input)), std::istreambuf_iterator()); + if (!input.good() && !input.eof()) { lua_close(state); return 67; } + lua_pushlstring(state, request.data(), request.size()); + lua_setglobal(state, "TEST_REQUEST"); + const float negativeZero = -0.0f; + lua_pushnumber(state, negativeZero); + lua_setglobal(state, "TEST_NEGATIVE_ZERO"); + lua_newtable(state); + lua_pushcfunction(state, PackF32); + lua_setfield(state, -2, "PackF32"); + lua_setglobal(state, "VFS"); + lua_newtable(state); + for (int index = 1; index <= 2; ++index) { + lua_pushstring(state, argv[index]); + lua_rawseti(state, -2, index); + } + lua_setglobal(state, "arg"); + const int status = luaL_dofile(state, argv[3]); + if (status != 0) { + const char* error = lua_tostring(state, -1); + std::fprintf(stderr, "%s\n", error == nullptr ? "lua-error" : error); + } + lua_close(state); + return status == 0 ? 0 : 66; +} diff --git a/tests/stock-observer/test_stock_observer.py b/tests/stock-observer/test_stock_observer.py index 77ab8711..7d857d2e 100644 --- a/tests/stock-observer/test_stock_observer.py +++ b/tests/stock-observer/test_stock_observer.py @@ -44,6 +44,15 @@ def fields(response: bytes) -> dict[str, list[str]]: class StockObserverTests(unittest.TestCase): + def test_restricted_synced_surface_uses_packf32_and_no_wide_word_arithmetic(self): + source = (ROOT / "data/barc-stock-observer/LuaRules/Gadgets/barc_stock_queue_reader.lua").read_text() + self.assertNotIn("rawget", source) + self.assertNotIn("bit32", source) + self.assertNotIn("0x428a2f98", source) + self.assertNotIn("4294967296", source) + self.assertIn("VFS.PackF32", source) + self.assertIn('"428a2f98"', source) + def test_contract_vectors_are_byte_exact(self): self.assertEqual(request(), VECTOR["production-request"]["ascii"].encode()) self.assertEqual(run_lua(request(), "empty"), VECTOR["empty-production"]["ascii"].encode()) @@ -61,6 +70,21 @@ def test_team_and_factory_scope_are_closed(self): self.assertEqual(fields(run_lua(request(), "wrong-team"))["reason"], ["wrong-team"]) self.assertEqual(fields(run_lua(request(), "not-factory"))["reason"], ["not-factory"]) + def test_actor_zero_is_valid_and_still_team_scoped(self): + accepted = fields(run_lua(request(unit=0), "actor0")) + self.assertEqual(accepted["status"], ["ok"]) + self.assertEqual(accepted["unit"], ["0"]) + refused = fields(run_lua(request(unit=0), "actor0-wrong-team")) + self.assertEqual(refused["reason"], ["wrong-team"]) + self.assertEqual(refused["unit"], ["0"]) + + def test_float32_bytes_preserve_low_bits_and_extremes(self): + low_bit = fields(run_lua(request(), "mantissa-low-bit"))["row"][0] + self.assertEqual(low_bit.rsplit("|", 1)[1], "3f800001") + boundaries = fields(run_lua(request(), "float-boundaries"))["row"][0] + self.assertEqual(boundaries.rsplit("|", 1)[1], + "00000001,007fffff,7f7fffff") + def test_entry_and_parameter_bounds_refuse_without_truncation(self): for case in ("overflow65", "params17", "total257"): parsed = fields(run_lua(request(), case)) From c7ba253688b1fb5cf328a7bb3c09761d31387d1f Mon Sep 17 00:00:00 2001 From: EHotwagner Date: Thu, 1 Oct 2026 09:58:03 +0200 Subject: [PATCH 7/7] Repair stock tactical final fences --- scripts/build-barc-stock-plugin.sh | 4 +- src/circuit/grpc/CommandDispatch.cpp | 2 +- src/circuit/grpc/LiveControlState.cpp | 26 +++++++- src/circuit/grpc/LiveControlState.h | 17 +++++ src/circuit/grpc/TacticalNativeState.cpp | 29 +++++++-- src/circuit/grpc/TacticalNativeState.h | 20 ++++-- src/circuit/module/GrpcGatewayModule.cpp | 21 ++++--- src/circuit/spring/SpringEngine.cpp | 35 +++++++++++ src/circuit/spring/SpringEngine.h | 7 +++ tests/unit/live_control_state_test.cc | 57 +++++++++++++++++ tests/unit/tactical_native_state_test.cc | 80 +++++++++++++++++++++--- 11 files changed, 266 insertions(+), 32 deletions(-) diff --git a/scripts/build-barc-stock-plugin.sh b/scripts/build-barc-stock-plugin.sh index 14a97e3a..946a4f4d 100755 --- a/scripts/build-barc-stock-plugin.sh +++ b/scripts/build-barc-stock-plugin.sh @@ -57,6 +57,6 @@ fi -DHIGHBAR_BUILD_TESTS=ON ninja -C "${build_dir}" -j"${jobs}" \ - BARb stock_queue_reader_test tactical_native_state_test + BARb live_control_state_test stock_queue_reader_test tactical_native_state_test ctest --test-dir "${build_dir}" --output-on-failure \ - -R '^(stock_queue_reader_test|tactical_native_state_test)$' + -R '^(live_control_state_test|stock_queue_reader_test|tactical_native_state_test)$' diff --git a/src/circuit/grpc/CommandDispatch.cpp b/src/circuit/grpc/CommandDispatch.cpp index 48a3e02a..f838a3ce 100644 --- a/src/circuit/grpc/CommandDispatch.cpp +++ b/src/circuit/grpc/CommandDispatch.cpp @@ -223,11 +223,11 @@ bool DispatchTacticalCommand(::circuit::CCircuitAI* ai, return DispatchAfterTacticalControlAndFinalFence(already_controlled, [&] { return ai->UnitControl(unit, false); }, [&] { - if (post_control_fence && !post_control_fence()) return false; const auto final_queue = ReadCurrentTacticalQueue( ai, native, command.queue_domain()); return final_queue.has_value() && matches_expected(*final_queue); }, + [&] { return !post_control_fence || post_control_fence(); }, std::forward(effect)); }; switch (command.action_case()) { diff --git a/src/circuit/grpc/LiveControlState.cpp b/src/circuit/grpc/LiveControlState.cpp index 3cdba12d..21ba091b 100644 --- a/src/circuit/grpc/LiveControlState.cpp +++ b/src/circuit/grpc/LiveControlState.cpp @@ -338,13 +338,33 @@ LiveFenceResult LiveControlState::CheckTacticalCommandLocked( LiveFenceResult LiveControlState::DispatchGuarded( const QueuedCommand& q, const std::function& dispatch, Clock::time_point now) const { + return DispatchGuardedWithLockedState(q, + [&](const LockedDispatchState&) { return dispatch(); }, now); +} + +LiveFenceResult LiveControlState::DispatchGuardedWithLockedState( + const QueuedCommand& q, + const std::function& dispatch, + Clock::time_point now) const { std::lock_guard lock(mutex_); auto checked = CheckQueuedCommandLocked(q, now); if (!checked.ok) return checked; - if (!dispatch()) return {false, ::highbar::v1::LIVE_FENCE_CAPABILITY_CHANGED}; + if (!dispatch(LockedDispatchState(this))) { + return {false, ::highbar::v1::LIVE_FENCE_CAPABILITY_CHANGED}; + } return checked; } +std::uint64_t LiveControlState::LockedDispatchState::OwnedLifetime( + std::uint32_t id) const { + return state_->OwnedLifetimeLocked(id); +} + +std::uint64_t LiveControlState::OwnedLifetimeLocked(std::uint32_t id) const { + auto it = owned_.find(id); + return it != owned_.end() && it->second.present ? it->second.lifetime : 0; +} + std::uint64_t LiveControlState::MarkOwnedPresent(std::uint32_t id) { std::lock_guard lock(mutex_); auto& x = owned_[id]; if (!x.present) { if (++x.lifetime == 0) ++x.lifetime; x.present = true; } @@ -368,8 +388,8 @@ void LiveControlState::MarkEnemyRemoved(std::uint32_t id) { if (x.present) { x.present = false; x.visual = false; if (++x.lifetime == 0) ++x.lifetime; } } std::uint64_t LiveControlState::OwnedLifetime(std::uint32_t id) const { - std::lock_guard lock(mutex_); auto it = owned_.find(id); - return it != owned_.end() && it->second.present ? it->second.lifetime : 0; + std::lock_guard lock(mutex_); + return OwnedLifetimeLocked(id); } std::uint64_t LiveControlState::EnemyLifetime(std::uint32_t id) const { std::lock_guard lock(mutex_); auto it = enemies_.find(id); diff --git a/src/circuit/grpc/LiveControlState.h b/src/circuit/grpc/LiveControlState.h index 8503daab..d72f6b2a 100644 --- a/src/circuit/grpc/LiveControlState.h +++ b/src/circuit/grpc/LiveControlState.h @@ -33,6 +33,18 @@ enum class BasisLookupResult { class LiveControlState { public: using Clock = std::chrono::steady_clock; + class LockedDispatchState { + public: + LockedDispatchState(const LockedDispatchState&) = delete; + LockedDispatchState& operator=(const LockedDispatchState&) = delete; + std::uint64_t OwnedLifetime(std::uint32_t id) const; + + private: + friend class LiveControlState; + explicit LockedDispatchState(const LiveControlState* state) + : state_(state) {} + const LiveControlState* state_; + }; static std::string NewMatchIncarnation(); LiveControlState(std::string plugin_id, @@ -65,6 +77,10 @@ class LiveControlState { const QueuedCommand& command, const std::function& dispatch, Clock::time_point now = Clock::now()) const; + LiveFenceResult DispatchGuardedWithLockedState( + const QueuedCommand& command, + const std::function& dispatch, + Clock::time_point now = Clock::now()) const; bool LegacyGameplayAllowed() const; bool DispatchLegacyGuarded(const std::function& dispatch) const; @@ -117,6 +133,7 @@ class LiveControlState { const ::highbar::v1::LiveCommandBatch& batch) const; BasisLookupResult ClassifyBasisLocked( const ::highbar::v1::NativeObservationBasis& basis) const; + std::uint64_t OwnedLifetimeLocked(std::uint32_t id) const; std::string plugin_id_; std::string process_incarnation_; diff --git a/src/circuit/grpc/TacticalNativeState.cpp b/src/circuit/grpc/TacticalNativeState.cpp index 32c2bcb1..0a9ab86b 100644 --- a/src/circuit/grpc/TacticalNativeState.cpp +++ b/src/circuit/grpc/TacticalNativeState.cpp @@ -212,11 +212,30 @@ bool SupportsRallyQueueApi(const char* hash, const char* additional) { && std::strcmp(additional, kRallyQueueEngineAdditional) == 0; } -bool SupportsStockRecoilProfile(const char* major, const char* hash, - const char* branch, const char* additional) { - return major != nullptr && hash != nullptr && branch != nullptr - && additional != nullptr && std::strcmp(major, "2025") == 0 - && hash[0] == '\0' && branch[0] == '\0' && additional[0] == '\0'; +bool SupportsStockRecoilProfile(const char* major, const char* minor, + const char* patchset, const char* commits, const char* hash, const char* branch, + const char* additional, const char* normal, const char* sync, + const char* full, bool is_release) { + return major != nullptr && minor != nullptr && patchset != nullptr + && commits != nullptr && hash != nullptr && branch != nullptr + && additional != nullptr + && normal != nullptr && sync != nullptr && full != nullptr + && is_release + && std::strcmp(major, "2025") == 0 + && std::strcmp(minor, "06") == 0 + && std::strcmp(patchset, "19") == 0 + && commits[0] == '\0' && hash[0] == '\0' && branch[0] == '\0' + && std::strcmp(additional, "Headless") == 0 + && std::strcmp(normal, "2025.06.19") == 0 + && std::strcmp(sync, "2025.06.19") == 0 + && std::strcmp(full, "2025.06.19 (Headless)") == 0; +} + +std::optional NativeBuildDefinitionId( + std::int32_t command_id) { + if (command_id >= 0) return std::nullopt; + return static_cast(- + static_cast(command_id)); } FeatureLifetimeLedger::Entry* FeatureLifetimeLedger::Find(std::uint32_t id) { diff --git a/src/circuit/grpc/TacticalNativeState.h b/src/circuit/grpc/TacticalNativeState.h index 67c375d8..dcb4a4b3 100644 --- a/src/circuit/grpc/TacticalNativeState.h +++ b/src/circuit/grpc/TacticalNativeState.h @@ -94,15 +94,18 @@ bool DispatchAfterTacticalControlFence(bool already_controlled, // Stock queue evidence must be read again after Circuit has yielded control. // Keep that final fence between control acquisition and the effect so a stale // observation can never become an accepted command. -template +template bool DispatchAfterTacticalControlAndFinalFence(bool already_controlled, - AcquireControl&& acquire_control, FinalFence&& final_fence, + AcquireControl&& acquire_control, FinalRead&& final_read, + PostReadFence&& post_read_fence, DispatchEffect&& dispatch_effect) { if (!already_controlled && !std::forward(acquire_control)()) { return false; } - if (!std::forward(final_fence)()) return false; + if (!std::forward(final_read)()) return false; + if (!std::forward(post_read_fence)()) return false; std::forward(dispatch_effect)(); return true; } @@ -121,8 +124,10 @@ bool SupportsRallyQueueApi(const char* hash, const char* additional); // closed tuple prevents a custom/development artifact from negotiating the // stock profile; the external artifact packet additionally binds the full // executable SHA-256 because the callback ABI does not expose it. -bool SupportsStockRecoilProfile(const char* major, const char* hash, - const char* branch, const char* additional); +bool SupportsStockRecoilProfile(const char* major, const char* minor, + const char* patchset, const char* commits, const char* hash, const char* branch, + const char* additional, const char* normal, const char* sync, + const char* full, bool is_release); // Engine-neutral projections of the Spring command and feature callbacks. // Keeping these types independent of protobuf lets the engine thread take one @@ -153,6 +158,11 @@ NativeQueueSnapshot MakeNativeQueueSnapshot( std::optional ExactNativeQueueUnitTargetId( const NativeQueueEntry& entry, bool unit_target_action); +// Spring build commands encode the definition as a negative signed id. Widen +// before taking the magnitude so the full accepted int32 bridge range, +// including INT32_MIN, has defined behavior. +std::optional NativeBuildDefinitionId(std::int32_t command_id); + struct NativeQueueUnitTarget { std::uint32_t id = 0; std::uint64_t lifetime = 0; diff --git a/src/circuit/module/GrpcGatewayModule.cpp b/src/circuit/module/GrpcGatewayModule.cpp index ea799550..3fda8b47 100644 --- a/src/circuit/module/GrpcGatewayModule.cpp +++ b/src/circuit/module/GrpcGatewayModule.cpp @@ -594,8 +594,12 @@ void CGrpcGatewayModule::EnsureCoordinatorClientStarted(const char* reason) { auto* configured_engine = circuit->GetEngine(); tactical_profile_available_ = configured_engine != nullptr && grpc::SupportsStockRecoilProfile(configured_engine->GetVersionMajor(), - configured_engine->GetVersionHash(), configured_engine->GetVersionBranch(), - configured_engine->GetVersionAdditional()); + configured_engine->GetVersionMinor(), configured_engine->GetVersionPatchset(), + configured_engine->GetVersionCommits(), configured_engine->GetVersionHash(), + configured_engine->GetVersionBranch(), + configured_engine->GetVersionAdditional(), configured_engine->GetVersionNormal(), + configured_engine->GetVersionSync(), configured_engine->GetVersionFull(), + configured_engine->IsVersionRelease()); #endif auto* tactical = tactical_profile_available_ ? capabilities.mutable_tactical() : nullptr; @@ -1077,9 +1081,10 @@ void CGrpcGatewayModule::BuildAndReportTacticalSnapshot( out->mutable_unit_target()->set_id(target->id); out->mutable_unit_target()->set_lifetime(target->lifetime); } - if (command.command_id < 0) - out->set_definition_id(static_cast(-command.command_id)); - else if (command.command_id == CMD_MOVE && command.params.size() >= 3) { + if (const auto definition_id = + grpc::NativeBuildDefinitionId(command.command_id)) { + out->set_definition_id(*definition_id); + } else if (command.command_id == CMD_MOVE && command.params.size() >= 3) { out->set_world_x(command.params[0]); out->set_world_z(command.params[2]); } @@ -2237,7 +2242,9 @@ void CGrpcGatewayModule::DrainCommandQueue() { grpc::LiveFenceResult guarded{true, ::highbar::v1::LIVE_FENCE_REASON_UNSPECIFIED}; bool dispatched = false; if (entry.live) { - guarded = live_control_state_ ? live_control_state_->DispatchGuarded(entry, [&] { + guarded = live_control_state_ + ? live_control_state_->DispatchGuardedWithLockedState(entry, + [&](const grpc::LiveControlState::LockedDispatchState& locked_state) { auto* fresh_actor = circuit->GetTeamUnit(static_cast(entry.live_actor.id())); if (fresh_actor == nullptr || fresh_actor->IsDead()) { AppendCoordinatorTrace("live dispatch refused reason=actor_missing_or_dead"); @@ -2296,7 +2303,7 @@ void CGrpcGatewayModule::DrainCommandQueue() { auto* current = circuit->GetTeamUnit( static_cast(entry.live_actor.id())); return current == fresh_actor && !fresh_actor->IsDead() - && live_control_state_->OwnedLifetime(entry.live_actor.id()) + && locked_state.OwnedLifetime(entry.live_actor.id()) == entry.live_actor.lifetime(); }; const bool applied = entry.live_tactical_command diff --git a/src/circuit/spring/SpringEngine.cpp b/src/circuit/spring/SpringEngine.cpp index c73fc10b..9f03bbac 100644 --- a/src/circuit/spring/SpringEngine.cpp +++ b/src/circuit/spring/SpringEngine.cpp @@ -26,6 +26,21 @@ const char* CEngine::GetVersionMajor() const return sAICallback->Engine_Version_getMajor(skirmishAIId); } +const char* CEngine::GetVersionMinor() const +{ + return sAICallback->Engine_Version_getMinor(skirmishAIId); +} + +const char* CEngine::GetVersionPatchset() const +{ + return sAICallback->Engine_Version_getPatchset(skirmishAIId); +} + +const char* CEngine::GetVersionCommits() const +{ + return sAICallback->Engine_Version_getCommits(skirmishAIId); +} + const char* CEngine::GetVersionHash() const { return sAICallback->Engine_Version_getHash(skirmishAIId); @@ -41,4 +56,24 @@ const char* CEngine::GetVersionAdditional() const return sAICallback->Engine_Version_getAdditional(skirmishAIId); } +const char* CEngine::GetVersionNormal() const +{ + return sAICallback->Engine_Version_getNormal(skirmishAIId); +} + +const char* CEngine::GetVersionSync() const +{ + return sAICallback->Engine_Version_getSync(skirmishAIId); +} + +const char* CEngine::GetVersionFull() const +{ + return sAICallback->Engine_Version_getFull(skirmishAIId); +} + +bool CEngine::IsVersionRelease() const +{ + return sAICallback->Engine_Version_isRelease(skirmishAIId); +} + } // namespace circuit diff --git a/src/circuit/spring/SpringEngine.h b/src/circuit/spring/SpringEngine.h index f6ea28b8..b99d73de 100644 --- a/src/circuit/spring/SpringEngine.h +++ b/src/circuit/spring/SpringEngine.h @@ -19,9 +19,16 @@ class CEngine virtual ~CEngine(); const char* GetVersionMajor() const; + const char* GetVersionMinor() const; + const char* GetVersionPatchset() const; + const char* GetVersionCommits() const; const char* GetVersionHash() const; const char* GetVersionBranch() const; const char* GetVersionAdditional() const; + const char* GetVersionNormal() const; + const char* GetVersionSync() const; + const char* GetVersionFull() const; + bool IsVersionRelease() const; private: const struct SSkirmishAICallback* sAICallback; diff --git a/tests/unit/live_control_state_test.cc b/tests/unit/live_control_state_test.cc index bc50683f..0048ecac 100644 --- a/tests/unit/live_control_state_test.cc +++ b/tests/unit/live_control_state_test.cc @@ -6,7 +6,15 @@ #include "grpc/TacticalNativeState.h" #include +#include #include +#include + +#ifndef _WIN32 +#include +#include +#include +#endif namespace { using namespace circuit::grpc; @@ -233,6 +241,55 @@ TEST(LiveControlState, RevocationCannotInterleaveWithGuardedDispatch) { EXPECT_FALSE(result.ok); EXPECT_FALSE(called); EXPECT_EQ(result.reason,LIVE_FENCE_AUTHORITY_REVOKED); } +#ifndef _WIN32 +TEST(LiveControlState, LockedDispatchLifetimeFenceCompletesWithoutRelocking) { + const pid_t child = fork(); + ASSERT_GE(child, 0); + if (child == 0) { + auto state = State(); + const auto t0 = LiveControlState::Clock::time_point{}; + Apply(*state, LIVE_CONTROL_DIRECTIVE_KIND_ARM, 1); + QueuedCommand queued; + queued.live = true; + queued.live_binding = Binding(1); + queued.live_actor.set_id(0); + const auto lifetime = state->MarkOwnedPresent(0); + queued.live_actor.set_lifetime(lifetime); + queued.live_basis = state->RecordBasis(23, 43, 200003, 30, + std::chrono::milliseconds(500), t0); + queued.live_basis_deadline = queued.live_command_deadline + = queued.live_lease_deadline = t0 + std::chrono::seconds(1); + const auto accepted = state->DispatchGuardedWithLockedState(queued, + [&](const LiveControlState::LockedDispatchState& locked) { + return locked.OwnedLifetime(0) == lifetime; + }, t0); + if (!accepted.ok) _exit(2); + const auto refused = state->DispatchGuardedWithLockedState(queued, + [&](const LiveControlState::LockedDispatchState& locked) { + return locked.OwnedLifetime(0) != lifetime; + }, t0); + _exit(!refused.ok && refused.reason == LIVE_FENCE_CAPABILITY_CHANGED + ? 0 : 3); + } + int status = 0; + bool completed = false; + for (int attempt = 0; attempt < 100; ++attempt) { + if (waitpid(child, &status, WNOHANG) == child) { + completed = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + if (!completed) { + kill(child, SIGKILL); + waitpid(child, &status, 0); + } + ASSERT_TRUE(completed) << "guarded lifetime fence deadlocked"; + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(WEXITSTATUS(status), 0); +} +#endif + TEST(LiveControlState, NativeEmissionAgeCannotBeRenewedByDelayedTransport) { auto state=State(); const auto t0=LiveControlState::Clock::time_point{}; Apply(*state,LIVE_CONTROL_DIRECTIVE_KIND_ARM,1,2000); diff --git a/tests/unit/tactical_native_state_test.cc b/tests/unit/tactical_native_state_test.cc index a803b642..ff4b570a 100644 --- a/tests/unit/tactical_native_state_test.cc +++ b/tests/unit/tactical_native_state_test.cc @@ -4,6 +4,8 @@ #include #include +#include +#include #include namespace { @@ -48,22 +50,82 @@ TEST(TacticalNativeState, FinalQueueFenceRunsAfterControlAndBeforeEffect) { EXPECT_TRUE(DispatchAfterTacticalControlAndFinalFence(false, [&] { events.push_back(1); return true; }, [&] { events.push_back(2); return true; }, - [&] { events.push_back(3); })); - EXPECT_EQ(events, (std::vector{1, 2, 3})); + [&] { events.push_back(3); return true; }, + [&] { events.push_back(4); })); + EXPECT_EQ(events, (std::vector{1, 2, 3, 4})); events.clear(); EXPECT_FALSE(DispatchAfterTacticalControlAndFinalFence(false, [&] { events.push_back(1); return true; }, - [&] { events.push_back(2); return false; }, - [&] { events.push_back(3); })); - EXPECT_EQ(events, (std::vector{1, 2})); + [&] { events.push_back(2); return true; }, + [&] { events.push_back(3); return false; }, + [&] { events.push_back(4); })); + EXPECT_EQ(events, (std::vector{1, 2, 3})); +} + +TEST(TacticalNativeState, ExpiryOrActorChangeDuringFinalReadPreventsEffect) { + using Clock = std::chrono::steady_clock; + for (std::size_t expiring = 0; expiring < 3; ++expiring) { + auto now = Clock::time_point{} + std::chrono::milliseconds(9); + std::array deadlines{ + Clock::time_point{} + std::chrono::milliseconds(20), + Clock::time_point{} + std::chrono::milliseconds(20), + Clock::time_point{} + std::chrono::milliseconds(20)}; + deadlines[expiring] = Clock::time_point{} + std::chrono::milliseconds(10); + bool effect = false; + EXPECT_FALSE(DispatchAfterTacticalControlAndFinalFence(true, + [] { return true; }, + [&] { now += std::chrono::milliseconds(2); return true; }, + [&] { + return now < deadlines[0] && now < deadlines[1] + && now < deadlines[2]; + }, + [&] { effect = true; })); + EXPECT_FALSE(effect); + } + + auto now = Clock::time_point{} + std::chrono::milliseconds(9); + const auto deadline = Clock::time_point{} + std::chrono::milliseconds(20); + bool actor_valid = true; + bool effect = false; + EXPECT_FALSE(DispatchAfterTacticalControlAndFinalFence(true, + [] { return true; }, + [&] { actor_valid = false; return true; }, + [&] { return now < deadline && actor_valid; }, + [&] { effect = true; })); + EXPECT_FALSE(effect); } TEST(TacticalNativeState, StockProfileRequiresClosedReleaseTuple) { - EXPECT_TRUE(SupportsStockRecoilProfile("2025", "", "", "")); - EXPECT_FALSE(SupportsStockRecoilProfile("2025", "custom", "", "")); - EXPECT_FALSE(SupportsStockRecoilProfile("2026", "", "", "")); - EXPECT_FALSE(SupportsStockRecoilProfile(nullptr, "", "", "")); + auto supports = [](const char* major, const char* minor, const char* patchset, + const char* hash, const char* branch, const char* additional, + const char* normal, const char* sync, const char* full, + bool is_release = true) { + return SupportsStockRecoilProfile(major, minor, patchset, "", hash, branch, + additional, normal, sync, full, is_release); + }; + EXPECT_TRUE(supports("2025", "06", "19", "", "", "Headless", + "2025.06.19", "2025.06.19", "2025.06.19 (Headless)")); + EXPECT_FALSE(supports("2025", "06", "20", "", "", "Headless", + "2025.06.20", "2025.06.20", "2025.06.20 (Headless)")); + EXPECT_FALSE(supports("2025", "06", "19", "", "", "", + "2025.06.19", "2025.06.19", "2025.06.19")); + EXPECT_FALSE(supports("2025", "06", "19", "", "", "Headless Debug", + "2025.06.19", "2025.06.19", "2025.06.19 (Headless Debug)")); + EXPECT_FALSE(supports("2025", "06", "19", "custom", "", "Headless", + "2025.06.19", "2025.06.19", "2025.06.19 (Headless)")); + EXPECT_FALSE(supports("2025", "06", "19", "", "", "Headless", + "2025.06.19", "2025.06.19", "2025.06.19 (Headless)", false)); + EXPECT_FALSE(supports(nullptr, "06", "19", "", "", "Headless", + "2025.06.19", "2025.06.19", "2025.06.19 (Headless)")); +} + +TEST(TacticalNativeState, BuildDefinitionProjectionCoversSignedMinimum) { + EXPECT_EQ(NativeBuildDefinitionId(-1), 1u); + EXPECT_EQ(NativeBuildDefinitionId(std::numeric_limits::min()), + 2147483648u); + EXPECT_FALSE(NativeBuildDefinitionId(0).has_value()); + EXPECT_FALSE(NativeBuildDefinitionId(1).has_value()); } StockQueueRevisionContext GoldenStockContext() {