diff --git a/src/votv-coop/include/coop/interactables/atv_sync.h b/src/votv-coop/include/coop/interactables/atv_sync.h index 5fef231e..481e183c 100644 --- a/src/votv-coop/include/coop/interactables/atv_sync.h +++ b/src/votv-coop/include/coop/interactables/atv_sync.h @@ -78,4 +78,8 @@ void Tick(); // single-player), then clear the index + interp state. void OnDisconnect(); -} // namespace coop::atv_sync +// Interaction query: true iff `actor` is indexed as an ATV currently occupied by another network +// peer (occupantSlot != 0xFF && occupantSlot != LocalSlot()). Populates `outOccupantSlot` if non-null. +bool IsOccupiedByOther(void* actor, uint8_t* outOccupantSlot = nullptr); + +} // namespace coop::atv_sync \ No newline at end of file diff --git a/src/votv-coop/src/coop/interactables/atv_sync.cpp b/src/votv-coop/src/coop/interactables/atv_sync.cpp index d9a0117c..7db9fd9b 100644 --- a/src/votv-coop/src/coop/interactables/atv_sync.cpp +++ b/src/votv-coop/src/coop/interactables/atv_sync.cpp @@ -7,6 +7,11 @@ // always-present keyed actor; the grime/window dirt sync made the same divergence vs its element // blueprint). The index/poll/connect-snapshot shape follows the keyed-interactable modules // (power_sync/keypad_sync); the per-ATV LerpWindow interp follows element::Npc's pose drive. +// +// Seat Contention / Double-Mount Prevention: +// Each ATV entry tracks occupantSlot (0xFF = unoccupied). Authority is claimed only if the seat +// is free or already owned by the local peer. If a remote peer is driving, local mount attempts +// are blocked / disregarded so two players cannot seat simultaneously or fight for pose authority. #include "coop/interactables/atv_sync.h" @@ -20,8 +25,8 @@ #include "ue_wrap/engine/engine.h" // ReadMainPlayerGrabState (grabber authority) + Get/SetActorRootPhysicsVelocity (release) #include "ue_wrap/core/log.h" #include "ue_wrap/core/reflection.h" -#include "ue_wrap/core/settled_object_scan.h" // stream-settle scan (L5 + the 18:41 world-reload cure) -#include "ue_wrap/core/walk_timer.h" // L5: [WALK-TIME] profiling +#include "ue_wrap/engine/world_identity.h" // R-2: gen-stamped index (dead-world guard) +#include "coop/element/object_scan_hub.h" // R-2: the shared sliced scan pass #include "ue_wrap/core/types.h" // FVector, FRotator, NormalizeAxis #include @@ -46,7 +51,6 @@ using coop::net::WireKeyFromString; using coop::net::StringFromWireKey; using coop::net::FnvKey; -constexpr auto kRebuildThrottle = std::chrono::seconds(2); constexpr uint64_t kSendIntervalMs = 50; // ~20 Hz occupant stream while seated constexpr int kInterpWindowMs = 75; // matches the NPC pose interp window @@ -61,6 +65,7 @@ struct AtvEntry { float curYaw = 0.f, tgtYaw = 0.f, errYaw = 0.f; float curRoll = 0.f, tgtRoll = 0.f, errRoll = 0.f; uint64_t lastSentMs = 0; + uint8_t occupantSlot = 0xFF; // 0xFF = unseated/free, otherwise peer slot of the active driver bool hasPose = false; bool dirty = false; bool preparedAsMirror = false; // we disabled this ATV's physics/tick to mirror it @@ -73,10 +78,10 @@ std::atomic g_session{nullptr}; // g_atvs is GAME-THREAD ONLY: Install / Tick / OnReliable (event_feed drain) / // QueueConnectBroadcastForSlot / OnDisconnect all run on the game thread, serially within the // net-pump, so no synchronization is needed (the drain + the sync ticks never overlap). Tick -// mutates only entry FIELDS; structural inserts/erases happen in RebuildIndex (called at the top -// of Tick, before the iteration) and OnReliable (the drain, before the ticks). +// mutates only entry FIELDS; structural inserts/erases happen in the hub pass's +// HubPassComplete (scan_hub::Tick runs before this module's Tick in the pump order, and both +// are GT-serial) and OnReliable (the drain, before the ticks). std::unordered_map g_atvs; -std::chrono::steady_clock::time_point g_lastRebuild{}; size_t g_lastLogCount = SIZE_MAX; uint64_t g_lastLogHash = 0; bool g_installed = false; // latch the one-time index+log (Install is the per-tick ensure path) @@ -88,10 +93,10 @@ bool g_installed = false; // latch the one-time index+log (Install is th // gives each such ATV a SYNTHETIC stable wire key ("coopatv#N") and announces it (AtvSpawn); clients // fresh-spawn a native AATV_C under that key. Default SAVE-PLACED ATVs (deterministic key, both peers // loaded them) stay on the real-key path untouched. -std::unordered_set g_savePlacedKeys; // HOST: real keys seen BEFORE any client connected = save-placed (a joiner loads them) -std::unordered_set g_savePlacedActors; // HOST: ATV ACTORS present before any client connected -- so a save ATV that mints its UCS key LATE (after connect) is recognised by its actor, not misread as a purchase (-> client dupe) +std::unordered_set g_savePlacedKeys; // HOST: real keys seen BEFORE any client connected = save-placed (a joiner loads them) +std::unordered_set g_savePlacedActors; // HOST: ATV ACTORS present before any client connected -- so a save ATV that mints its UCS key LATE (after connect) is recognised by its actor, not misread as a purchase (-> client dupe) std::unordered_map g_synthForActor; // actor -> synthetic wire key (host purchased + client mirror) -uint32_t g_synthCounter = 0; // HOST: monotonic synth-key id +uint32_t g_synthCounter = 0; // HOST: monotonic synth-key id const wchar_t* const kSynthPrefix = L"coopatv#"; // distinguishes synth keys from real ATV keys ("atv"/base64) @@ -145,11 +150,18 @@ uint64_t NowMs() { std::chrono::steady_clock::now().time_since_epoch()).count()); } -// True iff THIS peer's local player is currently seated in `actor` -- i.e. we are the driver. +// True iff THIS peer's local player is currently seated in `actor` according to the local engine. bool IsLocalOccupant(void* actor, void* localPlayer) { return localPlayer && A::IsDriven(actor) && A::GetOccupantPlayer(actor) == localPlayer; } +// True iff THIS peer can claim or currently holds driver authority. +// Prevents claiming driver authority if another network peer is already seated (fixes double-mount races). +bool CanClaimOrIsDriver(void* actor, void* localPlayer, uint8_t occupantSlot, uint8_t localSlot) { + if (!IsLocalOccupant(actor, localPlayer)) return false; + return occupantSlot == 0xFF || occupantSlot == localSlot; +} + // True iff THIS peer's local player is currently grav-hand GRABBING `actor` (carrying it in the // air like an object -- NOT seated). The ATV has no grabbed/held flag of its own (isDriven stays // false, Player stays null during a grab -- those are written only on the seating path), so the @@ -165,10 +177,10 @@ bool IsLocalGrabber(void* actor, void* localPlayer) { } // THIS peer is the single authority for `actor` -- it must STREAM it, not mirror it -- iff its -// local player is the driver OR the grav-hand grabber. The two are mutually exclusive (you cannot -// be seated and grav-hand-holding the same ATV at once), so there is still exactly one authority. -bool IsLocalAuthority(void* actor, void* localPlayer) { - return IsLocalOccupant(actor, localPlayer) || IsLocalGrabber(actor, localPlayer); +// local player is the validated driver OR the grav-hand grabber. The two are mutually exclusive. +bool IsLocalAuthority(void* actor, void* localPlayer, uint8_t occupantSlot, uint8_t localSlot) { + const bool isDriver = CanClaimOrIsDriver(actor, localPlayer, occupantSlot, localSlot); + return isDriver || (!isDriver && IsLocalGrabber(actor, localPlayer)); } // Fill an AtvStatePayload from a live ATV read. False if the transform read fails. `grabbed` marks @@ -245,39 +257,44 @@ void ApplyMirror(AtvEntry& e) { e.dirty = false; } -// Full GUObjectArray walk -> refresh the WIRE-key->actor index, PRESERVING interp/sender state for -// keys that persist (only actor/idx are updated). Classifies each ATV's identity (v77): a save-placed -// ATV (real key, both peers loaded it) keeps its real key; a HOST-side mid-session PURCHASED ATV gets -// a synthetic key + an AtvSpawn announce so clients fresh-spawn it. Game thread. Logs a keys-hash. -size_t RebuildIndex() { - if (!A::EnsureResolved()) return 0; +// ---- R-2 shared-scan hub consumer (design: votv-shared-scan-hub-R2-DESIGN-2026-08-23.md). +// The per-module walk is RETIRED; the hub's shared sliced pass drives these callbacks -- +// PRESERVING interp/sender state for keys that persist (only actor/idx are updated), and the +// v77 identity classification verbatim: a save-placed ATV (real key, both peers loaded it) +// keeps its real key; a HOST-side mid-session PURCHASED ATV gets a synthetic key + an +// AtvSpawn announce so clients fresh-spawn it. Note the join edge: a purchase landing inside +// the <=1-pass (~2 s) index staleness window at a join is announced on the NEXT pass, when +// the joiner is already connected -- the announce reaches it; no re-announce machinery needed. +uint32_t g_indexGen = 0; // world gen of the last completed pass (stale-gen index = EMPTY) +bool IndexCurrent() { return g_indexGen == ue_wrap::world_identity::Generation(); } +struct ScanFound { std::wstring wireKey; void* obj; int32_t idx; std::wstring realKey; }; +std::vector g_scanFound; // pass scratch (GT-only) +bool g_scanIsHost = false; // pass context, captured at pass begin +bool g_scanCapturing = false; + +void HubPassBegin(void*, bool) { + g_scanFound.clear(); auto* s = g_session.load(std::memory_order_acquire); - const bool isHost = s && s->role() == coop::net::Role::Host; + g_scanIsHost = s && s->role() == coop::net::Role::Host; + const bool isHost = g_scanIsHost; // Baseline-capture window: before any client is connected, EVERY keyed ATV the host has is // save-placed (a joiner will load it from the save). After a client connects, a newly-appearing // key is a runtime purchase. (Accumulated -- not a single-frame latch -- so a default ATV that is // a few seconds slow to mint its UCS key still lands in the save-set before the first joiner.) - const bool capturing = isHost && (!s || !s->connected()); - - // Stream-settle scan (ue_wrap/settled_object_scan.h) -- the raw tail-scan died at the 18:41 - // host world reload (prune-to-0, recycled slots below the cursor; the host log's "atv: indexed - // 0 ATV(s)" at 18:41:10 is this). The capturing baseline + purchase-detect orderings are preserved - // below: the not-settled phase scans [0, N) so the initial save-set is fully captured. - static ue_wrap::scan::SettledObjectScan sScan; - const auto r = sScan.Begin(); - - struct Found { std::wstring wireKey; void* obj; int32_t idx; std::wstring realKey; }; - std::vector found; - found.reserve(4); - for (int32_t i = r.begin; i < r.end; ++i) { - void* obj = R::ObjectAt(i); - if (!obj || !A::IsAtv(obj)) continue; + g_scanCapturing = isHost && (!s || !s->connected()); +} + +void HubMatch(void*, void* obj) { + const bool isHost = g_scanIsHost; + const bool capturing = g_scanCapturing; + auto& found = g_scanFound; + { const std::wstring nm = R::ToString(R::NameOf(obj)); - if (nm.rfind(L"Default__", 0) == 0) continue; // skip CDO - if (!R::IsLive(obj)) continue; + if (nm.rfind(L"Default__", 0) == 0) return; // skip CDO + if (!R::IsLive(obj)) return; if (capturing) g_savePlacedActors.insert(obj); // capture the ACTOR (even before it mints its key) std::wstring realKey = A::GetKeyString(obj); - if (realKey.empty() || realKey == L"None") continue; // not yet keyed -- next rebuild picks it up + if (realKey.empty() || realKey == L"None") return; // not yet keyed -- the next pass picks it up std::wstring wireKey; auto sf = g_synthForActor.find(obj); if (sf != g_synthForActor.end()) { @@ -300,16 +317,21 @@ size_t RebuildIndex() { } found.push_back({ std::move(wireKey), obj, R::InternalIndexOf(obj), std::move(realKey) }); } +} + +size_t HubPassComplete(void*, bool isFull, uint32_t worldGen) { + const bool isHost = g_scanIsHost; + auto& found = g_scanFound; // Drop entries whose ATV vanished. A HOST synth (purchased) ATV that's gone -> AtvDestroy so the // clients tear down their fresh-spawned mirror; clean its synth map entry. - // FULL scan: `found` is authoritative (it covered [0,N)) -> drop any entry NOT in `found`. - // TAIL scan: `found` is only the new tail -> a persistent live entry is NOT in `found`; prune by + // FULL pass: `found` is authoritative (it covered [0,N)) -> drop any entry NOT in `found`. + // TAIL pass: `found` is only the new tail -> a persistent live entry is NOT in `found`; prune by // IsLiveByIndex instead (drop only entries whose actor actually died). The synth AtvDestroy folds // into this prune (a gone host-synth -> announce + clean its synth map) -- same teardown, different // liveness oracle. Either way the same set is removed (a tail vanish-drop is index-cheap, O(index)). for (auto it = g_atvs.begin(); it != g_atvs.end();) { bool keep; - if (r.isFull) { + if (isFull) { keep = false; for (auto& f : found) if (f.wireKey == it->first) { keep = true; break; } } else { @@ -328,22 +350,32 @@ size_t RebuildIndex() { e.actor = f.obj; e.idx = f.idx; } - sScan.End(g_atvs.size()); // feed the settle gate (any count change re-arms full walks) - // Recompute the keys-hash over the WHOLE index (cheap, O(index)) -- on a tail scan `found` is only the + g_indexGen = worldGen; + // Recompute the keys-hash over the WHOLE index (cheap, O(index)) -- on a tail pass `found` is only the // new arrivals, so hashing just `found` would lose the persistent keys + thrash the dedup log. uint64_t keysHash = 0; for (auto& kv : g_atvs) keysHash ^= FnvKey(kv.first); if (g_atvs.size() != g_lastLogCount || keysHash != g_lastLogHash) { g_lastLogCount = g_atvs.size(); g_lastLogHash = keysHash; - UE_LOGI("atv: index rebuilt -- %zu live ATV(s), keysHash=0x%016llX (%s scan, +%zu new) " + UE_LOGI("atv: index rebuilt -- %zu live ATV(s), keysHash=0x%016llX (%s pass, +%zu new) " "(compare host vs client for cross-peer Key stability)", g_atvs.size(), static_cast(keysHash), - r.isFull ? "full" : "tail", found.size()); + isFull ? "full" : "tail", found.size()); } + g_scanFound.clear(); return g_atvs.size(); } +void RegisterWithScanHub() { + static bool sDone = false; + if (sDone) return; + sDone = true; + coop::element::scan_hub::Register(coop::element::scan_hub::Consumer{ + "atv", nullptr, &A::EnsureResolved, &A::IsAtv, + &HubPassBegin, &HubMatch, &HubPassComplete, /*settleScans*/ 15}); +} + } // namespace void Install(coop::net::Session* session) { @@ -352,7 +384,7 @@ void Install(coop::net::Session* session) { // until the class loads) -- latch the one-time initial index + log so we don't full-walk the // GUObjectArray + spam the log every tick (the Tick's throttled rebuild owns ongoing indexing). if (!g_installed && A::EnsureResolved()) { - UE_LOGI("atv: indexed %zu ATV(s)", RebuildIndex()); + RegisterWithScanHub(); // the hub builds the index on its own cadence g_installed = true; } } @@ -361,6 +393,7 @@ void OnReliable(const coop::net::AtvStatePayload& payload, uint8_t /*senderPeerS std::wstring key = StringFromWireKey(payload.key); if (key.empty()) { UE_LOGW("atv: OnReliable empty key -- dropping"); return; } if (!A::EnsureResolved()) return; + if (!IndexCurrent()) return; // audit W-2: a stale-gen index holds another world's ATVs (R-1 class); the 20 Hz stream re-sends // NaN/Inf guard before the kinematic engine writes (event_feed also guards; defensive). if (!std::isfinite(payload.x) || !std::isfinite(payload.y) || !std::isfinite(payload.z) || !std::isfinite(payload.pitch) || !std::isfinite(payload.yaw) || !std::isfinite(payload.roll)) { @@ -371,9 +404,17 @@ void OnReliable(const coop::net::AtvStatePayload& payload, uint8_t /*senderPeerS if (it == g_atvs.end()) return; // not indexed yet -- the throttled rebuild will pick it up AtvEntry& e = it->second; if (!R::IsLiveByIndex(e.actor, e.idx)) return; - // If WE are the AUTHORITY of this ATV (driving OR grav-hand grabbing it), ignore the incoming + + void* localPlayer = coop::players::Registry::Get().Local(); + const uint8_t localSlot = coop::players::Registry::Get().LocalPeerId(); + + // If WE are the legitimate authority of this ATV (driving OR grav-hand grabbing it), ignore the incoming // pose so a relayed/echoed copy can't fight our live driving/carrying. - if (IsLocalAuthority(e.actor, coop::players::Registry::Get().Local())) return; + if (IsLocalAuthority(e.actor, localPlayer, e.occupantSlot, localSlot)) return; + + // Track the incoming network occupant slot. + e.occupantSlot = payload.occupantSlot; + // v77: a connect-snapshot (adopt=1) of an IDLE ATV (bit3 authored clear -- no peer is driving/ // grabbing it) must NOT freeze it: place it at the host pose but keep physics ON so the local // player can grab/drive it like a native ATV. (A live stream always comes from an authority, so @@ -397,6 +438,7 @@ void OnAtvRelease(const coop::net::AtvReleasePayload& payload, uint8_t /*senderP std::wstring key = StringFromWireKey(payload.key); if (key.empty()) { UE_LOGW("atv: OnAtvRelease empty key -- dropping"); return; } if (!A::EnsureResolved()) return; + if (!IndexCurrent()) return; // audit W-2: never drive velocity writes into a dead-world actor // NaN/Inf guard before the kinematic-off + velocity engine writes (event_feed also guards). if (!std::isfinite(payload.linVelX) || !std::isfinite(payload.linVelY) || !std::isfinite(payload.linVelZ) || !std::isfinite(payload.angVelX) || !std::isfinite(payload.angVelY) || !std::isfinite(payload.angVelZ)) { @@ -407,9 +449,17 @@ void OnAtvRelease(const coop::net::AtvReleasePayload& payload, uint8_t /*senderP if (it == g_atvs.end()) return; // not indexed yet -- nothing to un-freeze AtvEntry& e = it->second; if (!R::IsLiveByIndex(e.actor, e.idx)) return; + + void* localPlayer = coop::players::Registry::Get().Local(); + const uint8_t localSlot = coop::players::Registry::Get().LocalPeerId(); + // If WE are the authority (driving OR grabbing this ATV), we own its physics -- ignore a stale // or echoed release so it can't perturb our live carry. - if (IsLocalAuthority(e.actor, coop::players::Registry::Get().Local())) return; + if (IsLocalAuthority(e.actor, localPlayer, e.occupantSlot, localSlot)) return; + + // Remote peer released the vehicle -> reset the occupant reservation. + e.occupantSlot = 0xFF; + // Re-enable physics FIRST, THEN write the launch velocity: a kinematic body (mirror) ignores a // velocity write, so the simulate-on must precede it (the PropRelease apply order). Stop driving // the interp so the ATV's own simulation carries it from here (arc + land). @@ -460,6 +510,7 @@ void OnAtvDestroy(const coop::net::AtvDestroyPayload& payload, uint8_t /*senderP if (!s || s->role() == coop::net::Role::Host) return; // client-only std::wstring synthKey = StringFromWireKey(payload.synthKey); if (synthKey.empty()) return; + if (!IndexCurrent()) return; // audit W-2: the pass prunes a dead-world entry itself auto it = g_atvs.find(synthKey); if (it == g_atvs.end()) return; void* actor = it->second.actor; @@ -473,8 +524,11 @@ void QueueConnectBroadcastForSlot(int peerSlot) { auto* s = g_session.load(std::memory_order_acquire); if (!s || s->role() != coop::net::Role::Host) return; // host-only snapshot if (peerSlot < 0 || peerSlot >= static_cast(coop::players::kMaxPeers)) return; - RebuildIndex(); + // R-2: the forced sync rebuild is gone -- the hub keeps the index <=1 pass (~2 s) fresh; + // a purchase inside that window is announced on the next pass to the (by then connected) + // joiner -- see the hub-consumer block note. void* localPlayer = coop::players::Registry::Get().Local(); + const uint8_t localSlot = coop::players::Registry::Get().LocalPeerId(); int sent = 0, spawns = 0; for (auto& kv : g_atvs) { AtvEntry& e = kv.second; @@ -485,10 +539,11 @@ void QueueConnectBroadcastForSlot(int peerSlot) { if (IsSynthKey(kv.first)) { SendAtvSpawn(kv.first, e.actor, peerSlot); ++spawns; } // authored = SOME peer is actively driving/grabbing this ATV NOW (host occupant/grabber, or // the host is itself mirroring a client's stream). The joiner freezes only an authored ATV; - // an idle one stays physics-on + grabbable (occupantSlot 0xFF; adopt=1 snaps the body). - const bool authored = IsLocalAuthority(e.actor, localPlayer) || e.preparedAsMirror; + // an idle one stays physics-on + grabbable. Pass e.occupantSlot so the joiner inherits the active driver. + const bool isAuthority = IsLocalAuthority(e.actor, localPlayer, e.occupantSlot, localSlot); + const bool authored = isAuthority || e.preparedAsMirror; coop::net::AtvStatePayload p{}; - if (!ReadPayload(e.actor, kv.first, 0xFF, /*adopt*/true, p, /*grabbed*/false, /*authored*/authored)) continue; + if (!ReadPayload(e.actor, kv.first, e.occupantSlot, /*adopt*/true, p, /*grabbed*/false, /*authored*/authored)) continue; s->SendReliableToSlot(peerSlot, coop::net::ReliableKind::AtvState, &p, sizeof(p)); ++sent; } @@ -498,15 +553,10 @@ void QueueConnectBroadcastForSlot(int peerSlot) { void Tick() { if (!A::EnsureResolved()) return; + RegisterWithScanHub(); // safety net for any order where Tick precedes Install + if (!IndexCurrent()) return; // index belongs to a dead world -- wait for the hub's next pass auto* s = g_session.load(std::memory_order_acquire); - const auto nowTp = std::chrono::steady_clock::now(); - if (nowTp - g_lastRebuild >= kRebuildThrottle) { - g_lastRebuild = nowTp; - ue_wrap::ScopedWalkTimer _wt("atv:RebuildIndex"); // logs only the rare ~5min full safety - RebuildIndex(); // L5: INCREMENTAL -- tail-scan + a rare full safety (no 237k walk) - } - if (!s || !s->connected()) return; void* localPlayer = coop::players::Registry::Get().Local(); const uint8_t localSlot = coop::players::Registry::Get().LocalPeerId(); @@ -515,9 +565,10 @@ void Tick() { for (auto& kv : g_atvs) { AtvEntry& e = kv.second; if (!R::IsLiveByIndex(e.actor, e.idx)) continue; - const bool occupant = IsLocalOccupant(e.actor, localPlayer); - const bool grabber = !occupant && IsLocalGrabber(e.actor, localPlayer); // mutually exclusive - const bool authority = occupant || grabber; + + const bool isDriver = CanClaimOrIsDriver(e.actor, localPlayer, e.occupantSlot, localSlot); + const bool isGrabber = !isDriver && IsLocalGrabber(e.actor, localPlayer); // mutually exclusive + const bool authority = isDriver || isGrabber; // AUTHORITY-LOST edge (v77, generalizes the grab-release): we were the authority last tick // (driver OR grabber) and are now neither. Tell receivers to re-enable the ATV's physics and @@ -527,6 +578,7 @@ void Tick() { // model). The final pose streamed last tick rides the same Normal lane, so GNS delivers // pose-then-release in order -- no settle delay needed. if (e.wasAuthority && !authority) { + e.occupantSlot = 0xFF; // unseat / release slot reservation FVector lin{}, ang{}; ue_wrap::engine::GetActorRootPhysicsVelocity(e.actor, lin, ang); // best-effort; zero on fail coop::net::AtvReleasePayload rp{}; @@ -541,6 +593,11 @@ void Tick() { e.wasAuthority = authority; if (authority) { + // Claim the occupant slot locally if we are driving. + if (isDriver) { + e.occupantSlot = localSlot; + } + // We own this ATV (driving OR grabbing). If it had been a mirror (physics off), restore // its rig so our local driving/carrying works again -- and stop applying any stale interp. if (e.preparedAsMirror) { @@ -552,8 +609,8 @@ void Tick() { if (nowMs - e.lastSentMs >= kSendIntervalMs) { e.lastSentMs = nowMs; coop::net::AtvStatePayload p{}; - const uint8_t occSlot = occupant ? localSlot : uint8_t{0xFF}; // grabber: no seated driver - if (ReadPayload(e.actor, kv.first, occSlot, /*adopt*/false, p, /*grabbed*/grabber)) + const uint8_t occSlot = isDriver ? localSlot : uint8_t{0xFF}; // grabber: no seated driver + if (ReadPayload(e.actor, kv.first, occSlot, /*adopt*/false, p, /*grabbed*/isGrabber)) s->SendReliable(coop::net::ReliableKind::AtvState, &p, sizeof(p)); } } else if (e.hasPose) { @@ -583,4 +640,21 @@ void OnDisconnect() { if (n > 0) UE_LOGI("atv: OnDisconnect -- cleared %zu ATV(s) (released save mirrors; destroyed purchased mirrors)", n); } -} // namespace coop::atv_sync +// Check if an ATV actor is occupied by a remote peer (used to block local mount interactions). +bool IsOccupiedByOther(void* actor, uint8_t* outOccupantSlot) { + if (!actor) return false; + const uint8_t localSlot = coop::players::Registry::Get().LocalPeerId(); + for (const auto& kv : g_atvs) { + if (kv.second.actor == actor) { + const uint8_t occ = kv.second.occupantSlot; + if (occ != 0xFF && occ != localSlot) { + if (outOccupantSlot) *outOccupantSlot = occ; + return true; + } + return false; + } + } + return false; +} + +} // namespace coop::atv_sync \ No newline at end of file diff --git a/src/votv-coop/src/coop/interactables/device_occupancy.cpp b/src/votv-coop/src/coop/interactables/device_occupancy.cpp index c3626e11..10358e9d 100644 --- a/src/votv-coop/src/coop/interactables/device_occupancy.cpp +++ b/src/votv-coop/src/coop/interactables/device_occupancy.cpp @@ -3,6 +3,7 @@ #include "coop/interactables/device_occupancy.h" #include "coop/comms/peer_action_feed.h" // the busy-notice chat line (AnnounceDirect) +#include "coop/interactables/atv_sync.h" // IsOccupiedByOther -> ATV seating deny gate #include "coop/interactables/desk_input_sync.h" // v116: PingActiveSlot -> the desk FSM-hold #include "coop/net/session.h" #include "coop/net/wire_key_util.h" @@ -10,6 +11,7 @@ #include "coop/props/prop_sound.h" #include "ue_wrap/desk/device_screen.h" +#include "ue_wrap/devices/atv.h" // EnsureResolved / IsAtv #include "ue_wrap/engine/engine.h" #include "ue_wrap/core/game_thread.h" #include "ue_wrap/core/log.h" @@ -216,11 +218,30 @@ void OnUseInputPre(void* self, void*, void*) { if (!self) return; auto* s = g_session.load(std::memory_order_acquire); if (!s || !s->connected()) return; - if (!DS::EnsureResolved()) return; + // Only the LOCAL player processes input (puppets are unpossessed), so // `self` is the local mainPlayer_C by construction. void* aimed = ue_wrap::engine::ReadMainPlayerLookAtActor(self); if (!aimed) return; + + // ATV occupancy gate: if another peer is already seated on this ATV, deny seating. + if (ue_wrap::atv::EnsureResolved() && ue_wrap::atv::IsAtv(aimed)) { + uint8_t holder = 0xFF; + if (coop::atv_sync::IsOccupiedByOther(aimed, &holder)) { + g_memoName = L"the ATV"; + g_memoKey = L"atv"; + g_memoTime = std::chrono::steady_clock::now(); + if (DS::ClearAimForDispatch(self)) { + g_denyPending = true; + g_denyKey = L"atv"; + g_denyHolder = holder; + g_denyName = L"the ATV"; + } + return; + } + } + + if (!DS::EnsureResolved()) return; const std::wstring key = DS::ClassifyDeviceActorClaimKey(aimed); if (key.empty()) return; // not aiming at an enterable device // Aim memo -- written UNCONDITIONALLY (before the busy check): the canonical @@ -549,4 +570,4 @@ void OnDisconnect() { g_nextFsmHoldPoll = {}; // v116 } -} // namespace coop::device_occupancy +} // namespace coop::device_occupancy \ No newline at end of file