Skip to content

[fix] atv: gate InpActEvt_use on occupantSlot to prevent multi-driver desync - #9

Merged
pelmentor merged 2 commits into
VOTV-MP:mainfrom
arigalit:main
Aug 26, 2026
Merged

pelmentor merged 2 commits into
VOTV-MP:mainfrom
arigalit:main

Conversation

@arigalit

Copy link
Copy Markdown
Contributor

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 incoming occupantSlot in AtvEntry and expose IsOccupiedByOther(actor, &slot).
  • device_occupancy: In OnUseInputPre, detect when the aimed actor is an AATV_C occupied by another peer. Call DS::ClearAimForDispatch() so the native seating logic never runs.
  • Feedback: In OnUseInputPost, fire the standard deny click sound and push the "<Player> is using the ATV" notice to the chat feed.
  • Dismount: Drivers still pass the gate cleanly (occupantSlot == LocalSlot()) and release vehicle authority on exit.

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
pelmentor merged commit ca89cc1 into VOTV-MP:main Aug 26, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants