Repository navigation
[fix] atv: gate InpActEvt_use on occupantSlot to prevent multi-driver desync - #9
Merged
Merged
Conversation
pelmentor
added a commit
that referenced
this pull request
Aug 26, 2026
PR #9's own header said the occupantSlot gate means "two players cannot seat simultaneously". Simultaneous is the one case it did not cover, and the comment is why I looked. OnReliable read e.occupantSlot in the authority check and assigned it from the payload AFTER the early return. So a peer that believed itself the driver never learned a competing claim existed. Two peers mounting inside one round-trip both read a free seat, both elect themselves, and each then treats the other's stream as an echo to ignore -- the double-drive the gate exists to prevent, made PERMANENT rather than transient. The deny gate cannot see it either: at mount time each reads 0xFF. Fixed with LOWER SLOT WINS, placed before the early return -- a total order both peers already hold, no wire cost, no new state, and only a genuine claim (not 0xFF) from a peer we outrank can take the seat, so an echo can never demote us. TRACING MY OWN FIX THROUGH Tick FOUND THE DEFECT IT INTRODUCED, and it was worse than the one being fixed: the authority-lost edge cleared occupantSlot unconditionally. On a YIELD (still physically seated, just outranked) that erases the winner's claim; IsLocalOccupant is still true, so we re-claim on the very next tick and flap -- while broadcasting an AtvRelease that re-enables physics on the ATV underneath the peer who just won it. Yield and dismount now split: only a genuine dismount frees the slot and sends the release. Four smaller things: - IsLocalAuthority computed `isDriver || (!isDriver && grabber)`; the second term is dead. - IsOccupiedByOther had no IndexCurrent() guard -- a stale-gen index holds another world's ATVs, the same class as the W-2 guards already on the three receivers. - Two files lost their trailing newline. - The header now says what the gate does AND that a self-elected seat is a tie-break, NOT authority: a peer that lies about its slot is not defeated by it, and the real fix is an arbitrated claim (act-as-host). Better an honest ceiling than a comment that overclaims. README gains a Contributors table. "written by one person" became "written and directed by one person" -- a merged outside contribution makes the original claim untrue, and leaving it would be the wrong kind of tidy. EVIDENCE: cmake Release build 0 errors, 0 warnings in both touched TUs (forced recompile after the final edit -- the first build predated it). mp.py smoke PASS, both peers, exit 0; movement_ledger and intent_authority selftests ALL PASS on host and client; 0 [ERROR] and 0 HotPathGuard in either log; ATV keysHash 0xFA51F519A091499A identical host vs client and the connect-snapshot sent its 1 pose to slot 1. atv_sync.cpp 684 / device_occupancy.cpp 573 / header 85, all under the 800 soft cap. NOT hands-on, and the smoke does not exercise two peers mounting one ATV -- it proves no regression, not that the tie-break fires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSq4THte1x5s3r2vzCrDDr
pelmentor
added a commit
that referenced
this pull request
Aug 26, 2026
…and my own rows in someone else's commit The ATV lane gained a facet and the docs did not have a row for it. COOP_SYNC_PROFILES ATV goes 4 -> 5 with a seat-contention row (verdict U, evidence code -- the smoke does NOT put two peers on one ATV, so the tie-break has never been observed firing), and its late-join answer is real rather than assumed: the connect snapshot carries e.occupantSlot, so a joiner inherits the active driver (principle 8). COOP_SYNC_MAP's atv_sync row now says where the deny lives and that a self-elected seat is a TIE-BREAK, not authority. THE DEPLOYED-SHA LINE IN COOP_SYNC_MAP WENT STALE TWICE IN ONE DAY. It was corrected this morning from "the disk moved to db07cb2c" to "all four hold ac55fc7f"; by this afternoon that was false too -- the disk holds b744d990, my PR-#9 merge build, which itself predates 23481e3 and so is not current main either. Rather than write a third figure that will rot by evening, the row now says what the repeated failure teaches: a deployed SHA in a doc is a READING, not a fact, and md5sum at use time is the only trustworthy answer. Caught by the other session, who correctly did not edit a file I had uncommitted work in. A LESSON I WROTE ENDED UP IN SOMEONE ELSE'S COMMIT, and the detector is worth having. I left three LESSONS.md rows uncommitted while a build ran; the other session's /documentize staged that file and committed 941a796, which therefore carries my rows under their message. Nothing lost, nothing conflicted, neither of us noticed until my own `git diff --cached --stat` came back at 6 lines where ~50 were expected. That mismatch IS the detector, and CROSS_SESSION.md now says so -- along with the direction it had missed: the shared-file hazard is not only one you create, it is one you are exposed to, and LESSONS.md/MEMORY.md are the most shared files in the tree precisely because both sessions run /documentize over them. RULE 2 on my own work: my dedicated "a throttled log line is not an event count" lesson was a near-twin of the other session's "an instrument may answer a narrower question", which already carries my case as one of three instances. One file, not two -- mine is retired, its actionable half (read the emitter's guard; prefer a counter the line CARRIES; write ">= N lines" not "N events") folded into theirs, and the ledger row left as a pointer so the trail still resolves. Also corrected, and it is the sharper half of the reporter-build lesson: I had cited the ABSENCE of a post-fix marker as proof that arigalit's build predates 65fccd7. Absence alone is ambiguous -- a marker can be missing because the path never ran. The real proof is a POSITIVE CONTROL from the same UE_LOGI (prop_destroy_seam.cpp:137): `CLIENT suppressed KEYED` 2169 vs `CLIENT suppressed eid-only` 0, so the seam ran thousands of times and never once took the keyless branch. The fix commit's own "2,172x" cross-checks the 2169. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSq4THte1x5s3r2vzCrDDr
pelmentor
added a commit
that referenced
this pull request
Aug 30, 2026
…freezing it Arc 1 commit 1 of the C1 crutch replacement (docs/CRUTCHES.md). One axis: what a non-authoring peer DOES with an ATV. It used to freeze the rig (physics off, tick off) and teleport its ROOT ~20x/s under a LerpWindow interp. AATV_C is a five-body constraint rig whose whole visible output is suspension travel, and SetActorLocation moves the root alone -- so that teleported one body and dragged four behind it. Now: physics stays ON, the BRAIN goes off, and the receiver is CORRECTED. Velocity is written hard from the wire every packet, position error is closed by a bounded corrective velocity, and a speed-scaled threshold warps through the game's own teleportVehicle (which re-places the wheels itself). Nothing runs per frame for a mirror at all now -- it is driven only at packet arrival. THE RELEASE PATH DISSOLVES rather than getting a fix. The design's owed question was "P1/P4 must answer the release path"; the answer is that under this model there is no release. Nothing froze, so nothing un-freezes; every packet already carried the velocity; and the stream does not stop, because authorSlot 0xFF elects the HOST as the idle vehicle's syncer (MTA CUnoccupiedVehicleSync). AtvReleasePayload loses its six velocity floats and its handler becomes "clear the author". Those floats were a MEASURED divergence source, not a feature: the autonomous probe caught them handing the other peer's copy a 158 cm/s launch from an already-stale pose, which is what put it 109.9 cm away with its rig at 29.58 cm of travel against a native 2-4 cm band (docs/vehicles/ATV.md 13.4). Measured this session, and it is why the collision guard is in THIS commit rather than a later one: all seven of ATV_C's ComponentHit delegates author real state, and they are dispatched by the physics scene, not the tick, so SetActorTickEnabled does not reach them. impulse() does health -= |NormalImpulse|/500000*2*getBumperMult() and calls explode() at <=0; processTire() burns tire durability and ejectWheel()s at 0. Turning physics on without gating them would let a mirror blow up a vehicle its authority still has. They are cancelled PRE-dispatch on a non-owner, and the lane FAILS CLOSED: without all seven registered it stays inert and says so. The interceptor was chosen over setting bNotifyRigidBodyCollision on the components, reversing my own first recommendation, because two of the seven components (car1_Capsule, car1_frontWheelRoot) are not in ATV.uasset's name table at all -- a flag guard would silently have covered five of seven, which is the shape of the root-only crutch it would have replaced. The seven UFunction names have no such ambiguity. kMaxInterceptors 40 -> 56 (registration-time only, 16 B/slot). Two predicates, never one. IsPoseAuthor decides who streams; OwnsTick (that, or "I am the host and nobody authors it") decides whose machine runs the brain. They differ on the host, and fusing them is the PR #9 defect. occupantSlot stays a separate byte from authorSlot for the same reason -- it is the SEAT that device_occupancy's E-press deny reads, and a peer merely GRABBING an ATV must not deny a seat nobody is in. A departed peer can no longer hold an ATV hostage: authorSlot is what elects the idle syncer, so one stuck on a gone slot would mean nobody ever runs that rig again. The lane subscribes to the roster's slot-replaced fanout (slots recycle, so this cannot hang off a per-slot boolean of ours). The idle syncer costs nothing when parked: 5 Hz ceiling AND the CUnoccupiedVehicleSync change gate, so a stationary ATV sends zero packets. Wire, proto 145 -> 146: AtvStatePayload 60 -> 84 B (+linVel[3] +angVel[3] +authorSlot, stateBits bit3 `authored` deleted -- it said only WHETHER, and nothing freezes now); AtvReleasePayload 56 -> 32 B. The probe gains the field whose absence left ATV.md 11.1 open: `owns=` per sample, read through atv_sync's own published set rather than recomputed. tools/atv_probe_report.py gains a machine ACCEPTANCE (rig travel inside the native band while mirroring, the two copies settling together, the guard armed, one owner per ATV per second) and atv_probe_report_drill.py proves it can FAIL -- 6/6 arms, all four RED arms built from the b145 numbers, plus a control and an "idle run is INCONCLUSIVE, not PASS" arm. NOT SMOKED: a concurrent session has held the game continuously. Build Release clean, islive/minhook/registry gates PASS, acceptance drill 6/6. atv_sync.cpp is now 909 LOC, over the 800 soft cap -- extraction of the v77 synthetic-key identity lane proposed as the next commit. Design: research/findings/vehicles/votv-ATV-arc1-mirror-model-IMPL-2026-08-29.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER1xHJgdZzr62Y95Vt8spB
pelmentor
added a commit
that referenced
this pull request
Sep 4, 2026
A second peer mounting an ATV somebody is already driving made both engines run vehicle physics on the same body and fight over its transform. arigalit's fix tracks an occupantSlot per indexed ATV and denies the mount at the INPUT seam -- device_occupancy::OnUseInputPre calls ClearAimForDispatch so the native seating logic never runs, with the standard deny click and a busy line in the feed. That is the right shape for this project: a client-side PRODUCER suppression, not a receive gate, which is what COOP_SYNCER_MODEL section 2b requires of a lane like this. Taken as authored; my corrections follow in the next commit rather than being folded into this merge, so the history shows what arigalit wrote. Co-Authored-By: arigalit <319897120+arigalit@users.noreply.github.com> Claude-Session: https://claude.ai/code/session_01PSq4THte1x5s3r2vzCrDDr
pelmentor
added a commit
that referenced
this pull request
Sep 4, 2026
PR #9's own header said the occupantSlot gate means "two players cannot seat simultaneously". Simultaneous is the one case it did not cover, and the comment is why I looked. OnReliable read e.occupantSlot in the authority check and assigned it from the payload AFTER the early return. So a peer that believed itself the driver never learned a competing claim existed. Two peers mounting inside one round-trip both read a free seat, both elect themselves, and each then treats the other's stream as an echo to ignore -- the double-drive the gate exists to prevent, made PERMANENT rather than transient. The deny gate cannot see it either: at mount time each reads 0xFF. Fixed with LOWER SLOT WINS, placed before the early return -- a total order both peers already hold, no wire cost, no new state, and only a genuine claim (not 0xFF) from a peer we outrank can take the seat, so an echo can never demote us. TRACING MY OWN FIX THROUGH Tick FOUND THE DEFECT IT INTRODUCED, and it was worse than the one being fixed: the authority-lost edge cleared occupantSlot unconditionally. On a YIELD (still physically seated, just outranked) that erases the winner's claim; IsLocalOccupant is still true, so we re-claim on the very next tick and flap -- while broadcasting an AtvRelease that re-enables physics on the ATV underneath the peer who just won it. Yield and dismount now split: only a genuine dismount frees the slot and sends the release. Four smaller things: - IsLocalAuthority computed `isDriver || (!isDriver && grabber)`; the second term is dead. - IsOccupiedByOther had no IndexCurrent() guard -- a stale-gen index holds another world's ATVs, the same class as the W-2 guards already on the three receivers. - Two files lost their trailing newline. - The header now says what the gate does AND that a self-elected seat is a tie-break, NOT authority: a peer that lies about its slot is not defeated by it, and the real fix is an arbitrated claim (act-as-host). Better an honest ceiling than a comment that overclaims. README gains a Contributors table. "written by one person" became "written and directed by one person" -- a merged outside contribution makes the original claim untrue, and leaving it would be the wrong kind of tidy. EVIDENCE: cmake Release build 0 errors, 0 warnings in both touched TUs (forced recompile after the final edit -- the first build predated it). mp.py smoke PASS, both peers, exit 0; movement_ledger and intent_authority selftests ALL PASS on host and client; 0 [ERROR] and 0 HotPathGuard in either log; ATV keysHash 0xFA51F519A091499A identical host vs client and the connect-snapshot sent its 1 pose to slot 1. atv_sync.cpp 684 / device_occupancy.cpp 573 / header 85, all under the 800 soft cap. NOT hands-on, and the smoke does not exercise two peers mounting one ATV -- it proves no regression, not that the tie-break fires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSq4THte1x5s3r2vzCrDDr
pelmentor
added a commit
that referenced
this pull request
Sep 4, 2026
…and my own rows in someone else's commit The ATV lane gained a facet and the docs did not have a row for it. COOP_SYNC_PROFILES ATV goes 4 -> 5 with a seat-contention row (verdict U, evidence code -- the smoke does NOT put two peers on one ATV, so the tie-break has never been observed firing), and its late-join answer is real rather than assumed: the connect snapshot carries e.occupantSlot, so a joiner inherits the active driver (principle 8). COOP_SYNC_MAP's atv_sync row now says where the deny lives and that a self-elected seat is a TIE-BREAK, not authority. THE DEPLOYED-SHA LINE IN COOP_SYNC_MAP WENT STALE TWICE IN ONE DAY. It was corrected this morning from "the disk moved to db07cb2c" to "all four hold ac55fc7f"; by this afternoon that was false too -- the disk holds b744d990, my PR-#9 merge build, which itself predates c99feca and so is not current main either. Rather than write a third figure that will rot by evening, the row now says what the repeated failure teaches: a deployed SHA in a doc is a READING, not a fact, and md5sum at use time is the only trustworthy answer. Caught by the other session, who correctly did not edit a file I had uncommitted work in. A LESSON I WROTE ENDED UP IN SOMEONE ELSE'S COMMIT, and the detector is worth having. I left three LESSONS.md rows uncommitted while a build ran; the other session's /documentize staged that file and committed fc612f2, which therefore carries my rows under their message. Nothing lost, nothing conflicted, neither of us noticed until my own `git diff --cached --stat` came back at 6 lines where ~50 were expected. That mismatch IS the detector, and CROSS_SESSION.md now says so -- along with the direction it had missed: the shared-file hazard is not only one you create, it is one you are exposed to, and LESSONS.md/MEMORY.md are the most shared files in the tree precisely because both sessions run /documentize over them. RULE 2 on my own work: my dedicated "a throttled log line is not an event count" lesson was a near-twin of the other session's "an instrument may answer a narrower question", which already carries my case as one of three instances. One file, not two -- mine is retired, its actionable half (read the emitter's guard; prefer a counter the line CARRIES; write ">= N lines" not "N events") folded into theirs, and the ledger row left as a pointer so the trail still resolves. Also corrected, and it is the sharper half of the reporter-build lesson: I had cited the ABSENCE of a post-fix marker as proof that arigalit's build predates 39eb379. Absence alone is ambiguous -- a marker can be missing because the path never ran. The real proof is a POSITIVE CONTROL from the same UE_LOGI (prop_destroy_seam.cpp:137): `CLIENT suppressed KEYED` 2169 vs `CLIENT suppressed eid-only` 0, so the seam ran thousands of times and never once took the keyless branch. The fix commit's own "2,172x" cross-checks the 2169. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSq4THte1x5s3r2vzCrDDr
pelmentor
added a commit
that referenced
this pull request
Sep 4, 2026
…freezing it Arc 1 commit 1 of the C1 crutch replacement (docs/CRUTCHES.md). One axis: what a non-authoring peer DOES with an ATV. It used to freeze the rig (physics off, tick off) and teleport its ROOT ~20x/s under a LerpWindow interp. AATV_C is a five-body constraint rig whose whole visible output is suspension travel, and SetActorLocation moves the root alone -- so that teleported one body and dragged four behind it. Now: physics stays ON, the BRAIN goes off, and the receiver is CORRECTED. Velocity is written hard from the wire every packet, position error is closed by a bounded corrective velocity, and a speed-scaled threshold warps through the game's own teleportVehicle (which re-places the wheels itself). Nothing runs per frame for a mirror at all now -- it is driven only at packet arrival. THE RELEASE PATH DISSOLVES rather than getting a fix. The design's owed question was "P1/P4 must answer the release path"; the answer is that under this model there is no release. Nothing froze, so nothing un-freezes; every packet already carried the velocity; and the stream does not stop, because authorSlot 0xFF elects the HOST as the idle vehicle's syncer (MTA CUnoccupiedVehicleSync). AtvReleasePayload loses its six velocity floats and its handler becomes "clear the author". Those floats were a MEASURED divergence source, not a feature: the autonomous probe caught them handing the other peer's copy a 158 cm/s launch from an already-stale pose, which is what put it 109.9 cm away with its rig at 29.58 cm of travel against a native 2-4 cm band (docs/vehicles/ATV.md 13.4). Measured this session, and it is why the collision guard is in THIS commit rather than a later one: all seven of ATV_C's ComponentHit delegates author real state, and they are dispatched by the physics scene, not the tick, so SetActorTickEnabled does not reach them. impulse() does health -= |NormalImpulse|/500000*2*getBumperMult() and calls explode() at <=0; processTire() burns tire durability and ejectWheel()s at 0. Turning physics on without gating them would let a mirror blow up a vehicle its authority still has. They are cancelled PRE-dispatch on a non-owner, and the lane FAILS CLOSED: without all seven registered it stays inert and says so. The interceptor was chosen over setting bNotifyRigidBodyCollision on the components, reversing my own first recommendation, because two of the seven components (car1_Capsule, car1_frontWheelRoot) are not in ATV.uasset's name table at all -- a flag guard would silently have covered five of seven, which is the shape of the root-only crutch it would have replaced. The seven UFunction names have no such ambiguity. kMaxInterceptors 40 -> 56 (registration-time only, 16 B/slot). Two predicates, never one. IsPoseAuthor decides who streams; OwnsTick (that, or "I am the host and nobody authors it") decides whose machine runs the brain. They differ on the host, and fusing them is the PR #9 defect. occupantSlot stays a separate byte from authorSlot for the same reason -- it is the SEAT that device_occupancy's E-press deny reads, and a peer merely GRABBING an ATV must not deny a seat nobody is in. A departed peer can no longer hold an ATV hostage: authorSlot is what elects the idle syncer, so one stuck on a gone slot would mean nobody ever runs that rig again. The lane subscribes to the roster's slot-replaced fanout (slots recycle, so this cannot hang off a per-slot boolean of ours). The idle syncer costs nothing when parked: 5 Hz ceiling AND the CUnoccupiedVehicleSync change gate, so a stationary ATV sends zero packets. Wire, proto 145 -> 146: AtvStatePayload 60 -> 84 B (+linVel[3] +angVel[3] +authorSlot, stateBits bit3 `authored` deleted -- it said only WHETHER, and nothing freezes now); AtvReleasePayload 56 -> 32 B. The probe gains the field whose absence left ATV.md 11.1 open: `owns=` per sample, read through atv_sync's own published set rather than recomputed. tools/atv_probe_report.py gains a machine ACCEPTANCE (rig travel inside the native band while mirroring, the two copies settling together, the guard armed, one owner per ATV per second) and atv_probe_report_drill.py proves it can FAIL -- 6/6 arms, all four RED arms built from the b145 numbers, plus a control and an "idle run is INCONCLUSIVE, not PASS" arm. NOT SMOKED: a concurrent session has held the game continuously. Build Release clean, islive/minhook/registry gates PASS, acceptance drill 6/6. atv_sync.cpp is now 909 LOC, over the 800 soft cap -- extraction of the v77 synthetic-key identity lane proposed as the next commit. Design: research/findings/vehicles/votv-ATV-arc1-mirror-model-IMPL-2026-08-29.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER1xHJgdZzr62Y95Vt8spB
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When a second peer mounts an ATV while another player is already driving, both engines run vehicle physics and fight over transform authority, causing severe desync. This gates ATV interaction to a single occupant.
Details
atv_sync: Track incomingoccupantSlotinAtvEntryand exposeIsOccupiedByOther(actor, &slot).device_occupancy: InOnUseInputPre, detect when the aimed actor is anAATV_Coccupied by another peer. CallDS::ClearAimForDispatch()so the native seating logic never runs.OnUseInputPost, fire the standard deny click sound and push the"<Player> is using the ATV"notice to the chat feed.occupantSlot == LocalSlot()) and release vehicle authority on exit.