Skip to content

Add native support for magnets#561

Open
freezy wants to merge 40 commits into
masterfrom
feature/magnets
Open

Add native support for magnets#561
freezy wants to merge 40 commits into
masterfrom
feature/magnets

Conversation

@freezy

@freezy freezy commented Jul 5, 2026

Copy link
Copy Markdown
Owner

This PR adds native VPE support for magnetic mechanisms, including radial magnets, center-grab magnets, runtime repel behavior, and VPX-style spinning turntables.

  • Add MagnetComponent, runtime state, physics integration, packaging, prefab, inspector, and table API access.
  • Support attract, repel, center grab/hold, grab release events, and destroyed-ball cleanup.
  • Add VPX-compatible magnet force behavior matching cvpmMagnet.
  • Add TurntableComponent, physics, packaging, inspector, table API access, and visual rotation support.
  • Add editor detection for script-defined magnets and turntables, including constant-based initialization values.
  • Route wire destinations to magnet coils.
  • Store magnet and turntable positions relative to the playfield transform.
  • Add authoring documentation for magnets and turntables.
  • Add physics tests for magnet force, grab/release behavior, repel behavior, and turntable motion.

freezy added 18 commits July 5, 2026 21:02
Introduce the magnet component, API, native state map, VPX-compatible radial force update, editor inspector, toolbox prefab, and focused force-scaling coverage for the first magnet milestone.
Clamp grabbed balls to the magnet core in VPX-compatible mode, emit grab and release transitions, drive the ball-held switch, and make ReleaseBall suppress immediate recapture until the ball leaves the grab radius.
Route Strength and Radius setters through the physics state, keep negative strength as first-class repulsion, and add an eject helper that releases grabbed balls with a directed kick.
Add a review-first editor tool that scans VPX table scripts for cvpmMagnet patterns, creates selected MagnetComponent instances from trigger data, maps solenoid coils, and flags turntable or manual-review cases.
Add TurntableComponent with native physics state, motor and direction coils, VPX-compatible tangential force, visual rotation support, detector creation, editor inspector, and focused physics coverage.
Implement saturated inverse-square magnet force with velocity damping and a capped spring-damper hold path for physical grabs. Add focused coverage for force saturation, negative-strength repel, and non-teleporting hold behavior.
Document native magnet and turntable setup, force profiles, coil and switch items, script API usage, and VPX detection guidance. Add the page to the creators-guide mechanisms navigation.

docs(magnets): keep guide authoring-focused

Remove the script control section from the magnet documentation and keep the usage guidance centered on editor setup, coil mapping, and force profile selection.

docs(magnets): narrow compatibility guidance

Remove the Terminator 2 and broad VPX script wording from the VPX Compatible profile guidance, keeping the section focused on imported magnet behavior.
The VPX-compatible profile computed ratio = dist/Size, but core.vbs uses
dist/(1.5*Size), which made imported magnets about twice as weak over most
of the influence radius. Also damp the impulse together with the old
velocity, as core.vbs does, and add a parity test that encodes the
core.vbs constants numerically instead of asserting against our own
implementation.
Destroying a ball only removed it from InsideOfs, which frees its bit
index for reuse while MagnetState.GrabbedBalls/ReleasedBalls kept the
stale bit. The next ball assigned that index was treated as already
grabbed (no BallGrabbed event) or regrab-suppressed, and since no release
event ever fired, MagnetApi's ball_held switch stayed closed for the rest
of the session. Clear the bits and enqueue the release event before the
bit index is freed; both unregister paths run under the physics lock in
external-timing mode, so the queue write is safe.
Both components advertise their coils as wire destinations, but the APIs
did not implement IApiWireDeviceDest, so Player never registered them
with the wire player and wires targeting magnet_coil, motor_coil or
direction_coil silently did nothing (e.g. a MagnaSave button wired
straight to a magnet). Forward Wire() to the coil lookup like flippers
and kickers do.
The tangential force only checked planar distance, so balls on ramps or
wireforms crossing above the disc got curved sideways. VPX avoided this
implicitly because cvpmTurntable membership came from a z-bounded
trigger. Add the same height window the magnet already has, packed and
editable in the inspector.
RotationAngle advanced by Speed revolutions per second, so a VPX-typical
speed of 90 spun the visual disc at 5400 RPM. Speed is a force scale,
not a rotation rate; map it to degrees per second through an authorable
VisualSpeedFactor (default: 60 RPM at speed 90).
PackReferences returned an empty payload, so the RotationTarget binding
was silently lost on every package save/reload and the disc stopped
rotating. Round-trip it like the teleporter does with its kickers; plain
Transform references are now registered in PackagedRefs so any component
can pack them.
TurntableApi had no name/component dictionary in TableApi, so
registration was silently dropped and turntables were the only device
unreachable by name. Add the dictionaries and Turntable() accessors,
matching the magnet.
CreateState converted transform.localPosition with the static
world-to-VPX matrix, which is only correct while every ancestor up to
the playfield sits at identity. Re-parenting the magnet (e.g. under its
trigger or a moved group) silently shifted the force field away from
the world-space gizmo. Convert the world position relative to the
playfield transform instead; turntables share the helper.
The InitMagnet/InitTurnTable regexes only matched numeric literals, so
scripts passing a named constant (GOTG: 'kOrbMagnetPower', Ramones:
'discspeed') were silently dropped from the scan. Match identifiers too
and create the device with a default plus a manual-review note. Also
align the turntable ramp defaults with cvpmTurntable (SpinUp 10,
SpinDown 4 units/sec) — the previous 600/600 spun discs up 60-150x
faster than VPX.
Hoist the per-ball math.pow out of the 1 kHz ball loop, skip the 64-bit
release scan and membership lookup for idle magnets, and skip grab
bookkeeping (hashmap lookups, bit index allocation) for plain attraction
magnets that can never grab. Replace the BallEventArgs duplicate with
the existing HitEventArgs and use Unity.Mathematics instead of Mathf.
PhysicsEngine.cs does not import VisualPinball.Engine.Game, so the
magnet release event id added for destroyed balls needs the qualified
name to compile.
@freezy freezy self-assigned this Jul 5, 2026
@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds native magnet and turntable support. The main changes are:

  • New magnet and turntable components, APIs, state, physics, packaging, and inspectors.
  • Runtime coil integration for magnet strength, repel behavior, center grab, and turntable direction control.
  • Editor detection for script-defined magnets and turntables.
  • Prefabs, icons, documentation, and physics tests for the new mechanisms.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs Adds turntable authoring and runtime behavior, including visual rotation relative to the authored target rotation.
VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs Adds editor detection and creation for script-defined magnets and turntables.
VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs Updates coil dispatch so mapped destinations can receive normalized coil values.

Reviews (3): Last reviewed commit: "doc(magnets): document ROM-modulated (PW..." | Re-trigger Greptile

Comment thread VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs Outdated
freezy added 9 commits July 5, 2026 23:05
Keep the optional rotation target's authored local rotation and apply the simulated turntable spin as a relative delta.
Marshal bool fields in magnet and turntable physics state structs so Burst direct calls can pass them by reference.
Add a dedicated magnet icon and wire it up in the toolbox and icon
registry, replacing the reused coil icon. Assign the icon to the
MagnetComponent script. Also add new physics-thread, shortcut and
tilt-bob icons, and a HelpURL to the tilt bob component.
Update the live magnet physics state from play-mode inspector edits and render the height range gizmo as a cylinder.
Generalize kinematic transform tracking beyond colliders and let magnets and turntables follow moving transforms during gameplay. Carry grabbed balls with magnet velocity and document the new authoring toggle.
Collider components resolve their item id from the main component's
GameObject while magnets and turntables use their own, so two kinematic
components on one GameObject silently overwrote each other in the
transform-callback map — the losing component (e.g. a kicker keeping its
capture center current) stopped receiving OnTransformationChanged. Warn
at registration and name both components. Also make the startup log
honest again: the kinematic count now includes colliderless transforms,
so report kinematic colliders and kinematic transforms separately.
The kinematic-octree dirty flag was set for every changed kinematic
transform, so a continuously moving colliderless magnet or turntable
forced RebuildOctree — swept AABBs for every kinematic collider on the
table — each tick batch despite no collider having moved. Only mark the
octree dirty when the item actually owns kinematic colliders; before
colliderless items entered this path, the flag was justified by
construction.
The kinematic refresh tracks the transform's height, but grab carry is
planar-only — a magnet on a lifting mech raised its height window past
the held ball's z and silently released it mid-carry. A grabbed ball now
stays held regardless of the range check; only the grab logic itself may
release it. Adds a PhysicsState test harness so tests can drive the real
update/state wiring, and documents that the carry is planar.
freezy added 13 commits July 6, 2026 10:25
Eject() overwrote the ball velocity absolutely, erasing the kinematic
carrier velocity the grab path maintains — firing from a moving magnet
(the T2 cannon case) behaved as if the magnet were stationary, unlike
releasing the coil mid-move. Add the magnet's kinematic velocity to the
eject impulse; zero for stationary magnets.
Kinematic registration and transform seeding happen once at startup, so
toggling Is Kinematic in play mode flipped only the state flag: enabling
was a silent no-op (never seeded or scanned) and disabling left the scan
running against a frozen field. Disable the checkbox during play instead
of pretending it works.
The inspector-sync fix covered only magnets, so play-mode edits of
turntable fields silently kept the boot-time values while the magnet
next to it responded immediately. Mirror the sync: rebuild the state
from the authored fields on OnValidate, preserving the runtime-owned
motor, direction, speed and rotation angle, and recompute the target
speed the way the API setters do.
SyncPhysicsState re-derived every converted field of CreateState by
hand, so any new magnet parameter would import correctly but silently
stop syncing from the inspector. Build the synced state through
CreateState and preserve only the runtime-owned fields (coil state and
grab bookkeeping), matching the turntable sync.
Re-capturing the visual baseline from a disc that was already driven
baked the accumulated spin into the baseline, compounding an angular
offset on every retarget and leaving the old disc frozen mid-spin.
Restore the previous target to its authored pose before capturing the
new one, so retargeting round-trips cleanly.
The magnet's kinematic velocity duplicated the step-substitution core of
GetKinematicSurfaceVelocity token for token — load-bearing anti-swallow
logic that would silently drift apart when tuned. Extract an item-level
GetKinematicVelocityAt on PhysicsState and let both the collider surface
path and the magnet field path use it.
Disabled kinematic magnets paid two hashmap lookups and velocity math
per tick for a result the early-out discarded; refresh the pose after
the enabled check instead. Replace the three test-only zero-velocity
forwarding overloads with defaulted parameters so no caller can silently
drop the carrier velocity, scan the hierarchy once at startup (every
collidable is a kinematic transform component), and cover the kinematic
gating and velocity plumbing with wiring tests — the code paths both
review findings lived in.
GetValueByRef in the new inspector sync is an extension method from
VisualPinball.Unity.Collections.
The PhysicsState test harness holds a NativeOctree field for the state
constructor, which lives in the com.bartofzo.nativetrees assembly.
Support 3-D magnet attraction and rigid grab/carry behavior for mech-mounted ball pickups, including vertical ejects, editor/docs coverage, and focused physics tests.
The spatial magnet held its ball by freezing it (BallState.IsFrozen) and
clamping its position each tick. Reusing IsFrozen — which the engine
treats as visually static and collision-exempt — for a moving carried
ball made the ball invisible (transform sync skips frozen balls), sink
one radius (kicker-hole render hack), and let other balls phase through
it, and the hold could never be overcome.

Hold the ball with a strong 3-D critically-damped spring instead (the
spatial analog of ApplyPhysicalHold), leaving it a live physics object:
it renders and collides normally, and because the spring impulse is
capped at Strength*dt, a hard enough hit knocks it loose — the ball then
leaves the grab radius and is released, and the ball that hit it can be
grabbed in turn. Release is now just dropping the hold (the ball keeps
its live velocity), so spatial and playfield release collapse to one
path, and the two grab methods unify into one. Also fixes the stale pose
on kinematic eject, drops the dead per-tick vpxDamping and Profile
coercion for spatial magnets, and rewrites the spatial tests for the
force model (grab-without-freeze, moving-hold tracking, knock-loose
release, coil-off release).
Add a float value to CoilEventArgs and IApiCoil.OnCoil(float), normalized
to [0..1] — 0/1 for plain on/off coils, or the duty-cycle strength for
PWM-integrated (modulated) coils. DeviceCoil routes it through an optional
OnValue action; CoilPlayer dispatches the value; on/off consumers are
unchanged (value > 0). The magnet consumes it to scale its strength by the
duty cycle, so a ROM that PWMs a magnet coil (e.g. Iron Man) drives a
proportional pull instead of full-or-nothing.
@freezy

freezy commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

@greptile-apps please review.

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.

1 participant