Entities/Player.cs
using System.Runtime.CompilerServices;

namespace BlockParty;

public enum PlayerAnimType
{
	Idle, Walk, Crouch, AirUp, AirUpActive, AirDown, AirDownActive, AirFallFast, WallUp, WallDown,
	Climb, CeilingCling, CeilingClingMove,
	Charge0, Charge1, Charge2, Charge3, Charge4,
	ChargeLeft0, ChargeLeft1, ChargeLeft2, ChargeLeft3, ChargeLeft4,
	ChargeRight0, ChargeRight1, ChargeRight2, ChargeRight3, ChargeRight4,
	GunShootSide, GunShootUp, GunShootDown,
	GunShootSideEmpty, GunShootUpEmpty, GunShootDownEmpty,
	GunReload, Hardened
}

/// <summary>
/// The player character — a faithful port of the original <c>Player</c> (GameAPI.BudgetBoy).
/// Physics constants, update order, collision, wall-jump, crush and death are preserved.
///
/// Driven by <see cref="Entity2D.Tick"/> at a fixed 60Hz (called by GameStage). Input comes
/// from the per-frame <see cref="InputState"/> snapshot. Rendering uses one child
/// <see cref="SpriteRenderer"/> whose animation/flip is updated each step.
/// </summary>
public sealed class Player : Entity2D
{
	public GameStage Stage { get; set; }

	// Collision box is 8x10 (smaller than the 10x12 art).
	public static readonly Vector2 COLLISION_SIZE = new Vector2( 8, 10 );

	// SpriteRenderer.Size is treated by the engine as a SQUARE bounding box that the texture's
	// own aspect ratio is fit inside (see the note in GameStage.SpawnArena). Passing the literal
	// non-square 10x12 makes the engine size the square from the SHORTER edge (10), scaling the
	// whole sprite down by 10/12 — that ~0.83px horizontal shrink is what left the player looking
	// ~1px off the side walls. Pass a square = the LONG edge so the 10x12 art renders 1:1, leaving
	// the 8px-wide visible body flush with the 8px-wide collision box (and the walls).
	public static readonly Vector2 ART_SIZE = new Vector2( 12, 12 );

	private SpriteRenderer _sprite;
	internal SpriteRenderer BodySpriteRenderer => _sprite;
	// Head direction the body sprite is rolled to (feet-to-surface poses); null = upright.
	private Vector2? _spriteHead;
	private SpriteRenderer _stickyPlayerGoo;
	private SpriteRenderer _chargeAimIndicator;
	private CharacterDef _activeCharacter;
	public bool IsHardened { get; private set; }
	public bool IsTwinDashing { get; private set; }
	private bool _twinDashEndedThisTick;
	private bool _rewindDeathRecoveredThisTick;

	/// <summary>The character this player is (art + movement feel). Set at spawn (before
	/// <see cref="CreateVisuals"/>) from the run context; defaults to the original so the player is
	/// always valid even if spawned without a character.</summary>
	public CharacterDef Character { get; set; } = Characters.Original;

	/// <summary>This character's movement tuning, resolved in <see cref="CreateVisuals"/>. The ALL-CAPS
	/// movement values below read from here so a character can vary the feel without touching physics.</summary>
	private CharacterMovement _move = CharacterMovement.Original;

	/// <summary>This character's ability toggles (bounce/dash/wall-kick/wall-dive, arena-wall
	/// solidity, edge-wrap), resolved in <see cref="CreateVisuals"/>. Gates the corresponding
	/// behaviours in the shared physics below.</summary>
	private CharacterAbilities _abilities = CharacterAbilities.Original;

	/// <summary>Pluggable per-character ability modules (see <see cref="PlayerAbility"/>), built from
	/// the character in <see cref="CreateVisuals"/> and ticked at the end of <see cref="Tick"/>.</summary>
	private readonly List<PlayerAbility> _abilityModules = new();

	/// <summary>Solar's stored charge, normalised to 0..1. It remains on the player when temporarily
	/// transformed into another character, so returning to Solar restores the previous charge.</summary>
	public float Energy { get; internal set; } = 1f;
	internal float SolarMovementFactor { get; set; } = 1f;
	internal float SolarJumpFactor { get; set; } = 1f;
	internal float SolarAirControlFactor { get; set; } = 1f;
	internal float SolarDashFactor { get; set; } = 1f;
	internal float SolarWallDiveFactor { get; set; } = 1f;
	internal float SolarAirJumpFactor { get; set; } = 1f;
	internal float SolarWallClingFactor { get; set; } = 1f;
	internal float SolarBounceFactor { get; set; } = 1f;
	internal bool SolarInSunlight { get; set; }
	float _gunnerReloadMovementFactor = 1f;
	float? _gunnerAirFrictionFactor;

	// Movement feel — driven by the character (see CharacterMovement). Kept as ALL-CAPS properties so
	// every physics call site below reads them exactly as it did when they were consts.
	float MAX_X_SPEED => _move.MaxXSpeed * SolarMovementFactor * _gunnerReloadMovementFactor;
	float MAX_RISE_SPEED => _move.MaxRiseSpeed;
	float MAX_FALL_SPEED => _move.MaxFallSpeed;
	float HORIZONTAL_ACCELERATION => _move.HorizontalAcceleration * SolarMovementFactor * _gunnerReloadMovementFactor;
	float HORIZONTAL_DECELERATION => _move.HorizontalDeceleration;
	float GROUND_FRICTION_FACTOR => _move.GroundFrictionFactor;
	float AIR_FRICTION_FACTOR => _gunnerAirFrictionFactor ?? _move.AirFrictionFactor;
	float WIND_CATCH_FACTOR => _move.WindCatchFactor;
	float SIREN_CATCH_FACTOR => _move.SirenCatchFactor;
	float WALL_SLIDE_MAX_FALL_SPEED => _move.WallSlideMaxFallSpeed;
	float JUMP_HORIZ_EXIT_BOOST => _move.JumpHorizExitBoost;
	float AIR_ACCEL_FACTOR => _move.AirAccelerationFactor;
	float TURNAROUND_DECELERATION => _move.TurnaroundDeceleration;
	float LANDING_MOMENTUM_RETENTION => _move.LandingMomentumRetention;
	int MAX_AIR_JUMPS => _abilities.MaxAirJumps;
	float AIR_JUMP_POWER => (_abilities.AirJumpPower < 0f ? _move.JumpPower : _abilities.AirJumpPower) * SolarAirJumpFactor;
	bool VARIABLE_JUMP_HEIGHT => _abilities.VariableJumpHeight;
	float VARIABLE_JUMP_CUT_FACTOR => _abilities.VariableJumpCutFactor;
	int WALL_CLING_FRAMES => (int)MathF.Round( _abilities.WallClingFrames * SolarWallClingFactor );
	bool CAN_CEILING_CLING => _abilities.CanCeilingCling;
	float FALL_DAMAGE_IMPACT_SPEED => _abilities.FallDamageImpactSpeed;
	bool CAN_LONG_JUMP => _abilities.CanLongJump;
	float LONG_JUMP_HEIGHT_FACTOR => _abilities.LongJumpHeightFactor;
	float LONG_JUMP_HORIZONTAL_BOOST => _abilities.LongJumpHorizontalBoost;
	bool CAN_BACK_FLIP => _abilities.CanBackFlip;
	float BACK_FLIP_HEIGHT_FACTOR => _abilities.BackFlipHeightFactor;
	float BACK_FLIP_HORIZONTAL_BOOST => _abilities.BackFlipHorizontalBoost;
	float BACK_FLIP_BACKWARD_FORCE => _abilities.BackFlipBackwardForce;
	float BACK_FLIP_BACKWARD_DURATION => _abilities.BackFlipBackwardDuration;
	float RISE_GRAVITY_FACTOR => _move.RiseGravityFactor;
	float FALL_GRAVITY_FACTOR => _move.FallGravityFactor;
	float MAX_AIR_X_SPEED => _move.MaxAirXSpeed < 0f ? MAX_X_SPEED : _move.MaxAirXSpeed;
	// Horizontal movement feels GROUNDED (tight: full accel/brake + the ground speed cap) rather than
	// floaty air steering when we're actually on the ground OR anchored to a ceiling via ceiling-cling,
	// so a clinging character steers snugly along the block instead of drifting like they're mid-air.
	// (the attachment is last tick's value; these read before the cling updates — fine for a state flag.)
	bool HorizontalMovementIsGrounded => OnFloor || _attach == Attachment.CeilingCling;
	float DIVE_MAX_AIR_X_SPEED => _move.DiveMaxAirXSpeed;
	float DIVE_HORIZONTAL_ACCELERATION => _move.DiveHorizontalAcceleration;
	// Dive gear: airborne + holding Down (orientation-swapped, so it stays the dive key in a reverse
	// field) with a dive cap configured. Raises the air cap below and feeds the swoop acceleration in
	// HandleHorizontalInput; releasing Down drops the cap and the overspeed bleed eases the excess off.
	bool DiveGearEngaged => DIVE_MAX_AIR_X_SPEED > 0f && !HorizontalMovementIsGrounded && DownPressed;
	// The walk-speed cap for the current state: separate air vs ground caps (defaults equal). Ceiling-
	// clinging uses the ground cap too (see HorizontalMovementIsGrounded).
	float CurrentMaxXSpeed => HorizontalMovementIsGrounded ? MAX_X_SPEED
		: DiveGearEngaged ? Math.Max( MAX_AIR_X_SPEED, DIVE_MAX_AIR_X_SPEED ) : MAX_AIR_X_SPEED;
	// Rate (px/s²) that over-cap horizontal speed eases back to the cap when the air/ground cap drops
	// below current speed (see ApplyHorizontalDeceleration) — a smooth settle instead of a 1-tick snap.
	float OVERSPEED_DECAY => _move.OverspeedDecay;
	int JUMP_BUFFER_FRAMES => _move.JumpBufferFrames;
	float JUMP_SPEED_BONUS => _move.JumpSpeedBonus;
	float WALL_CLIMB_SPEED => _move.WallClimbSpeed;
	int HOVER_FRAMES => _move.HoverFrames;
	bool CAN_GROUND_POUND => _abilities.CanGroundPound;
	float GROUND_POUND_SPEED => _abilities.GroundPoundSpeed;
	bool AUTO_BOUNCE_GROUND => _abilities.AutoBounceGround;
	float AUTO_BOUNCE_GROUND_RESTITUTION => _abilities.AutoBounceGroundRestitution;
	float AUTO_BOUNCE_GROUND_MIN_SPEED => _abilities.AutoBounceGroundMinSpeed;
	float AUTO_BOUNCE_GROUND_MIN_STRENGTH => _abilities.AutoBounceGroundMinStrength;
	float AUTO_BOUNCE_GROUND_IMPACT_FEEDBACK_FACTOR => _abilities.AutoBounceGroundImpactFeedbackFactor;
	int AUTO_BOUNCE_GROUND_COMPRESSION_FRAMES => _abilities.AutoBounceGroundCompressionFrames;
	bool AUTO_BOUNCE_WALL => _abilities.AutoBounceWall;
	float AUTO_BOUNCE_WALL_RESTITUTION => _abilities.AutoBounceWallRestitution;
	float AUTO_BOUNCE_WALL_MIN_SPEED => _abilities.AutoBounceWallMinSpeed;
	float AUTO_BOUNCE_WALL_MIN_STRENGTH => _abilities.AutoBounceWallMinStrength;
	bool AUTO_BOUNCE_CEILING => _abilities.AutoBounceCeiling;
	float AUTO_BOUNCE_CEILING_RESTITUTION => _abilities.AutoBounceCeilingRestitution;
	float AUTO_BOUNCE_CEILING_MIN_SPEED => _abilities.AutoBounceCeilingMinSpeed;
	bool HAS_CHARGE_JUMP => _abilities.HasChargeJump;
	bool HAS_CHARGE_WALL_JUMP => _abilities.HasChargeWallJump;
	bool HAS_CHARGE_CEILING_JUMP => _abilities.HasChargeCeilingJump;
	bool CHARGE_JUMP_AUTO_FIRE_AT_MAX => _abilities.ChargeJumpAutoFireAtMax;
	float CHARGE_JUMP_MAX_TIME => _abilities.ChargeJumpMaxTime;
	float CHARGE_WALL_JUMP_MAX_TIME => _abilities.ChargeWallJumpMaxTime > 0f ? _abilities.ChargeWallJumpMaxTime : CHARGE_JUMP_MAX_TIME;
	float CHARGE_JUMP_MIN_SPEED => _abilities.ChargeJumpMinSpeed;
	float CHARGE_WALL_JUMP_MIN_SPEED => _abilities.ChargeWallJumpMinSpeed > 0f ? _abilities.ChargeWallJumpMinSpeed : CHARGE_JUMP_MIN_SPEED;
	float CHARGE_JUMP_MAX_SPEED => _abilities.ChargeJumpMaxSpeed;
	float CHARGE_WALL_JUMP_MAX_SPEED => _abilities.ChargeWallJumpMaxSpeed > 0f ? _abilities.ChargeWallJumpMaxSpeed : CHARGE_JUMP_MAX_SPEED;
	float CHARGE_WALL_JUMP_GRAVITY_SUPPRESS_TIME => _abilities.ChargeWallJumpGravitySuppressTime;
	float STICKY_CHARGE_RELEASE_FRACTION => _abilities.StickyChargeReleaseFraction;
	float STICKY_CHARGE_RELEASE_FRACTION_P2 => _abilities.StickyChargeReleaseFractionPhase2;
	float STICKY_CHARGE_POWER_FACTOR => _abilities.StickyChargePowerFactor;
	float STICKY_CHARGE_POWER_FACTOR_P2 => _abilities.StickyChargePowerFactorPhase2;
	float CHARGE_JUMP_MIN_ANGLE_DEG => _abilities.ChargeJumpMinAngleDeg;
	float CHARGE_JUMP_DIRECTIONAL_POSE_THRESHOLD => Math.Clamp( _abilities.ChargeJumpDirectionalPoseThreshold, 0f, 1f );
	float APEX_HANG_GRAVITY_FACTOR => _move.ApexHangGravityFactor;
	float APEX_HANG_VEL_THRESHOLD => _move.ApexHangVelThreshold;
	float AIR_JUMP_HORIZONTAL_BOOST => _abilities.AirJumpHorizontalBoost * SolarAirJumpFactor;
	float BLOCK_PRESS_BOOST => _abilities.BlockPressBoost;
	bool STICK_TO_MOVING_BLOCKS => _abilities.StickToMovingBlocks;
	// FLIPPER: the character flips its OWN gravity (persistently) instead of jumping — a grounded
	// jump/up press toggles it (see HandleGravityFlip). Gating the ground jump on this stops the same
	// press ALSO firing a normal jump. Off for every other character.
	bool CAN_FLIP_GRAVITY => _abilities.CanFlipGravity;
	bool HAS_SURFACE_GRAVITY => _abilities.HasSurfaceGravity;
	bool CAN_MANTLE => _abilities.CanLedgeGrab;
	bool CAN_WALL_HUG => _abilities.CanWallHug;
	// Whether this character can wall-jump at all. Default true; the Flipper turns it off ("can't
	// wall-jump"). Gates only the wall-jump LAUNCH, so wall-hug slide/leniency bookkeeping is untouched.
	bool CAN_WALL_JUMP => _abilities.CanWallJump;

	// additional velocity from external forces
	public float ExtraVelX { get; private set; }
	public float ExtraVelY { get; private set; }
	Vector2 _grappleReleaseVelocity;
	const float MAX_EXTRA_X_SPEED = 500.0f;
	const float MAX_EXTRA_Y_SPEED = 500.0f;
	float EXTRA_VELOCITY_HORIZONTAL_DECELERATION => _move.ExtraVelHorizontalDeceleration;
	// Flat decel (px/s²) pulling the vertical ExtraVel channel back toward zero from EITHER direction.
	// Applied symmetrically so an up-dash's positive
	// component coasts to a stop instead of parking for the whole airtime (see HandleExtraVelocity).
	float EXTRA_VELOCITY_VERTICAL_DECELERATION => _move.ExtraVelVerticalDeceleration;

	// Wind push (BlockWind's gust). A separate channel from ExtraVel so the continuous gust is
	// isolated from dash/wall/platform forces and can decay EXPONENTIALLY (a % per tick) the instant
	// the player leaves a lane, independent of the ExtraVel channels' flat linear decay. Fed each tick
	// by AddWindImpulse while the player stands in a lane; the moment they leave (not fed this tick) it
	// decays EXPONENTIALLY to zero (see HandleExtraVelocity).
	Vector2 _windVel;
	bool _windFedThisTick;                   // a wind lane fed us this tick (gates the leave-the-lane decay)
	const float WIND_TERMINAL = 78.0f;      // max wind-driven speed (well under the 500 knockback cap)
	// When we LEAVE a lane the gust decays EXPONENTIALLY (a fixed fraction per tick, framerate-independent
	// via dt) rather than by a flat px/s² amount: a strong gust (e.g. a high WindCatchFactor raising the
	// cap) then dies in the SAME short time (~0.4s) as a weak one instead of lingering for seconds. Snapped
	// to zero below a small threshold so it reaches exactly zero. Only applies while NOT fed — in-lane the
	// cap in AddWindImpulse governs the speed, so the in-lane feel is unchanged.
	const float WIND_DECAY_RATE = 12.0f;    // exponential decay rate (per second) once out of the lane
	const float WIND_STOP_THRESHOLD = 4.0f; // px/s below which the coasting gust snaps to zero
	// Wind that peels us off a block we're riding/hugging. Sideways we're ACTIVELY holding into the
	// face (input), so it takes a strong gust to overcome the hold; on top/bottom we're just resting,
	// so a gentle upward gust lifts us straight off (low bar avoids the "nudge 1px then snap back" jitter).
	const float WIND_GRIP_BREAK_SPEED = 10.0f;  // sideways hug (held into the face)
	const float WIND_LIFT_SPEED = 5.0f;        // top/bottom rest (blown off the surface)

	// Magnet pull (BlockMagnet's beam). Its OWN channel, deliberately separate from the wind channel:
	// unlike wind it ACCUMULATES over time (the accel is fed every tick and integrates up toward
	// MAGNET_TERMINAL), so a sustained pull ramps in and drags hard along the lane. The beam no longer
	// fights gravity — the sustained-field anti-gravity rule (see FIELD_LIFT_FULL_CANCEL_SPEED below)
	// cancels gravity while a field lifts — so the away-from-the-floor component is capped separately at
	// the much lower MAGNET_LIFT_TERMINAL: that cap IS the reel-up speed now, where the old raw terminal
	// was sized to out-race the fall speed gravity kept banking into VelY during the suspension. Sideways
	// and floor-ward pulls keep the full terminal (they were never fighting gravity).
	//
	// Unlike wind it does NOT gently decay: the pull is a beam, not inertia, so the instant the player
	// leaves it the whole channel is zeroed (no coasting). This is feed-gated — AddMagnetImpulse (called
	// from every overlapping magnet lane, before the player ticks) sets _magnetFedThisTick; if a whole
	// tick passes with no feed, HandleExtraVelocity clears the channel that same frame.
	Vector2 _magnetVel;
	bool _magnetFedThisTick;
	const float MAGNET_TERMINAL = 240.0f;     // max magnet-driven speed along / toward the floor
	const float MAGNET_LIFT_TERMINAL = 90.0f; // max away-from-floor pull (gravity is cancelled, so this IS the rise speed)

	// SIREN DRIFT (BlockSiren's song). Its OWN channel — deliberately NOT the magnet channel it first
	// rode: AddMagnetImpulse INTEGRATES its feed toward the 240 px/s terminal (that accumulation is the
	// beam's ramped-in hard drag), so a multi-second song silently banked several times the walk
	// speed — un-out-walkable by mid-song, and a player glued to a sticky block banked the whole song with
	// no motion to spend it on, releasing it as an untelegraphed yank. The song is a bounded DRIFT, not an
	// integrator: reset to zero at frame start (ResetStasis, before the blocks tick), each singing siren
	// contributes its envelope-scaled drift VELOCITY for this tick (ApplySirenPull), and the channel is
	// integrated into position — so the pull is always exactly what the envelope says right now, it can
	// never accumulate, and it dies the instant the song ends. Capped so overlapping songs stay bounded.
	Vector2 _sirenVel;
	const float SIREN_DRIFT_MAX = 130.0f;     // combined cap for stacked songs (one song caps itself lower)

	// SUSTAINED-FIELD ANTI-GRAVITY. The three sustained field channels above (wind gust, magnet beam,
	// siren song) used to RACE gravity: gravity kept banking fall speed into VelY the whole time a field
	// held the player aloft, and the field merely out-pulled the growing total — so leaving the field
	// unmasked the banked VelY and the player plummeted like a rock. Instead, while the channels' summed
	// vertical component points AWAY from the floor (a lift), ApplyGravity CANCELS gravity — no fall
	// speed accumulates while a field holds you, so leaving it starts a fresh natural fall from rest —
	// and bleeds off any fall speed banked BEFORE entering the field (the "catch"; with gravity cancelled
	// nothing else would ever remove it). A RISING VelY (a jump) is left alone: gravity shapes the arc
	// normally and the field takes over at the apex. The cancel scales in over the lift speed so an
	// envelope's faint tail (a near-dead gust) doesn't read as a full hover. ApplySurfaceGravity mirrors
	// the rule along the Shifter's own gravity axis.
	const float FIELD_LIFT_FULL_CANCEL_SPEED = 30.0f; // lift px/s at which gravity is fully cancelled
	const float FIELD_FALL_ARREST_FACTOR = 2.5f;      // banked fall bleeds off at this × GRAVITY (the catch)

	// SHOCKWAVE knockback (BlockShockwave's expanding ring). Its OWN channel, separate from wind/magnet/
	// ExtraVel, for one reason: when this force drives the player INTO a solid it must BOUNCE (reflect with
	// restitution) — and ONLY this force, never wind/magnet/walk. Keeping it isolated lets HandleShockwaveBounce
	// mirror exactly this component while leaving every other force untouched. A one-shot radial impulse (set
	// by ApplyShockwaveKnockback, not fed per tick) that decays LINEARLY toward zero over ~0.75s so the punch
	// fades; snapped to zero below a threshold. Not capped (a strong slam can fling well past walk speed).
	Vector2 _shockwaveVel;
	float _shockwaveStartMag;                  // magnitude when last applied — the bounce reference (see below)
	const float SHOCKWAVE_DECEL = 420.0f;      // px/s² linear decay of the knockback
	const float SHOCKWAVE_STOP_THRESHOLD = 4.0f;
	// The bounce off a solid only fires while the shockwave is still STRONG — at least this fraction of the
	// force it started at. Once it's decayed (or been reflected) below it, a further into-solid contact just
	// absorbs (the component is dropped) instead of mirroring, which also caps a corridor to ~2 bounces.
	const float SHOCKWAVE_BOUNCE_MIN_FRACTION = 0.75f;
	const float SHOCKWAVE_BOUNCE_RESTITUTION = 0.9f;
	// Swarm bodies overlap freely, but a small short-lived velocity keeps the group from collapsing into
	// one unreadable stack. This stays separate from Shockwave so touching a copy never bounces off walls.
	// The channel is pure depenetration: it decays HARD (~0.1s from full) so a push dies the moment the
	// bodies separate — the lasting knockback comes from the launch below, not from banked channel speed.
	Vector2 _swarmRepelVel;
	const float SWARM_REPEL_MAX_SPEED = 90.0f;
	const float SWARM_REPEL_DECEL = 900.0f;
	const float SWARM_REPEL_STOP_THRESHOLD = 2.0f;
	// A strong one-shot bounce when two swarm bodies press into each other, gated per body by a cooldown
	// so a lingering overlap re-fires as periodic pops rather than a continuous force. Same channel split
	// as the sticky flings (see ApplyUnstickFling): ExtraVelX for a horizontal launch so it decays like a
	// platform fling, base VelY for a vertical one so the boosted body arcs under gravity — landing on a
	// copy's head bounces you well past a jump (JumpPower 90). The TOWARD-FLOOR kick is deliberately
	// weaker (gravity-relative: world-down normally, world-up in a reverse field): the body nearer the
	// effective floor shouldn't get spiked into it as hard as the other one flies away.
	float _swarmLaunchCooldown;
	const float SWARM_LAUNCH_POWER = 150.0f;
	const float SWARM_LAUNCH_DOWN_POWER = 70.0f;
	const float SWARM_LAUNCH_COOLDOWN = 0.5f;
	const float SWARM_LAUNCH_SHAKE_STRENGTH = 2.0f;
	const int SWARM_LAUNCH_SHAKE_FRAMES = 4;
	// A small hit-stop on a shockwave bounce, scaled by how fast the shockwave was carrying us into the
	// solid (the channel magnitude at the moment of contact).
	const int HIT_STOP_SHOCKWAVE_BOUNCE_MAX_FRAMES = 4;
	const float SHOCKWAVE_BOUNCE_HITSTOP_MIN_SPEED = 120.0f;
	const float SHOCKWAVE_BOUNCE_HITSTOP_MAX_SPEED = 480.0f;

	// STASIS TRAIL (BlockStasis). While the player stands in a Stasis block's fading trail the block
	// (which ticks before the player) reports the trail LEVEL via ApplyTrailStasis (1 = phase-1 trail,
	// 2 = phase-2; the strongest overlapping trail wins). It's a stasis field: a very tiny gravity (slow,
	// heavy sink) plus a viscous "held" damping that bleeds the player's velocity toward zero (thicker at
	// level 2), while their input keeps SOME weakened control (StasisControl) so it never feels dead. Reset
	// at frame start by ResetStasis (GameStage.Tick, before the blocks tick) because it's read across
	// several later player-tick phases. Deterministic (pure fn of trail cells + player pos; no Rng/Time.Delta).
	int _stasisLevel;
	// Effect strength 0..1 for THIS tick's trail cell (its fade curve — see BlockStasis.StasisStrength).
	// Scales the whole stasis effect (gravity blend, damping, control) so it eases to nothing at the tail.
	float _stasisStrength;
	// Previous tick's trail membership, for the edge-triggered enter/leave sound cue.
	bool _inTrailPrev;

	// REVERSE-GRAVITY (effective). `_gravityReversed` is the EFFECTIVE state every gravity-sign-aware
	// call site reads; it is the XOR of two independent sources:
	//   • _fieldReversed — a Reverse block's square field (BlockReverse): a PER-TICK flag the block sets
	//     via ApplyReverseGravity (it ticks before the player) and ResetStasis clears at frame start.
	//   • _selfGravityReversed — the persistent baseline gravity orientation, toggled by FLIPPER's
	//     grounded jump/down input. Survives across ticks.
	// XOR gives the "cancel-out" rule: a Flipper that has flipped its own gravity and then enters a
	// reverse field is pulled back to NORMAL gravity (true ^ true = false). Characters without a
	// persistent baseline flip still have _gravityReversed == _fieldReversed exactly as before.
	// Deterministic (pure per-tick flags; no Rng/Time.Delta).
	bool _gravityReversed;
	// The reverse-gravity FIELD flag alone, kept separate from the effective flag so a mid-tick self-flip
	// can recompute the XOR against the current field state.
	bool _fieldReversed;
	// The persistent baseline gravity flip used by FLIPPER.
	bool _selfGravityReversed;
	// SHIFTER's last adopted surface direction. A Reverse field only affects the Shifter while
	// AIRBORNE (ApplyReverseGravity is gated on base support): grounded players are immune, so
	// entering/leaving a field never detaches them, and a mid-field touch adopts the touched surface
	// as this base for real — the field stops applying the moment the adoption grounds them.
	Direction _surfaceGravityDirection = Direction.Down;
	// The face displaced by the latest adoption is suppressed only while it remains in the same
	// simultaneous-contact episode. Separation re-arms it; loss of the current floor promotes it.
	Direction _surfacePreviousGravityDirection = Direction.None;
	Direction _surfaceContactThisTick;
	// The effective frame the surface coyote/jump-buffer windows were armed in: a frame change
	// (a Reverse field flipping an airborne player, an adoption) zeroes the windows so an old
	// floor's coyote/buffer can't fire a jump off a floor that was never supported.
	Direction _surfaceJumpWindowFrame = Direction.Down;
	bool _surfaceWasCollidingLeft;
	bool _surfaceWasCollidingRight;
	bool _surfaceWasCollidingUp;
	bool _surfaceWasCollidingDown;
	// Previous tick's reverse-field membership, for the edge-triggered enter/leave sound cue.
	bool _inReverseFieldPrev;

	/// <summary>Vertical sign of the current gravity: +1 normal (pulls down), −1 in a reverse-gravity
	/// field (pulls up). Flips every jump/launch VelY so they push AWAY from the surface gravity holds us
	/// against (jump down off a ceiling when reversed).</summary>
	float GravitySign => _gravityReversed ? -1f : 1f;

	/// <summary>The surface gravity currently holds us against — the floor normally, the CEILING in a
	/// reverse-gravity field. Used by the jump/ability handlers so "grounded" tracks the flipped gravity.
	/// Public for abilities with a grounded gate (the Gunner's crouch-reload), which must track it too.</summary>
	public bool OnFloor => HAS_SURFACE_GRAVITY
		? HasSurfaceGravitySupport( EffectiveGravityDirection )
		: IsCollidingDirection( EffectiveGravityDirection );

	Direction EffectiveGravityDirection => HAS_SURFACE_GRAVITY
		? (_fieldReversed ? OppositeDirection( _surfaceGravityDirection ) : _surfaceGravityDirection)
		: (_gravityReversed ? Direction.Up : Direction.Down);

	/// <summary>Screen direction a jump launches toward — away from the effective floor: Up normally,
	/// Down when gravity is reversed (field or self-flip), away from the adopted surface for
	/// surface-gravity characters (valid airborne too — the frame persists until re-adoption). Feeds
	/// <see cref="InputState"/>'s resolution of the dedicated Jump action (Space / gamepad A), so the
	/// resolved direction is what gets recorded; the sim and replay format never see the raw button.
	/// While a charge winds, the button instead resolves AWAY FROM THE CHARGED SURFACE — the cancel key
	/// (see HandleChargeJump's awayJustPressed) — so A bails out of a wall or ceiling wind-up exactly as
	/// it does a floor one (where the two directions coincide). A held A re-targets to the plain frame
	/// direction once the charge ends without fabricating an edge (InputState's mid-hold rule).</summary>
	public Direction JumpScreenDirection => _chargingJump
		? OppositeDirection( ChargeHoldScreenDirection )
		: OppositeDirection( EffectiveGravityDirection );

	/// <summary>While winding a charge jump, the SCREEN direction whose held input sustains the
	/// charge (see HandleChargeJump's holdingCharge): the gravity-frame floor for a ground charge,
	/// the gravity-frame ceiling for a ceiling charge, the charged wall's side otherwise; None when
	/// not charging. <see cref="InputState"/> latches the analog stick's held bit toward this
	/// direction while the stick stays deflected, so sweeping the aim through an angle where the
	/// hold direction's component thins to nothing (pure vertical mid wall-charge) can't read as
	/// "released the hold" and fire the charge early.</summary>
	public Direction ChargeHoldScreenDirection => !_chargingJump
		? Direction.None
		: _chargeJumpSurfaceDirection switch
		{
			Direction.Left => Direction.Left,
			Direction.Right => Direction.Right,
			Direction.Up => _gravityReversed ? Direction.Down : Direction.Up, // gravity-frame ceiling
			_ => _gravityReversed ? Direction.Up : Direction.Down,            // None = floor charge
		};

	// OnFloor as probed at the START of the PREVIOUS tick — the fall-damage "was actually falling" gate.
	// Last tick's probe rather than this tick's because a lethal fall (≥245 px/s ≈ ≥4 px/tick) can begin
	// its final tick already inside the 1px flag threshold, which would read as grounded and randomly
	// wave the landing through; two consecutive starts within 1px are impossible while genuinely falling.
	bool _onFloorLastTick;

	/// <summary>Is this body inside a reverse-gravity field this tick? Public for the impostor AI
	/// (<see cref="AiInputSource"/>), which plans in the gravity frame and must publish its Up/Down
	/// intents through the same swap the input accessors below apply.</summary>
	public bool GravityReversed => _gravityReversed;
	public bool BaselineGravityReversed => _selfGravityReversed;
	internal bool ReverseGravityFieldActive => _fieldReversed;
	// Very tiny gravity applied inside a trail (fraction of normal) — a slow, heavy sink. Phase 2 gets even
	// less, so the deeper stasis holds you more against falling.
	const float STASIS_GRAVITY_FACTOR_P1 = 0.12f;
	const float STASIS_GRAVITY_FACTOR_P2 = 0.05f;
	// Viscous "held" damping (per second) that bleeds velocity toward zero inside a trail — thicker (more
	// frozen) at phase 2. Tuned in tandem with STASIS_FIELD_FACTOR: the fields' shove is resisted there,
	// so the inertia soak carries a little less of the "held" feel on its own.
	const float STASIS_DAMP_P1 = 2.2f;
	const float STASIS_DAMP_P2 = 3.5f;
	// Weakened-but-nonzero control authority inside a trail (scales the player's own acceleration): some at
	// phase 1, a little at phase 2 — enough that pressing a direction still visibly nudges you. Also sets
	// grounded run speed (equilibrium vs the damp ≈ accel × control / damp — ~51 px/s at phase 1 vs the 86 cap).
	const float STASIS_CONTROL_P1 = 0.32f;
	const float STASIS_CONTROL_P2 = 0.16f;
	// Control authority, blended from full (1 — outside a trail or at the faded tail) toward the level's
	// reduced value by the cell's effect strength.
	float StasisControl
	{
		get
		{
			if ( _stasisLevel < 1 ) return 1f;
			float levelControl = _stasisLevel >= 2 ? STASIS_CONTROL_P2 : STASIS_CONTROL_P1;
			return 1f + (levelControl - 1f) * _stasisStrength;
		}
	}

	// Slow factor a stasis trail applies to a grapple pull. A pull moves the body directly (MoveByGrapple)
	// and so bypasses the trail's gravity blend and velocity damping — without this the Grappler would reel
	// through a stasis field at full speed. Thicker (slower) at phase 2, eased back to full speed at the
	// fading tail, same blend as StasisControl.
	const float STASIS_GRAPPLE_FACTOR_P1 = 0.5f;
	const float STASIS_GRAPPLE_FACTOR_P2 = 0.3f;
	/// <summary>How much this tick's stasis trail slows a grapple pull (1 = no trail).</summary>
	public float StasisGrappleFactor
	{
		get
		{
			if ( _stasisLevel < 1 ) return 1f;
			float levelFactor = _stasisLevel >= 2 ? STASIS_GRAPPLE_FACTOR_P2 : STASIS_GRAPPLE_FACTOR_P1;
			return 1f + (levelFactor - 1f) * _stasisStrength;
		}
	}

	// Slow factor a stasis trail applies to the SUSTAINED field channels (wind gust, magnet beam, siren
	// song). The viscous damp can't touch these — the feeding block tops the channel back up every tick
	// (and wind/magnet integrate toward a terminal, so a weaker feed only ramps slower to the same
	// speed) — so the goo instead resists the shove where it's spent: the channels' contribution to
	// motion is scaled by this factor at integration (see FieldVelocity). The channels themselves stay
	// untouched, so stepping off the trail restores the full push instantly. Thicker (weaker fields) at
	// phase 2, eased back to full strength at the fading tail, same blend as StasisControl.
	const float STASIS_FIELD_FACTOR_P1 = 0.5f;
	const float STASIS_FIELD_FACTOR_P2 = 0.25f;
	/// <summary>How much this tick's stasis trail attenuates the sustained field channels — wind,
	/// magnet, siren — where their motion is spent (1 = no trail). See <see cref="FieldVelocity"/>.</summary>
	float StasisFieldFactor
	{
		get
		{
			if ( _stasisLevel < 1 ) return 1f;
			float levelFactor = _stasisLevel >= 2 ? STASIS_FIELD_FACTOR_P2 : STASIS_FIELD_FACTOR_P1;
			return 1f + (levelFactor - 1f) * _stasisStrength;
		}
	}

	// STICKY BLOCK (BlockSticky). While stuck the player is glued flush to one face of the block and
	// can't jump off. Unlike wind/magnet this isn't a force channel: it fully OWNS the player's movement
	// for the tick (see HandleStickyBlocks), so it's driven here rather than pushed from the block.
	//
	// While stuck (see the ATTACHMENT block below; _attachFace is the relative direction of the block
	// FROM the player: Down = standing on its top, Up = hanging under it, Left/Right = hugging that side):
	//
	// A four-second hold timeout tears the player free, preventing a stopped sticky block from
	// creating a permanent trap. It counts only while NOT winding a charge (that's the sanctioned escape
	// and it locks tangent movement, so the timer must not steal it). You can also come unstuck by:
	//   - walking/climbing along any sticky face and off an edge, or
	//   - EITHER PHASE: the block landing a hard SLAM — that releases you for STICK_SLAM_RELEASE_TIME
	//     (with an inertia fling of the block's pre-slam velocity), or
	//   - EITHER PHASE: a charge-jump character winding a charged jump against the stuck face far enough
	//     to TEAR OUT of the glue (see HandleStickyChargeJump; the launch pays a power toll, and phase 2's
	//     stronger goo takes a near-full wind).
	// A slam opens a "grace window" (_slamTimer) recorded against the SPECIFIC side it flung us off
	// (_slamBlock/_slamFace). While it's open that one side is off-limits to re-grab, but ANY OTHER side
	// (or another block) can still grab us — just without the grab hit-stop. When the window closes, the
	// forbidden side is eligible again (so resting on it re-grabs). The slam trio is a COOLDOWN record,
	// not an attachment — it's live precisely while we're detached — so it stays outside the slot.
	Block _slamBlock;                    // the side a slam just flung us off (off-limits during the window)
	Direction _slamFace = Direction.None;
	float _slamTimer;                    // >0 while that side is off-limits and grabs skip the hit-stop
	float _stickyStuckTime;              // fixed-step duration of the current continuous sticky grip
	Block _stickyEdgeReleaseBlock;       // prevents a walk/shove-off from wrapping around the block's corner
	Direction _stickyEdgeReleaseFace = Direction.None;
	float _stickyEdgeReleaseCooldown;    // >0 keeps the RELEASED face itself off-limits too (else gravity re-grabs it within a frame at a top corner)
	const float STICK_SLAM_RELEASE_TIME = 0.6f; // free window a slam grants before the flung-off side can re-grab
	const float STICK_EDGE_RELEASE_COOLDOWN = 0.66f; // how long an edge walk/shove-off forbids re-grabbing the same face
	const float STICK_TIMEOUT = 4.0f;
	const float STICK_TIMEOUT_SHAKE_TIME = 0.5f;
	const float STICK_TIMEOUT_SHAKE_MIN = 0.75f;
	const float STICK_TIMEOUT_SHAKE_MAX = 1.5f;
	const float STICK_TIMEOUT_RELEASE_SPEED = 110.0f;
	const float STICK_RELEASE_FLING_SPEED = 40.0f; // tiny outward pop for the releases with no launch of their own
	const float STICK_CLIMB_SPEED = 75.0f;      // base px/s for climbing up/down a stuck side face
	const float STICK_MOVE_SPEED_FACTOR = 0.7f; // player-controlled tangent speed while stuck
	const float REPEL_STICKY_PHASE1_FACTOR = 0.8f;
	const float REPEL_STICKY_PHASE2_FACTOR = 0.6f;
	// How much of the player's opposing momentum a repel cancels before its launch applies (see
	// ApplyGunDirectionalVelocity). At 1 a point-blank repel always yields the same launch speed
	// regardless of prior motion; lower it toward 0 for a partial cushion that lets a fast fall
	// eat into the launch (the pre-consolidation additive behaviour).
	const float BULLET_REPEL_COUNTER_MOMENTUM_FACTOR = 1f;
	// Single-frame sim freeze to punch up the grab (same hit-stop mechanism as dash/bounce/death). Kept
	// to 1 step: anything longer reads as lag. The slam release rides the block's own impact juice, so it
	// adds no freeze of its own.
	const int HIT_STOP_STICK_FRAMES = 3;
	const int HIT_STOP_UNSTICK_FRAMES = 2;
	// A refused jump/walljump off the block gives a small "tug" of feedback: the player shakes along the
	// stuck face (as if stretching the goo) plus a barely-there freeze.
	const float STICK_BLOCKED_SHAKE = 1.5f;
	const int STICK_BLOCKED_SHAKE_FRAMES = 6;
	const int HIT_STOP_STICK_BLOCKED_FRAMES = 2;
	// Block jolt on grab (toward the face we cling to — the goo grabbing us) and on release (a snap back
	// AWAY from that face as the goo lets go).
	const float STICK_ENGAGE_SHAKE = 1.5f;
	const float STICK_RELEASE_SHAKE = 2.5f;

	public Vector2 LastVelocity { get; private set; }

	float JUMP_POWER => _move.JumpPower * SolarJumpFactor;
	float RollFloorJumpStrengthFactor()
	{
		float min = Math.Max( 0f, _move.JumpStrengthMinFactor );
		float max = Math.Max( 0f, _move.JumpStrengthMaxFactor );
		return max > min ? Rng.Float( min, max ) : min;
	}
	float RollWallJumpStrengthFactor()
	{
		float min = Math.Max( 0f, _move.WallJumpStrengthMinFactor );
		float max = Math.Max( 0f, _move.WallJumpStrengthMaxFactor );
		return max > min ? Rng.Float( min, max ) : min;
	}
	float GRAVITY => _move.Gravity;
	float ACTIVE_UP_GRAVITY_FACTOR => _move.ActiveUpGravityFactor;     // while holding Up in the air
	// When true, ACTIVE_UP_GRAVITY_FACTOR only softens gravity while FALLING (a gentle hold-Up glide DOWN),
	// not while rising — so holding Up can't nearly-cancel gravity on the way up and float a jump/wall-jump
	// way too high. Off for every other character (identical trajectories → no version bump).
	bool ACTIVE_UP_GRAVITY_FALL_ONLY => _move.ActiveUpGravityFallOnly;
	// When true, a held Down negates a held Up: with both held the Up softening is skipped and the Down
	// factor applies. Off for every other character (identical trajectories → no version bump).
	bool ACTIVE_DOWN_OVERRIDES_UP => _move.ActiveDownOverridesUp;
	float ACTIVE_DOWN_GRAVITY_FACTOR => _move.ActiveDownGravityFactor; // while holding Down in the air (falling)
	// While holding Down AND still RISING, a separate factor so a character can be yanked down hard on the
	// way DOWN without that same strong pull braking its ASCENT. Negative sentinel = use the falling factor
	// for both (the original both-ways behaviour → identical for every character that doesn't set it).
	float ACTIVE_DOWN_GRAVITY_RISE_FACTOR => _move.ActiveDownGravityRiseFactor < 0f ? ACTIVE_DOWN_GRAVITY_FACTOR : _move.ActiveDownGravityRiseFactor;
	float WALL_GRAVITY_FACTOR => _move.WallGravityFactor;              // while hugging wall
	float WALL_RISE_GRAVITY_FACTOR => _move.WallRiseGravityFactor;     // while hugging wall AND rising (VelY>0)

	// Bounce mechanic (diverges from original): holding Down on impact converts downward speed into
	// an upward bounce instead of a dead stop. BOUNCE_MIN_SPEED gates out micro-bounces (need a real
	// drop), BOUNCE_RESTITUTION is the fraction of impact speed returned upward.
	float BOUNCE_MIN_SPEED => _abilities.BounceMinSpeed;
	float BOUNCE_RESTITUTION => _abilities.BounceRestitution;
	const float BOUNCE_SHAKE_STRENGTH = 0.02f; // block visual shake per unit of impact speed

	// Hit-stop (impact freeze): a hard bounce / wall dive / dash / death briefly freezes the whole sim
	// for a few fixed steps (see GameStage.RequestHitStop) to punch up the impact. Kept tiny (1-4 steps)
	// so it reads as a hit, not a stutter. Measured in fixed steps (not seconds) so it's frame-rate
	// independent and replay-safe.
	//   - Bounce only freezes above HIT_STOP_BOUNCE_MIN_SPEED (well above the speed needed to bounce at
	//     all), and scales 1..3 with impact speed, so only genuinely hard slams hitch.
	//   - Dash and death use a fixed freeze (death the longest, to land the kill).
	const float HIT_STOP_BOUNCE_MIN_SPEED = 250.0f;
	const int HIT_STOP_BOUNCE_MIN_FRAMES = 1;
	const int HIT_STOP_BOUNCE_MAX_FRAMES = 6;
	const int HIT_STOP_WALL_DIVE_FRAMES = 4;
	const int HIT_STOP_WALL_KICK_FRAMES = 7;
	const int HIT_STOP_DASH_FRAMES = 3;
	const int HIT_STOP_DEATH_FRAMES = 5;

	public bool CollidingLeft { get; set; }
	public bool CollidingRight { get; set; }
	public bool CollidingDown { get; set; }
	public bool CollidingUp { get; set; }

	Entity2D _collidingEntityLeft;
	Entity2D _collidingEntityRight;
	Entity2D _collidingEntityDown;
	Entity2D _collidingEntityUp;

	public bool CrushingLeft { get; set; }
	public bool CrushingRight { get; set; }
	public bool CrushingDown { get; set; }
	public bool CrushingUp { get; set; }

	Entity2D _crushingEntityLeft;
	Entity2D _crushingEntityRight;
	Entity2D _crushingEntityDown;
	Entity2D _crushingEntityUp;

	// CRUSH PROBES: each direction is tested with a thin band rect instead of the original single
	// point at the edge midpoint.
	//   - EPS (epsilon) extends the band past our edge, perpendicular to it. The tick loop parks us EXACTLY
	//     flush against block faces (unpenetrate/glue assign e.g. Y = b.Bottom - Height/2), and the
	//     old point-on-the-edge test then hinged on float round-trips like (b.Bottom - 5) + 5 ==
	//     b.Bottom, which can land one ULP short — deterministically, every tick, for certain block
	//     positions — leaving crush blind while a block ground into us. The epsilon absorbs that.
	//   - HALF_EXTENT is the band's reach ALONG the edge, to each side of our centerline. Kept
	//     SMALL on purpose: a block must (nearly) cover our center to count as a crusher, so a
	//     block clipping only the outer sliver of our body still resolves as a lenient sideways
	//     push-out rather than a kill — the original edge-of-block forgiveness. 0 = the original
	//     point probe; Width/2 (resp. Height/2) = the full edge, maximally strict.
	const float CRUSH_PROBE_EPS = 0.1f;
	const float CRUSH_PROBE_HALF_EXTENT = 0.3f;

	// RESIDUAL-PENETRATION RESOLVE-OR-DIE (see ResolveResidualPenetrationOrDie): fires when the blind
	// unpenetrate pass leaves us embedded deeper than EPS in any solid. EPS sits above float-noise /
	// sub-pixel-lip scale (which the normal machinery absorbs) but below any real block intrusion,
	// which grows tick over tick while we're trapped. MAX_DIST caps the validated rescue push-out: it
	// covers the deepest one-tick intrusion physically possible (two max-speed blocks closing at
	// ~10px/tick) yet stays too short to ever "rescue" by teleporting past or on top of a 40px
	// crusher — beyond it we wouldn't be resolving contact any more, we'd be relocating.
	// SQUEEZE_MAX_DIST is the tighter cap on every face that offers neither of the two sanctioned
	// full-cap escapes. Full cap goes to: a solid's NEAREST face (the way we were pushed in — the
	// true minimum-translation exit, kept as the safety valve even for degenerate deep embeds), and
	// any face of a MOVING solid that points along its velocity (being spat out AHEAD of a crusher
	// is the designed bulldoze). Every other face — a static solid's far sides, a mover's
	// perpendicular or trailing faces — only rescues within this small squeeze-out allowance (must
	// stay under our 10px height — at or past it, a lip level with our head becomes hoppable): a
	// safe spot a few px away shouldn't be a death, but a near-body-length pop over a lip (an
	// obstacle top level with our head, or up out of the top seam of two closing blocks) is a crush
	// being cheated, not contact being resolved.
	const float RESIDUAL_PEN_EPS = 0.5f;
	const float RESIDUAL_RESOLVE_MAX_DIST = 12f;
	const float RESIDUAL_SQUEEZE_MAX_DIST = 6f;

	// DEBUG: draw the four crush probe bands over the playfield each rendered frame (drawn by
	// GameManager after transform sync; red = that direction's Crushing flag is currently set).
	public static bool ShowCrushProbeOverlay = false;

	// MIMIC SQUASH: the Mimic's own form is unkillable by crushing. Every crush kill (probe-pair,
	// residual no-fit, fall damage, teleport-block materialise) flattens it in place instead: the
	// squish anim plays and holds, control/self-motion/gravity/external force feeds all stop, and
	// the form timer pauses (Tick returns before the ability PreTick loop, so MimicAbility never
	// advances). The moment the collision box fits again the same anim runs backwards, quickly, and
	// control returns with every force channel cleared (see TickSquash). Hazards — spiked faces
	// grinding in, fireballs, teardrops, lasers, impostors — still kill the Mimic while it is
	// VISIBLE (flattening / hold beat / popping); the fully-hidden pancake is untouchable
	// (IsSquashHidden gates every kill path). A pinch that presses the body into a live spiked
	// surface never squashes at all — it is a spike death (see CrushedIntoSpikes).
	// Squashed = flattening/held flat; Unsquashing = the reverse anim is running.
	enum SquashState { None, Squashed, Unsquashing }
	SquashState _squashState;
	bool _squashHorizontal; // which squish art flattened us (matches the crush axis of the kill)
	int _squashFrame;       // manually-scrubbed squish frame (the sprite's own clock is paused)
	int _squashTickCounter; // paces the flatten's frame stepping
	public bool IsSquashed => _squashState != SquashState.None;

	/// <summary>The fully-hidden stretch of a squash: final squish frame shown, hide delay elapsed,
	/// sprite off. The vanished body is UNTOUCHABLE — every kill path gates on this — while the
	/// visible flatten and pop phases stay vulnerable to hazards like the walking player.</summary>
	public bool IsSquashHidden => _squashState == SquashState.Squashed
		&& _squashFrame == (_squashHorizontal ? SQUISH_H_FRAMES : SQUISH_V_FRAMES) - 1
		&& _squashTickCounter >= SQUASH_HIDE_DELAY_TICKS;

	// Frame counts of the squish_h / squish_v anims, hardcoded so the SIM never reads a render
	// resource (squash duration gates when control returns — headless replay determinism).
	const int SQUISH_H_FRAMES = 4;
	const int SQUISH_V_FRAMES = 5;
	const int SQUASH_IN_TICKS_PER_FRAME = 2; // ≈ the anims' native 27–33fps at the 60Hz step
	// The pancake's final frame doesn't line up with the true crush seam (the squish art is centred
	// on the body cell), so after showing it for ≈ one anim frame the sprite HIDES for the held
	// stretch; it reappears on that frame the moment the pop starts and plays back in reverse.
	// The vanish squirts a dust puff out of the pinch, and while hidden a quiet goo pulse repeats
	// so the flattened body never reads as despawned.
	const int SQUASH_HIDE_DELAY_TICKS = SQUASH_IN_TICKS_PER_FRAME;
	const int SQUASH_CUE_INTERVAL_TICKS = 40; // ~0.67s between "still under there" pulses
	int _squashCueTicks;

	int _groundedLeniencyCounter = 0;
	int NUM_GROUNDED_LENIENCY_FRAMES => _move.GroundedLeniencyFrames;

	// Per-character extra-mobility state (see CharacterAbilities knobs). All default to no-op values so
	// characters that don't opt in behave exactly as before.
	int _airJumpsUsed = 0;          // landing-recharged mid-air jumps spent since the last landing
	bool _jumpedThisTick = false;   // a ground/wall/air jump fired this tick (stops one Up press double-firing)
	bool _jumpCutArmed = false;     // a rising jump can still be cut short (VariableJumpHeight)
	int _wallClingCounter = 0;      // fixed steps of zero-slide wall cling remaining (WallClingFrames)
	bool _wallClingHold = false;    // the cling froze us this tick: external forces get no vertical vote at integration
	bool _clungLeftWall = false;    // consumed the cling on the current LEFT-wall contact (re-arms once we leave that wall)
	bool _clungRightWall = false;   // consumed the cling on the current RIGHT-wall contact (re-arms once we leave that wall)
	float _wallHugCarryY;           // hugged surface's resolved Y displacement this tick (combined with relative slide)
	float _wallHugStepVelocityY;    // velocity represented by _wallHugCarryY, used to keep VelY world-relative

	// The WallHug glue FOLLOWS a face, it never teleports: the farthest a legitimate flush target can
	// be is one tick of the fastest block (300 px/s = 5px) plus float slack. A flush spot beyond this
	// means the attachment is stale or mismatched (e.g. a face-Right hug whose entity sits on our
	// LEFT — snapping would carry us through it), so the glue lets go instead.
	const float WALL_HUG_MAX_FOLLOW = 6f;
	bool _ceilingHitThisTick = false; // AdjustYPosition resolved a genuine upward (ceiling) contact this tick
	bool _dashCoastCeilingHit = false; // that contact happened during a dash coast and is still live (see ApplyGravity)
	bool _backFlipping = false;     // a back-flip is airborne with Up held (applies the sustained backward push)
	float _backFlipBackDir = 0f;    // latched backward direction (±1, opposite the facing at take-off)
	float _backFlipTimer = 0f;      // seconds elapsed into the back-flip backward-push envelope
	int _jumpBufferCounter = 0;     // fixed steps a buffered jump press stays valid (JumpBufferFrames)
	int _hoverRemaining = 0;        // hover ticks left this airtime (HoverFrames)
	bool _groundPounding = false;   // mid ground-pound: locks horizontal input + bypasses the fall cap (CanGroundPound)
	int _autoBounceCompressionFrames;
	Direction _pressLaunchDir = Direction.None; // a NEW block-side press this tick schedules a launch away that face (BlockPressBoost)
	float _pressLaunchBlockVel = 0f; // pressed block's speed along the launch direction at press time (leading-face bonus; clamped to 0, never a reduction)
	public bool PressedBlockSideThisTick { get; private set; }
	Vector2 _grappleMoveIntent;

	// CHARGE JUMP. While _chargingJump the player is planted, winding up a directional leap.
	// None means a floor charge; Up means ceiling; Left/Right mean the corresponding side wall.
	bool _chargingJump = false;
	float _chargeJumpTime = 0f;
	float _chargeJumpAimTime = 0f;
	Direction _chargeJumpSurfaceDirection = Direction.None;
	Direction _suppressedChargeSurfaceDirection = Direction.None;
	float _chargeWallJumpGravityTimer = 0f;
	// Consecutive ticks spent OnFloor (reset the instant we leave it). Gates the charge START so that
	// FALLING onto the ground while holding Down bounces first, only winding up once we've actually settled.
	int _chargeGroundedFrames = 0;
	// Rumble held while the wind-up runs, from the first tick of the hold to a full charge (see
	// AdvanceChargeJump). Tops out well under the launch pulse it hands over to.
	const float CHARGE_JUMP_HAPTIC_MIN = 0.06f;
	const float CHARGE_JUMP_HAPTIC_MAX = 0.42f;
	// Pressing AWAY from the charged surface while still holding the charge cancels the wind-up with a
	// tiny repel pop of this speed (well under CHARGE_JUMP_MIN_SPEED — a bail-out, not a launch).
	const float CHARGE_CANCEL_POP_SPEED = 55f;

	// How far off a block ledge we can hang before we fall. The centre sits exactly on the edge
	// when 50% of the body is off, so a 0.75 threshold means the centre must travel a further
	// (0.75 - 0.5) = 0.25 of the body width past the edge before we let go. Used by the platform-glue
	// detach, the landing-catch margin in Unpenetrate and the sticky-face shimmy limit so they all
	// agree on "hanging off the lip" — anywhere you can stand you can land, and the glue releases
	// exactly where the catch ends. Must stay above 0.5 (some past-the-edge lean must exist).
	// Was 0.9: with the 8px hitbox inside the 12px art that let the player hang with their feet
	// pixels visibly fully off the surface; 0.75 (2px past the edge) keeps a toe on the corner.
	const float LEDGE_FALL_OFF_FRACTION = 0.75f;
	float LedgeHangMargin => (LEDGE_FALL_OFF_FRACTION - 0.5f) * Width;

	// Minimum own fall speed (px/s, toward the caught face) for the corner-landing deflect in
	// Unpenetrate to act; below it the catch is plainly refused as before. Must sit well above the
	// one-tick gravity "hair" (VelY ≈ -5..-6 across characters) that re-lands a GROUNDED player every
	// tick: that hair also walks the seam between two flush solids while running across it, and
	// letting it deflect shoved the runner backward out of the solid being entered every tick — an
	// impassable hitch at flat seams (solids-list-order dependent: it fired only when the entered
	// solid resolved before the supporting one, which is why obstacle seams hitched right-to-left
	// but not left-to-right). Genuine landings arrive far faster than this; a sliver landing caught
	// exactly at the jump apex just refuses for the few ticks until gravity passes the threshold —
	// a sub-pixel, invisible delay before the deflect kicks in.
	const float CORNER_DEFLECT_MIN_FALL_SPEED = 15f;

	// GUNNER LEDGE GRAB. A falling body catches an exposed gravity-relative corner (top normally,
	// bottom while reversed) when its hands cross the edge, hangs with two pixels of head past it, then
	// launches away from the current floor on a fresh gravity-relative Up press. This deliberately stays
	// outside the attachment slot: sticky glue,
	// platform riding and ceiling cling retain exclusive ownership, while a moving ledge is followed
	// only after a swept collision check succeeds.
	const float MANTLE_HAND_INSET = 2f;
	const float MANTLE_CORNER_TOLERANCE = 1.1f;
	const float MANTLE_FORCE_EPSILON = 0.01f;
	const float LEDGE_GRAB_JUMP_POWER_FACTOR = 0.75f;
	const int MANTLE_REGRAB_COOLDOWN_FRAMES = 6;
	bool _mantleHanging;
	Entity2D _mantleSurface;
	Direction _mantleSide = Direction.None;
	Direction _mantleGravityDirection = Direction.None;
	bool _mantleUpArmed;
	bool _mantleGunnerReloadArmed;
	int _mantleRegrabFrames;

	Direction _wallJumpDirection = Direction.None; // direction we move in after jumping off wall
	Direction _wallDirection = Direction.None;     // direction of the wall we are jumping off
	// The wall entity banked when the leniency window arms (the probed CollidingLeft/Right entity),
	// so the launch can award that face's press (see PressWallLaunchSurface). Null for an arena wall
	// or obstacle. Cleared wherever _wallDirection is.
	Entity2D _wallJumpWallEntity;
	float _wallJumpTimer = 0.0f;
	float WALL_JUMP_TIME => _move.WallJumpTime;
	/// <summary>A 0→1 ramp of "how much normal horizontal control has returned" after a wall jump. Right
	/// after one <see cref="_wallJumpTimer"/> is WALL_JUMP_TIME so this is 0, then it eases back to 1 as the
	/// timer counts down to 0 over WALL_JUMP_TIME. Used to (a) reduce AIR deceleration so the wall-jump push
	/// carries you across a gap instead of being braked instantly, and (b) reduce acceleration back INTO the
	/// wall you just launched from, so you can't immediately cancel the wall jump by steering into it. 1 (no
	/// active wall jump) = full, normal control.</summary>
	float GetWallJumpFactor() { return Utils.Map( _wallJumpTimer, WALL_JUMP_TIME, 0.0f, 0.0f, 1.0f, true, EasingType.Linear ); }
	float WALL_JUMP_HORIZONTAL_POWER => _move.WallJumpHorizontalPower * SolarJumpFactor;
	float WALL_JUMP_VERTICAL_POWER => _move.WallJumpVerticalPower * SolarJumpFactor;
	// Holding AWAY from the wall while jumping trades vertical for horizontal: a flatter, longer
	// kick to clear a gap. Reuses the existing left/right keys (no new input), distinguished from
	// the normal arc by which way you're leaning at the moment of the jump. The extra speed must go
	// through ExtraVelX (decays over ~0.3s) because VelX is clamped to MAX_X_SPEED, which would
	// otherwise swallow the boost and make the dive feel like a normal jump.
	const float WALL_JUMP_DIVE_EXTRA_HORIZONTAL = 150.0f;
	const float WALL_JUMP_DIVE_VERTICAL_POWER = 72.0f;
	// The lean-away dive is gated per-character by CharacterAbilities.CanWallDive (see HandleWallJumping).

	int _wallJumpLeniencyCounter = 0;
	int NUM_WALL_JUMP_LENIENCY_FRAMES => _move.WallJumpLeniencyFrames;
	// Set when a wall jump fires; blocks the leniency counter from re-arming off the SAME wall while
	// we're still launching off it. On the frame after a wall jump, CollidingLeft/Right is still
	// stale-true (we haven't moved away yet) and VelY is upward, so without this the counter would
	// refill to full and hand out a free mid-air jump within the window. Cleared once we've genuinely
	// separated from the wall OR started falling (apex), so a dive onto a far wall and a straight-up
	// neutral jump both re-arm normally — only the immediate self-launch is suppressed.
	bool _suppressWallRearm = false;

	// DOUBLE-TAP DASH (NEW feature, diverges from original): tap (press-then-release) a direction
	// then press it again — the dash fires on that SECOND press (no second release needed). Each axis
	// participates only when its corresponding direction knob is enabled. It's a single-use move per air-time —
	// with a surface: landing/standing on the ground or a block top, bouncing, or hugging a wall
	// (block side or arena wall) on either side. Driven purely off the recorded input bits (held +
	// "just pressed" edges) and fixed-step timers, so it stays deterministic and replay-safe — no new
	// input is read. All tunables below.
	//
	// DASH_TAP_RELEASE_TIME  : the first tap only counts if the key is RELEASED within this long after
	//                          being pressed (holding the key to walk must NOT count as a tap).
	// DASH_SECOND_TAP_TIME   : the dash fires on the SECOND press, which must arrive within this long
	//                          after the first tap was released (how quickly the double-tap follows).
	// DASH_FORCE             : the horizontal impulse (added to ExtraVelX) granted by a dash.
	// DASH_GRAVITY_SUPPRESS_TIME : for this brief window right after a dash, gravity is not applied to
	//                          VelY (a flat dash hang). Block/surface collisions still affect VelY.
	float DASH_TAP_RELEASE_TIME => _abilities.DashTapReleaseTime;
	float DASH_SECOND_TAP_TIME => _abilities.DashSecondTapTime;
	const float DASH_DIAGONAL_GRACE_TIME = 0.05f;
	float DASH_FORCE => _abilities.DashForce;
	float DASH_GRAVITY_SUPPRESS_TIME => _abilities.DashGravitySuppressTime;
	float DASH_GRAVITY_SUPPRESS_TIME_ALONG_GRAVITY => _abilities.DashGravitySuppressTimeAlongGravity ?? DASH_GRAVITY_SUPPRESS_TIME;
	float DASH_FORCE_ALONG_GRAVITY => _abilities.DashForceAlongGravity ?? DASH_FORCE;
	bool CAN_DASH_HORIZONTAL => _abilities.CanDashHorizontal;
	bool CAN_DASH_UP => _abilities.CanDashUp;
	bool CAN_DASH_DOWN => _abilities.CanDashDown;
	bool CAN_DASH_ON_GROUND => _abilities.CanDashOnGround;
	bool DASH_REFRESH_ON_FLOOR => _abilities.DashRefreshOnFloor;
	bool DASH_REFRESH_ON_WALL_HUG => _abilities.DashRefreshOnWallHug;
	float DASH_RECHARGE_FLOOR_DISTANCE => _abilities.DashRechargeFloorDistance;
	float DASH_RECHARGE_WALL_DISTANCE => _abilities.DashRechargeWallDistance;
	float DASH_RECHARGE_CEILING_DISTANCE => _abilities.DashRechargeCeilingDistance;
	float DASH_RECHARGE_SFX_PITCH_EMPTY => _abilities.DashRechargeSfxPitchEmpty;
	float DASH_RECHARGE_SFX_PITCH_FULL => _abilities.DashRechargeSfxPitchFull;
	float DASH_MOMENTUM_FACTOR => _abilities.DashMomentumFactor;
	bool CONSOLIDATE_DASH_HORIZONTAL_VELOCITY => _abilities.ConsolidateDashHorizontalVelocity;
	bool PRESERVE_DASH_VERTICAL_VELOCITY => _abilities.PreserveDashVerticalVelocity;

	bool _canDash = true;                       // re-armed by configured character events, spent on dash
	bool _dashedThisTick;                       // successful dash consumes later directional moves this tick
	readonly DirectionalDoubleTapGesture _dashGesture = new();
	float _dashGravityTimer;                    // remaining time gravity is suppressed after a dash
	bool _prevDashContact;                      // previous-frame surface-contact state (for re-arm edge)
	float _dashRechargeProgress;                // surface-movement recharge, 0..1 (each mode's distance normalised)
	internal bool DashCharged => _abilities.CanDash && _canDash;

	// WALL-KICK (NEW feature, diverges from original): for a brief window right after you go from NOT
	// touching a side wall to touching it (block side OR arena wall, hugging or not), pressing the
	// OPPOSITE direction reverses your horizontal speed and boosts it a bit — a snappy rebound off the
	// wall. Vertical velocity is left untouched, and a fast dust burst kicks off the wall. Driven off
	// the recorded input edges + fixed-step timers + last-frame collision flags, so it's deterministic
	// and replay-safe (no raw Input read).
	//
	// WALL_KICK_TIME        : how long the window stays open after first touching the wall.
	// WALL_KICK_SPEED_BONUS : the reversed speed is multiplied by this (the "increased a bit").
	// WALL_KICK_MIN_SPEED   : must have hit the wall at least this fast for the rebound to fire (gates
	//                         out brushing a wall at a crawl, mirrors the bounce micro-gate).
	const float WALL_KICK_TIME = 0.12f;
	const float WALL_KICK_SPEED_BONUS = 1.25f;
	const float WALL_KICK_MIN_SPEED = 30.0f;

	Direction _wallKickWall = Direction.None;     // wall we touched (Left/Right); None => no open window
	Entity2D _wallKickWallEntity;                 // that wall's entity, to award its press on the kick
	float _wallKickTimer;                         // remaining window time
	float _wallKickSpeed;                         // |horizontal speed| at the moment of impact (to reverse)
	bool _prevCollidingLeft, _prevCollidingRight; // last-frame wall contact, to detect the touch edge

	Direction _xDirection = Direction.None;
	// Persistent facing (Left/Right), remembered across ticks: the last horizontal direction the player
	// steered. Unlike _xDirection (which reflects only THIS tick's input and is None when idle), _facing
	// holds the last non-None direction so a standing-still player still "faces" the way they last turned.
	// Purely input-derived, so it's deterministic / replay-safe. Defaults Right (matches the spawn pose).
	Direction _facing = Direction.Right;
	PlayerAnimType _currentAnimType;
	// The animation name actually playing, AFTER per-tick variant remaps (Solar's sad face / dim
	// bolt). Can change while _currentAnimType stays put — e.g. the sunlight beam moves off an idle
	// player — so PlayAnim dedupes on this, not just the type.
	string _currentAnimName = "idle";
	string _pendingDeathAnimation;
	bool _animInitialized;
	const float SOLAR_DEATH_BURST_DELAY_MIN = 0.045f;
	const float SOLAR_DEATH_BURST_DELAY_MAX = 0.085f;
	const float SOLAR_DEATH_BURST_CENTER_RADIUS = 8f;
	Vector2 _solarDeathBurstOrigin;
	float _solarDeathBurstTimer;
	int _solarDeathBurstsRemaining;

	// ==========================================================================================
	// ATTACHMENT — the ONE surface that currently owns/carries the player. Riding (standing on a
	// moving platform), WallHug (glued to a block's side while pressing into it), CeilingCling
	// (hanging from an overhead surface with Up held) and Stuck (glued to a BlockSticky face) are
	// mutually exclusive by construction: there is a single slot, and attaching anything releases
	// whatever held us before. All transitions go through Attach()/Detach() so the invariant can't
	// drift. Kind-specific scratch state below is only meaningful for the current kind and is
	// (re)initialised at attach time.
	// ==========================================================================================
	enum Attachment { Free, Riding, WallHug, CeilingCling, Stuck }

	Attachment _attach = Attachment.Free;
	Entity2D _attachEntity;                  // the attached surface (null = arena ceiling, CeilingCling only)
	Direction _attachFace = Direction.None;  // relative direction of the surface FROM us (Riding = Down)
	float _attachTangentOffset;              // Riding/Stuck: our position ALONG the face, relative to the entity centre
	bool _attachWasStopped;                  // Riding/WallHug: was the block stopped last frame? (inertia-fling edge)
	Vector2 _attachParentLastPosition;        // Riding/WallHug: parent position last handled tick
	float _ceilingLastX;                     // CeilingCling: the block's X last tick (horizontal carry)

	float WallHugSurfaceVelocityY => _attach == Attachment.WallHug ? _attachEntity?.VelY ?? 0f : 0f;

	/// <summary>The sustained field channels' combined contribution to motion this tick — wind + magnet
	/// + siren, scaled by <see cref="StasisFieldFactor"/> (a stasis trail resists the fields' shove).
	/// Every consumer of "what are the fields actually doing to us" — the position integration,
	/// <see cref="TotalVelocity"/>, fall-damage closing speed, the field-lift gravity cancel, the
	/// mantle fall gate, the repel/kickback counter-momentum — reads this sum, so the attenuation is
	/// applied uniformly. The raw channels remain the feed/decay state only.</summary>
	Vector2 FieldVelocity => (_windVel + _magnetVel + _sirenVel) * StasisFieldFactor;

	/// <summary>The player's TOTAL commanded world-space motion: base velocity plus every push channel
	/// (dash/fling ExtraVel, grapple release, wind, magnet, shockwave, swarm repel, siren). The sustained
	/// fields enter via <see cref="FieldVelocity"/> (stasis-attenuated), matching what the position
	/// integration actually spends. Base VelY stays world-relative during a wall-hug carry, so nothing
	/// double-counts.</summary>
	Vector2 TotalVelocity => new Vector2(
		VelX + ExtraVelX + _grappleReleaseVelocity.x + FieldVelocity.x + _shockwaveVel.x + _swarmRepelVel.x,
		VelY + ExtraVelY + _grappleReleaseVelocity.y + FieldVelocity.y + _shockwaveVel.y + _swarmRepelVel.y );

	/// <summary>Attach to a surface, releasing whatever previously held us (single slot). Owns ALL the
	/// kind-specific scratch initialisation — tangent offset, stopped-flag, ceiling last-X — so a call
	/// site can't forget one: a stale <see cref="_attachWasStopped"/> would fire a phantom inertia fling
	/// off an already-stopped block, and a stale tangent offset would snap us to the block's centre on
	/// the next glue tick. Every value is a pure function of (kind, entity, face) + our position, all
	/// read at the same point in the tick the old hand-inits ran, so folding them here is exact.</summary>
	void Attach( Attachment kind, Entity2D entity, Direction face )
	{
		_stickyStuckTime = 0f;
		_attach = kind;
		_attachEntity = entity;
		_attachFace = face;
		// Position along the attached face relative to the entity centre: a Y-offset on a side face
		// (Stuck climbing), an X-offset on a top/bottom (Riding glue, Stuck walking, ceiling-charge pin).
		// Unused by WallHug and ordinary CeilingCling; null entity (arena ceiling) has no tangent.
		bool verticalFace = face == Direction.Left || face == Direction.Right;
		_attachTangentOffset = entity == null ? 0f : (verticalFace ? Y - entity.Y : X - entity.X);
		// Seed the inertia-fling edge detector with the block's CURRENT stopped state, so attaching to
		// an already-stopped block can't fire a fling from its banked PreImpactVelocity.
		_attachWasStopped = (entity as Block)?.IsStopped ?? false;
		_attachParentLastPosition = entity?.Position ?? Position;
		// CeilingCling's horizontal-carry baseline (null entity = static arena ceiling: zero carry).
		_ceilingLastX = entity?.X ?? X;
		// Any slot transition consumes the timed cling. WallHug's per-step carry is rebuilt from the
		// attached entity in HandleMovingPlatforms, never inherited across attachment transitions.
		CancelWallCling();
		_wallHugCarryY = 0f;
		_wallHugStepVelocityY = 0f;
	}

	void Detach()
	{
		_stickyStuckTime = 0f;
		_attach = Attachment.Free;
		_attachEntity = null;
		_attachFace = Direction.None;
		_attachTangentOffset = 0f;
		_attachWasStopped = false;
		_attachParentLastPosition = Vector2.Zero;
		_ceilingLastX = 0f;
		CancelWallCling();
		_wallHugCarryY = 0f;
		_wallHugStepVelocityY = 0f;
		if ( _stickyPlayerGoo is not null )
			_stickyPlayerGoo.Enabled = false;
	}

	/// <summary>Consume an in-progress timed wall cling. The per-side
	/// <see cref="_clungLeftWall"/>/<see cref="_clungRightWall"/> latches are
	/// deliberately NOT cleared — they re-arm only by leaving the wall, which is exactly what blocks the
	/// tap-repress cling-spam exploit; clearing them here would reopen it (WallHug re-attaches on every
	/// fresh press into the same wall).</summary>
	void CancelWallCling()
	{
		_wallClingCounter = 0;
		_wallClingHold = false;
	}

	/// <summary>Release only a platform attachment (Riding/WallHug) — the "we jumped/launched off the
	/// platform" paths, which must never break a sticky grip or ceiling cling. The timed wall cling is
	/// consumed UNCONDITIONALLY: an ARENA-wall hug has no attachment (nothing to Detach), so without
	/// this a shockwave/repel/jump broke a block-wall freeze (via Detach's CancelWallCling) but left an
	/// arena-wall freeze pinning the player through the launch. Every caller is a launch off the
	/// surface, where consuming the freeze is right regardless of what we were (or weren't) attached
	/// to; sticky/ceiling grips are untouched — the cancel only spends the wall-freeze timer.</summary>
	void DetachPlatform()
	{
		CancelWallCling();
		if ( _attach == Attachment.Riding || _attach == Attachment.WallHug )
			Detach();
	}

	void TrackRidingDisplacement( float deltaX, float deltaY )
	{
		if ( _attach != Attachment.Riding ) return;
		bool verticalFace = _attachFace == Direction.Left || _attachFace == Direction.Right;
		_attachTangentOffset += verticalFace ? deltaY : deltaX;
	}

	/// <summary>Glued to a BlockSticky face (see HandleStickyBlocks). Public for effects that must read
	/// it — the Shockwave ring skips its hit-stop on a stuck player (the glue zeroes the knockback, so
	/// freezing the sim would punctuate a hit that visibly does nothing).</summary>
	public bool IsStuck => _attach == Attachment.Stuck;
	/// <summary>The face we're glued to while Stuck (the block's direction relative to us), else None.
	/// The impostor AI reads it to steer an escape along the goo's tangent (see AiInputSource).</summary>
	public Direction StuckFace => _attach == Attachment.Stuck ? _attachFace : Direction.None;
	/// <summary>Centre of the gluing block while Stuck (our own position when free/just orphaned) —
	/// the AI's reference point for picking the nearest escape edge along the tangent.</summary>
	public Vector2 StuckBlockCenter => StuckBlock is Block { Replaced: false } b ? b.Position : Position;
	/// <summary>The sticky block gluing us (valid only while Stuck; EngageStick only ever attaches a Block).</summary>
	Block StuckBlock => _attach == Attachment.Stuck ? (Block)_attachEntity : null;
	/// <summary>The platform carrying us while Riding/WallHug, else null.</summary>
	Entity2D PlatformEntity => (_attach == Attachment.Riding || _attach == Attachment.WallHug) ? _attachEntity : null;

	/// <summary>While attached to a surface, render-snap in the PARENT's pixel frame instead of the
	/// world grid. The base snap rounds each entity independently, and a rider's sub-pixel phase
	/// differs from its carrier's — each crosses its rounding boundary on a different tick, so the
	/// rendered gap between them toggles by 1px once per pixel of travel (a visible shimmy while
	/// riding a horizontally-moving block). Snapping the parent first and rounding only our OFFSET
	/// from it keeps the rendered gap constant while the glue holds. Render-only; the sim (and
	/// therefore replays) never reads WorldPosition.</summary>
	public override void SyncTransform()
	{
		Entity2D parent = _attach != Attachment.Free ? _attachEntity : null; // null = arena ceiling cling
		if ( parent == null )
		{
			base.SyncTransform();
			return;
		}

		RectF parentRect = parent.GetPixelRect( parent.X, parent.Y );
		float left = parentRect.Left + MathF.Round( Left - parent.Left );
		float bottom = parentRect.Bottom + MathF.Round( Bottom - parent.Bottom );
		WorldPosition = new Vector3( left + Width / 2f, bottom + Height / 2f, Globals.DepthToZ( Depth ) );
	}

	// ==========================================================================================
	// WALL-HUG PREDICATE — computed in ONE place and reused by every consumer (wall jump arm/pause,
	// gravity/cling/climb/slide, dash re-arm, back-flip cancel, animation). A side contact whose
	// entity is ALSO our floor is the block we're standing on, not a wall (FLOOR-IS-NOT-A-WALL: the
	// glue's Bottom = parent.Top float round-trip, or a rising block catching our feet, leaves us a
	// hair inside the block underfoot and the sideways probe reads it as a left/right wall).
	// Null-guarded so a REAL corner (arena floor + arena side wall, both null) still counts.
	// Properties (not cached per tick) so each call site keeps its current timing: read before the
	// collision-flag refresh (e.g. HandleDash at the top of Tick) they see last tick's contacts,
	// read after it they see this tick's — exactly as the hand-derived copies did.
	// ==========================================================================================
	bool LeftIsFloor => _collidingEntityLeft is not null && _collidingEntityLeft == (_gravityReversed ? _collidingEntityUp : _collidingEntityDown);
	bool RightIsFloor => _collidingEntityRight is not null && _collidingEntityRight == (_gravityReversed ? _collidingEntityUp : _collidingEntityDown);
	bool HuggingLeftWall => CAN_WALL_HUG && CollidingLeft && LeftPressed && !LeftIsFloor
		&& (!HAS_CHARGE_WALL_JUMP || _suppressedChargeSurfaceDirection != Direction.Left);
	bool HuggingRightWall => CAN_WALL_HUG && CollidingRight && RightPressed && !RightIsFloor
		&& (!HAS_CHARGE_WALL_JUMP || _suppressedChargeSurfaceDirection != Direction.Right);
	bool HuggingWall => HuggingLeftWall || HuggingRightWall;

	// The inertia fling only fires on a momentum-handoff stop (Block.InertiaHandoffThisTick: a real
	// slam, or the hunter's full-speed mid-lane brake) at a meaningful speed. A block whose move
	// FAILED — re-opened its eyes facing an adjacent block, crept sub-pixel into it for the 0.33s
	// grind window, and re-stopped at a crawl — must not hand off momentum. The speed check remains
	// a defensive per-axis floor for the relevant component of the stop velocity.
	const float INERTIA_FLING_MIN_SPEED = 25.0f;

	public bool IsDead { get; private set; }

	/// <summary>Bumped every time the player is teleported via <see cref="TeleportTo"/> (arena wrap / swap-
	/// portal) — a DISCONTINUOUS position change that is NOT ordinary movement. Consumers compare it
	/// across ticks to distinguish a teleport from a normal per-tick move (e.g. the shockwave ring makes
	/// the player immune to a ring they only entered by teleporting past its active edge).</summary>
	public int TeleportSequence { get; private set; }

	float _runningDustTimer;
	const float RUNNING_DUST_INTERVAL_MIN = 0.05f;
	const float RUNNING_DUST_INTERVAL_MAX = 0.16f;
	float _wallClimbCloudTimer;
	const float WALL_CLIMB_CLOUD_INTERVAL_MIN = 0.05f;
	const float WALL_CLIMB_CLOUD_INTERVAL_MAX = 0.20f;
	float _ceilingClimbParticleTimer;
	const float CEILING_CLIMB_PARTICLE_INTERVAL_MIN = 0.05f;
	const float CEILING_CLIMB_PARTICLE_INTERVAL_MAX = 0.16f;

	float _runningSfxAmount;
	float _wallClimbSfxAmount;
	float _ceilingClimbSfxAmount;
	const float RUNNING_SFX_REQ = 12.0f;

	// Footsteps for a Swarm GROUP: every body may carry the track — a lone clone walking under an
	// airborne owner still sounds — but the group shares one cadence, gated by a timestamp on the
	// authoritative owner. A global rate limit can't do this: a window small enough for Solar's dash
	// cadence (~0.05s/step) passes nearly every interleaved group step, and one wide enough to
	// coalesce the group eats fast solo footsteps. Group scoping resolves it: non-swarm characters
	// bypass the gate entirely, and within a group the window sits safely below Swarm's own footstep
	// cadence (12px per step at 62px/s walk ≈ 0.19s), so a single walking body never loses a step.
	// NOTE: the margin depends on Swarm's MaxXSpeed staying under ~100px/s.
	const float SWARM_GROUP_FOOTSTEP_WINDOW = 0.12f;
	float _groupFootstepTime = float.NegativeInfinity;

	/// <summary>True when this body should emit its footstep. Swarm-group bodies (the owner and its
	/// clones) share the owner's timestamp; everyone else always passes.</summary>
	bool TryPlayGroupFootstep()
	{
		if ( !IsSwarmClone && !_abilities.HasSwarm ) return true;
		Player root = IsSwarmClone ? SwarmOwner : this;
		if ( root is null ) return true;
		if ( RealTime.Now - root._groupFootstepTime < SWARM_GROUP_FOOTSTEP_WINDOW ) return false;
		root._groupFootstepTime = RealTime.Now;
		return true;
	}
	const float WALL_CLIMB_SFX_REQ = 12.0f;
	const float CEILING_CLIMB_SFX_REQ = 12.0f;

	// Where this player reads its input. The human player uses the live snapshot (byte-identical to
	// reading InputState directly); a Summoner impostor supplies an AiInputSource so the SAME physics
	// drive an AI clone. See IPlayerInput.
	private IPlayerInput _input = LiveInputSource.Instance;
	private bool _preserveUncontrolledAirMomentum;
	private bool _directionalInputConsumed;
	private float _blinkPrepareFactor = 1f;
	private bool _blinkPreparing;
	private Vector2 _sustainedShakeOffset;
	private Vector2 _transientShakeOffset;
	private Vector2 _transientShakeAmplitude;
	private float _transientShakeMagnitude;
	// How SampleTransientShake turns the amplitude above into each frame's offset: Axis buzzes along it
	// with a re-rolled sign, Random ignores it for a fresh angle, Directional starts ON it and rings out.
	enum ShakeStyle { Axis, Random, Directional }
	private ShakeStyle _transientShakeStyle;
	// Per-frame wind-down of a Directional jolt (Block.SHAKE_RECOVERY's role, gentler: the player's
	// jolts are ~2px, so 0.5 would round the second frame away and lose the ring-out entirely).
	private const float SHAKE_JOLT_RECOVERY = 0.65f;
	private int _transientShakeFrames;
	private float _controlSwitchBounceTime = -1f;
	private AiInputSource _ai;
	private const float CONTROL_SWITCH_BOUNCE_PEAK = 1.15f;
	private const float CONTROL_SWITCH_BOUNCE_UP = 0.05f;
	private const float CONTROL_SWITCH_BOUNCE_DOWN = 0.10f;

	/// <summary>True when this body is AI-controlled rather than a run player. Gates ending the run on
	/// death and block-side pressing; friendly Swarm clones opt back into pressing.</summary>
	public bool IsImpostor { get; private set; }
	public bool IsSwarmClone { get; private set; }

	/// <summary>True when this body's cues belong to the person holding the pad: the human-controlled
	/// player and its own Swarm copies, never a hostile AI impostor (a Summoner's spawn). Vibration has
	/// no position to place it by — unlike a sound, which the mix places across the arena — so an
	/// impostor rumbling the controller reads as the player's OWN jump, landing or bounce. Impostors run
	/// the full movement code with Original's abilities (see GameStage.BuildImpostorCharacter) and drift
	/// around constantly, so they reach most of the shared cues below: every Haptics call in this file
	/// is gated on this.</summary>
	public bool DrivesHaptics => !IsImpostor || IsSwarmClone;
	public Player SwarmOwner { get; private set; }

	/// <summary>The AI clone's brain (null for run players). Exposes its per-step input and lifetime.</summary>
	public AiInputSource Ai => _ai;

	/// <summary>True when this body currently receives the run's recorded/live directional input.</summary>
	public bool HasInputControl => ReferenceEquals( _input, LiveInputSource.Instance );
	internal IPlayerInput InputSource => _input;

	/// <summary>Optional sprite tint applied in <see cref="CreateVisuals"/> (impostors are red).</summary>
	public Color? TintColor { get; set; }

	/// <summary>Blend toward a temporary ability tint without losing the player's base tint.</summary>
	public void SetAbilityTint( Color? color, float amount = 1f )
	{
		if ( _sprite is null ) return;
		Color baseColor = TintColor ?? Color.White;
		Color displayColor = color.HasValue ? Color.Lerp( baseColor, color.Value, amount ) : baseColor;
		float alpha = IsTwinDashing ? 0.48f : 1f;
		_sprite.Color = displayColor.WithAlpha( alpha );
	}

	/// <summary>Turn this player into an AI impostor driven by <paramref name="ai"/>.</summary>
	public void MakeImpostor( AiInputSource ai )
	{
		IsImpostor = true;
		_ai = ai;
		_input = ai;
	}

	public void MakeSwarmClone( Player owner )
	{
		IsImpostor = true;
		IsSwarmClone = true;
		SwarmOwner = owner;
		_input = owner.InputSource;
	}

	/// <summary>Assign the human input route. Impostors retain their AI source.</summary>
	internal void SetInputSource( IPlayerInput input )
	{
		if ( IsImpostor ) return;
		_input = input ?? NullPlayerInput.Instance;
	}

	/// <summary>Set whether this body is selected and whether the pair currently has directional input.</summary>
	internal void SetPairedControlActive( bool active, bool hasDirectionalInput )
	{
		if ( IsImpostor ) return;
		_preserveUncontrolledAirMomentum = !active || !hasDirectionalInput;
	}

	// Input accessors (mapped from the input source — live snapshot for the human, AI for an impostor).
	// NOT gated by the stasis trail — a stasis field leaves the player SOME (weakened) control rather than
	// deadening input (see StasisControl / ApplyGravity).
	bool LeftPressed => !_directionalInputConsumed && _input.Left;
	bool RightPressed => !_directionalInputConsumed && _input.Right;
	// In a reverse-gravity field the world is upside-down for the player, so the Up/Down keys swap: the
	// key that jumps off the floor is now DOWN (you jump downward off the ceiling), and holding UP dives
	// toward the ceiling. Swapping at this single choke point keeps every vertical consumer (jump trigger,
	// hover, fast-fall, etc.) consistent without touching each call site. Horizontal keys are unchanged.
	bool UpPressed => !_directionalInputConsumed && (_gravityReversed ? _input.Down : _input.Up);
	bool DownPressed => !_directionalInputConsumed && (_gravityReversed ? _input.Up : _input.Down);
	bool UpJustPressed => !_directionalInputConsumed && (_gravityReversed ? _input.DownJust : _input.UpJust);
	bool LeftJustPressed => !_directionalInputConsumed && _input.LeftJust;
	bool RightJustPressed => !_directionalInputConsumed && _input.RightJust;
	bool DownJustPressed => !_directionalInputConsumed && (_gravityReversed ? _input.UpJust : _input.DownJust);

	/// <summary>Consume all directional held/edge input for the rest of this physics tick. The raw
	/// snapshot remains intact for replay recording and ability gesture recognition.</summary>
	public void ConsumeDirectionalInputForTick()
	{
		_directionalInputConsumed = true;
	}

	/// <summary>The toward-floor ("crouch") key, tracking the reverse-field swap — physical Down normally,
	/// physical Up while gravity is reversed. Public for the Gunner's crouch-reload gate, which must read
	/// the same swapped semantics as the movement it sits alongside.</summary>
	public bool CrouchHeld => DownPressed;

	/// <summary>Whether shared double-tap gestures should ignore this tick's directional chord because
	/// its gravity-relative Up press is (or will be) consumed by a WALL jump (HandleWallJumping runs later
	/// this tick, after ability PreTick). Ground/ceiling jump presses stay valid taps — double-tapping Up
	/// on the floor should fire an upward ability — but ground-jump-then-instant-wall-jump must not read
	/// as a double tap. Mirrors the wall-jump gate: armed window (live hug, or wall coyote not stolen by
	/// a coyote ground jump — see yieldToWallJump), not a press the wall jump would reject (neutral
	/// without CanNeutralWallJump) — or a press about to be jump-BUFFERED into a wall within reach
	/// (see WillBufferedPressWallJump).</summary>
	internal bool ShouldIgnoreWallJumpDoubleTapPress
	{
		get
		{
			if ( !_abilities.IgnoreWallJumpDoubleTaps || !CAN_WALL_JUMP ) return false;
			if ( EffectiveGravityDirection is not (Direction.Up or Direction.Down) ) return false;
			if ( OnFloor || !(_gravityReversed ? _input.DownJust : _input.UpJust) ) return false;
			if ( !LeftPressed && !RightPressed && !_abilities.CanNeutralWallJump ) return false;
			bool liveHug = HuggingWall && !_suppressWallRearm;
			if ( liveHug || (_wallJumpLeniencyCounter > 0 && _groundedLeniencyCounter <= 0) ) return true;
			return WillBufferedPressWallJump();
		}
	}

	/// <summary>An airborne Up press with no wall contact yet latches into the jump buffer
	/// (HandleJumpBuffer) and fires a wall jump on contact up to JUMP_BUFFER_FRAMES later — ticks AFTER
	/// the double-tap gesture would already have triggered, so the flush-contact checks above can't see
	/// it. Predict it instead: holding toward a wall close enough to reach within the buffer window
	/// (approach speed over the window, plus a small margin for hold acceleration and the 1px flush
	/// threshold) means this press IS that wall jump.</summary>
	bool WillBufferedPressWallJump()
	{
		if ( JUMP_BUFFER_FRAMES <= 0 || !CAN_WALL_HUG || _suppressWallRearm ) return false;
		float velX = VelX + ExtraVelX;
		float window = JUMP_BUFFER_FRAMES * Arena.STEP;
		const float margin = 3f;
		if ( LeftPressed && IsPlayerColliding( X - (Math.Max( 0f, -velX ) * window + margin), Y, Direction.Left, out _ ) )
			return true;
		if ( RightPressed && IsPlayerColliding( X + (Math.Max( 0f, velX ) * window + margin), Y, Direction.Right, out _ ) )
			return true;
		return false;
	}

	/// <summary>Whether the Gunner is supported by a surface that permits crouch-reloading. A ledge
	/// only qualifies when crouch was held as the grab began and remains held for the entire hang.</summary>
	internal bool IsGunnerReloadSupported => OnFloor || IsStuck || (_mantleHanging && _mantleGunnerReloadArmed);
	internal bool IsGunnerAirborne => !OnFloor && !IsStuck && !_mantleHanging;

	internal void SetGunnerReloadMovementFactor( float factor )
	{
		_gunnerReloadMovementFactor = Math.Clamp( factor, 0f, 1f );
	}

	internal void SetGunnerAirFrictionFactor( float factor )
	{
		_gunnerAirFrictionFactor = Math.Max( 0f, factor );
	}

	/// <summary>Probe floor contact at the current position instead of relying on last tick's flags.</summary>
	public bool IsTouchingFloorNow()
	{
		Direction floor = EffectiveGravityDirection;
		Vector2 gravity = DirectionVector( floor );
		return IsPlayerColliding( X + gravity.x, Y + gravity.y, floor, out _ );
	}

	static Direction OppositeDirection( Direction direction ) => direction switch
	{
		Direction.Left => Direction.Right,
		Direction.Right => Direction.Left,
		Direction.Up => Direction.Down,
		Direction.Down => Direction.Up,
		_ => Direction.None,
	};

	static Vector2 DirectionVector( Direction direction ) => direction switch
	{
		Direction.Left => new Vector2( -1f, 0f ),
		Direction.Right => new Vector2( 1f, 0f ),
		Direction.Up => new Vector2( 0f, 1f ),
		Direction.Down => new Vector2( 0f, -1f ),
		_ => Vector2.Zero,
	};

	bool IsCollidingDirection( Direction direction ) => direction switch
	{
		Direction.Left => CollidingLeft,
		Direction.Right => CollidingRight,
		Direction.Up => CollidingUp,
		Direction.Down => CollidingDown,
		_ => false,
	};

	// Gunner pose override (reset every tick, set by GunnerAbility.PreTick): when present it forces the
	// reload / directional-shoot animation instead of the normal locomotion anim. Purely visual (no sim
	// effect). _gunnerPoseFace forces facing for side shots (Left/Right); None keeps the current facing.
	private PlayerAnimType? _gunnerPose;
	private Direction _gunnerPoseFace;

	/// <summary>Force the Gunner's reload / shoot pose animation for this frame, and optionally its facing
	/// (Left/Right for a side shot; None keeps the current facing). Visual only.</summary>
	public void SetGunnerPose( PlayerAnimType pose, Direction faceDir )
	{
		_gunnerPose = pose;
		_gunnerPoseFace = faceDir;
	}

	// ----------------------------------------------------------------------------------------
	// SHIFTER TRACE: temporary diagnostics for surface-gravity transitions. Toggle with the
	// `shifter_trace` console command, then play the repro (replays included — the trace only READS
	// sim state, so determinism is untouched). Logs every adoption, attach/detach with its reason,
	// inertia fling, and blind-unpenetrate shove, plus a per-tick state line, so the exact chain
	// behind a physics oddity can be reconstructed from the console output.
	static bool _shifterTrace;
	static int _shifterTraceTick;

	[ConCmd( "shifter_trace" )]
	static void ToggleShifterTrace()
	{
		if ( !Game.IsEditor ) return;
		_shifterTrace = !_shifterTrace;
		Log.Info( $"[shifter] trace {(_shifterTrace ? "ON" : "OFF")}" );
	}

	void TraceShifter( string message )
	{
		if ( _shifterTrace )
			Log.Info( $"[shifter {_shifterTraceTick}{(IsImpostor ? " imp" : "")}] {message}" );
	}

	// CHARGE TRACE (same pattern): toggle with `charge_trace`, then wind charge jumps. Logs the
	// charge lifecycle — start, every change of the touching/holding/raw-input picture while
	// winding, and the exact exit (fired vs lost) — each line paired with the input layer's stick
	// pipeline from the last live Sample (raw AnalogMove, decontaminated amounts, magnitude,
	// hysteresis + charge-latch state, resolver results). Read-only; determinism untouched. Built
	// to pin down phantom charge releases on analog sticks.
	static bool _chargeTrace;
	string _chargeTraceLastState;

	[ConCmd( "charge_trace" )]
	static void ToggleChargeTrace()
	{
		if ( !Game.IsEditor ) return;
		_chargeTrace = !_chargeTrace;
		InputState.StickTraceEnabled = _chargeTrace;
		Log.Info( $"[charge] trace {(_chargeTrace ? "ON" : "OFF")}" );
	}

	void TraceCharge( string message )
	{
		if ( _chargeTrace )
			Log.Info( $"[charge] {message}" );
	}

	// CRUSH TRACE (same pattern as the shifter trace above): turn on with `crush_trace`, then play
	// the repro (replays included — read-only, determinism untouched). Logs every corner deflect,
	// every residual rescue/kill with its full candidate table, and each KilledByCrushing call
	// site, so a wrong squish can be attributed to the exact resolve that caused it.
	static bool _crushTrace;
	static int _crushTraceTick;

	[ConCmd( "crush_trace" )]
	static void ToggleCrushTrace()
	{
		if ( !Game.IsEditor ) return;
		_crushTrace = !_crushTrace;
		Log.Info( $"[crush] trace {(_crushTrace ? "ON" : "OFF")}" );
	}

	void TraceCrush( string message )
	{
		if ( _crushTrace )
			Log.Info( $"[crush {_crushTraceTick}{(IsImpostor ? " imp" : "")}] {message}" );
	}

	// DEATH TRACE (same pattern as the traces above): turn on with `death_trace`, then play the
	// repro (replays included — read-only, determinism untouched). Every kill entry point logs its
	// cause, the exact CALL SITE (caller member + line, so a KillByCrush from the wrap ability's
	// closed-wall pin is distinguishable from a block press or a teleport landing), and the state
	// needed to attribute it: position, velocity, effective gravity, attachment. Die() then either
	// confirms the death or names the ability that intercepted it — a kill line with no DEAD line
	// was swallowed by an immunity/squash. The wrap ability adds its own context lines (teleports,
	// aborts, pins, deferred spike kills) through TraceDeathGlobal. Tick stamps share
	// _crushTraceTick so lines cross-reference with crush_trace output.
	static bool _deathTrace;

	[ConCmd( "death_trace" )]
	static void ToggleDeathTrace()
	{
		if ( !Game.IsEditor ) return;
		_deathTrace = !_deathTrace;
		Log.Info( $"[death] trace {(_deathTrace ? "ON" : "OFF")}" );
	}

	internal static bool DeathTraceEnabled => _deathTrace;

	/// <summary>Death-trace hook for code outside Player (e.g. the wrap ability's context lines).
	/// Gate call sites on <see cref="DeathTraceEnabled"/> to skip the string build when off.</summary>
	internal static void TraceDeathGlobal( string message )
	{
		if ( _deathTrace )
			Log.Info( $"[death {_crushTraceTick}] {message}" );
	}

	void TraceDeath( string message )
	{
		if ( _deathTrace )
			Log.Info( $"[death {_crushTraceTick}{(IsImpostor ? " imp" : "")}] {message}" );
	}

	string DeathTraceState =>
		$"pos=({X:0.00},{Y:0.00}) vel=({VelX:0.00},{VelY:0.00}) gravity={EffectiveGravityDirection} attach={_attach}";

	// SWARM TRACE (same pattern as the traces above): turn on with `swarm_trace`, then play the repro
	// (replays included — read-only, determinism untouched). Logs every bud, save-by-adoption, retire
	// and death across the whole swarm, plus a per-tick line per body flagged when it moved further in
	// one tick than movement can explain — so a mystery teleport can be attributed to the code that did it.
	static bool _swarmTrace;
	internal static int SwarmTraceTick;
	const float SWARM_TRACE_JUMP_PX = 12f;

	[ConCmd( "swarm_trace" )]
	static void ToggleSwarmTrace()
	{
		if ( !Game.IsEditor ) return;
		_swarmTrace = !_swarmTrace;
		Log.Info( $"[swarm] trace {(_swarmTrace ? "ON" : "OFF")}" );
	}

	internal static bool SwarmTracing => _swarmTrace;

	internal static void TraceSwarm( string message )
	{
		if ( _swarmTrace ) Log.Info( $"[swarm {SwarmTraceTick}] {message}" );
	}

	/// <summary>Trace label: the authoritative body is P, every bud carries the id stamped at spawn.</summary>
	internal int SwarmTraceId { get; set; }
	internal string SwarmLabel => IsSwarmClone ? $"c{SwarmTraceId}" : "P";
	/// <summary>Any body belonging to a swarm — the authoritative player or one of its copies.</summary>
	internal bool IsSwarmBody => IsSwarmClone || _abilities.HasSwarm;
	Vector2 _swarmTracePrevPos;
	bool _swarmTracePrevValid;

	/// <summary>One-line body state for the swarm trace.</summary>
	internal string DescribeSwarmBodyForTrace()
	{
		string ent = _attachEntity is null ? "-" : $"{_attachEntity.GetType().Name}@({_attachEntity.X:0.0},{_attachEntity.Y:0.0})";
		return $"{SwarmLabel} pos=({X:0.00},{Y:0.00}) vel=({VelX:0.0},{VelY:0.0}) extra=({ExtraVelX:0.0},{ExtraVelY:0.0})"
			+ $" attach={_attach}/{_attachFace} ent={ent}"
			+ $" LRUD={(CollidingLeft ? 1 : 0)}{(CollidingRight ? 1 : 0)}{(CollidingUp ? 1 : 0)}{(CollidingDown ? 1 : 0)}"
			+ $" dead={IsDead}";
	}

	/// <summary>Per-tick swarm snapshot, flagging a position change too large for one tick of movement
	/// (the fingerprint of a teleport or a stale-parent glue snap).</summary>
	internal void TraceSwarmTick()
	{
		if ( !_swarmTrace ) return;
		string jump = "";
		if ( _swarmTracePrevValid )
		{
			Vector2 delta = Pos - _swarmTracePrevPos;
			if ( delta.Length > SWARM_TRACE_JUMP_PX )
				jump = $"  !! JUMPED ({delta.x:0.00},{delta.y:0.00})";
		}
		_swarmTracePrevPos = Pos;
		_swarmTracePrevValid = true;
		TraceSwarm( DescribeSwarmBodyForTrace() + jump );
	}

	static string DescribeCrusher( bool crushing, Entity2D entity )
		=> !crushing ? "-"
		: entity is null ? "wall"
		: $"{entity.GetType().Name}@({entity.X:0.0},{entity.Y:0.0})v({entity.Velocity.x:0.0},{entity.Velocity.y:0.0})";

	/// <summary>Diagnostics for the on-screen debug overlay.</summary>
	public string DebugInfo()
	{
		return $"grounded:{CollidingDown}  L:{CollidingLeft} R:{CollidingRight} U:{CollidingUp}\n"
			+ $"gravity:{EffectiveGravityDirection}  base:{_surfaceGravityDirection}  reverseField:{_fieldReversed}\n"
			+ $"attach:{_attach}  face:{_attachFace}\n"
			+ $"velX:{VelX:0.0}  velY:{VelY:0.0}\n"
			+ $"groundLeniency:{_groundedLeniencyCounter}  wallLeniency:{_wallJumpLeniencyCounter}\n"
			+ $"wallDir:{_wallDirection}  wallJumpDir:{_wallJumpDirection}\n"
			+ $"in Up:{InputState.Up} UpJust:{InputState.UpJust} L:{InputState.Left} R:{InputState.Right} Dn:{InputState.Down}\n"
			+ $"ctrl:{Input.UsingController}  Jump(A/space):{Input.Down( "Jump" )}  Up(W/dpad):{Input.Down( "Up" )}  UpArrow:{Input.Down( "UpArrow" )}\n"
			+ $"rawDpadUp:{Input.Keyboard.Down( "UPARROW" )}  analogMove:{Input.AnalogMove}\n"
			+ $"pos:({X:0.0},{Y:0.0})  dead:{IsDead}";
	}

	// ----------------------------------------------------------------------------------------
	public void CreateVisuals()
	{
		var character = Character ?? Characters.Original;
		_activeCharacter = character;
		_move = character.Movement ?? CharacterMovement.Original;
		_abilities = character.Abilities ?? CharacterAbilities.Original;
		_hoverRemaining = HOVER_FRAMES;

		// Fresh spawn: the Flipper's persistent self-gravity flip always starts normal (a new run/replay
		// gets a fresh Player, so this just makes the default explicit).
		_selfGravityReversed = false;
		_fieldReversed = false;
		_gravityReversed = false;
		_surfaceGravityDirection = Direction.Down;
		_surfacePreviousGravityDirection = Direction.None;
		_surfaceContactThisTick = Direction.None;
		_surfaceJumpWindowFrame = Direction.Down;

		// Fresh spawn: no charge jump wound up.
		_chargingJump = false;
		_chargeJumpTime = 0f;
		_chargeJumpAimTime = 0f;
		_chargeJumpSurfaceDirection = Direction.None;
		_suppressedChargeSurfaceDirection = Direction.None;
		_chargeWallJumpGravityTimer = 0f;
		_chargeGroundedFrames = 0;
		_autoBounceCompressionFrames = 0;
		_wallClimbCloudTimer = 0f;
		_wallClimbSfxAmount = 0f;
		_ceilingClimbParticleTimer = 0f;
		_ceilingClimbSfxAmount = 0f;

		// Build the character's ability modules (the "code + state" tier; simple toggles are read
		// directly off _abilities). Kept in one place so the tick just iterates the list.
		_abilityModules.Clear();
		AddCharacterAbilityModules();

		Size = COLLISION_SIZE;
		Depth = Globals.DEPTH_PLAYER;
		_sprite = SpriteLayer.Add( GameObject, character.SpritePath, character.ArtSize, "idle" );
		if ( IsImpostor )
			_sprite.Opaque = true;
		CreateStickyPlayerGoo( character.ArtSize );
		CreateChargeAimIndicator();
		SetSpriteHead( HAS_SURFACE_GRAVITY ? new Vector2( 0f, 1f ) : null );
		// Impostors reuse Original's player art tinted red (a "hostile clone" look) — no separate sheet.
		if ( TintColor.HasValue )
			_sprite.Color = TintColor.Value;
		_currentAnimType = PlayerAnimType.Idle;
		_currentAnimName = "idle";
		_animInitialized = true;
		foreach ( var ability in _abilityModules )
			ability.OnSpawn( this );
	}

	// ======================================================================================
	public override void Tick( float dt )
	{
		TickSolarDeathSparks( dt );
		if ( IsDead ) return;
		_rewindDeathRecoveredThisTick = false;
		RefreshCharacter();
		if ( IsDead ) return;
		if ( HAS_SURFACE_GRAVITY && !IsImpostor ) _shifterTraceTick++;
		if ( !IsImpostor ) _crushTraceTick++;
		TickTransientShake();
		TickControlSwitchBounce( dt );
		if ( _autoBounceCompressionFrames > 0 ) _autoBounceCompressionFrames--;

		// MIMIC SQUASH: a flattened Mimic owns the whole tick — no input, movement, gravity, or
		// ability ticks (which is also what pauses MimicAbility's form timer). Hazard checks and the
		// has-room-to-pop-back probe run inside; everything else waits for the pop.
		if ( _squashState != SquashState.None )
		{
			TickSquash();
			return;
		}

		// Stasis-trail enter/leave sound cue (edge-triggered on this tick's trail membership, which the
		// block set before the player ticks).
		UpdateTrailAudio();

		// Reverse-gravity field enter/leave sound cue (edge-triggered, same pattern as the stasis cue).
		UpdateReverseGravityAudio();

		// A block-press launch (BlockPressBoost) scheduled by Unpenetrate this tick; applied after the
		// jump/gravity handlers below so their velocity-zeroing on the resolved face can't cancel it.
		_pressLaunchDir = Direction.None;
		_pressLaunchBlockVel = 0f;
		PressedBlockSideThisTick = false;
		// Cleared each tick; AdjustYPosition sets it when we actually rise into an overhead surface (a
		// genuine ceiling contact — the same resolve that presses the block's bottom face). Drives the
		// ceiling-cling engagement so it can't latch onto mere proximity short of the block.
		_ceilingHitThisTick = false;

		// Reset the Gunner pose override, then let ability modules run their PRE-move hook (the Gunner
		// advances its double-tap fire gesture + crouch-reload here, before the player moves).
		_gunnerPose = null;
		_gunnerPoseFace = Direction.None;
		if ( _mantleRegrabFrames > 0 ) _mantleRegrabFrames--;

		_directionalInputConsumed = false;
		_blinkPrepareFactor = 1f;
		_blinkPreparing = false;
		_twinDashEndedThisTick = false;
		_gunnerReloadMovementFactor = 1f;
		_gunnerAirFrictionFactor = null;
		if ( _mantleHanging && !CrouchHeld ) _mantleGunnerReloadArmed = false;
		for ( int i = 0; i < _abilityModules.Count; i++ )
		{
			_abilityModules[i].PreTick( this, dt );
			if ( !ReferenceEquals( Character ?? Characters.Original, _activeCharacter ) )
				break;
		}
		if ( IsDead || IsSquashed || _twinDashEndedThisTick || _rewindDeathRecoveredThisTick ) return;

		if ( IsHardened )
		{
			ClearMotion();
			PlayAnim( PlayerAnimType.Hardened );
			return;
		}
		if ( IsTwinDashing )
		{
			ClearMotion();
			return;
		}

		if ( !ReferenceEquals( Character ?? Characters.Original, _activeCharacter ) )
		{
			bool directionalInputConsumed = _directionalInputConsumed;
			RefreshCharacter();
			// A form change can land the body somewhere it doesn't fit; the return to Mimic squashes
			// there instead of dying (RefreshCharacter's residual resolve), so check both outcomes.
			if ( IsDead || IsSquashed ) return;
			_directionalInputConsumed |= directionalInputConsumed;
		}

		// __________________________________________________________________________________
		Direction newXDirection = HAS_SURFACE_GRAVITY ? HandleSurfaceGravityInput( dt ) : HandleHorizontalInput( dt );
		// Remember the last steered direction so a standing-still player keeps facing the way they turned.
		if ( newXDirection != Direction.None ) _facing = newXDirection;
		if ( !HAS_SURFACE_GRAVITY )
			ApplyHorizontalDeceleration( dt );
		HandleExtraVelocity( dt );
		_dashedThisTick = false;
		HandleDash( dt );
		if ( _dashedThisTick )
			_directionalInputConsumed = true;

		// ASSUME WE AREN'T COLLIDING
		_surfaceWasCollidingLeft = CollidingLeft;
		_surfaceWasCollidingRight = CollidingRight;
		_surfaceWasCollidingUp = CollidingUp;
		_surfaceWasCollidingDown = CollidingDown;
		_surfaceContactThisTick = Direction.None;
		ClearColliding();

		// CHECK FOR COLLISION WITH FIREBALLS
		foreach ( Fireball fireball in Stage.GetFireballs() )
		{
			if ( !fireball.IsSpawning && !fireball.IsDissipating && GetRect().Intersects( fireball.Hitbox ) )
			{
				KilledByFireball( fireball.Position );
				return;
			}
		}

		// CHECK FOR COLLISION WITH TEARDROPS
		foreach ( Teardrop teardrop in Stage.GetTeardrops() )
		{
			if ( !teardrop.HasSplashed && GetRect().Intersects( teardrop.Hitbox ) )
			{
				KilledByTeardrop( teardrop.Position );
				return;
			}
		}

		// CHECK IF WE ARE BEING CRUSHED
		CrushingLeft = IsPlayerBeingCrushed( Direction.Left, out _crushingEntityLeft );
		CrushingRight = IsPlayerBeingCrushed( Direction.Right, out _crushingEntityRight );
		CrushingDown = IsPlayerBeingCrushed( Direction.Down, out _crushingEntityDown );
		CrushingUp = IsPlayerBeingCrushed( Direction.Up, out _crushingEntityUp );

		if ( CheckForCrushing() )
		{
			// The squish anim axis must match the pair that actually CRUSHED, not just any pinned pair:
			// wedged horizontally between exonerated plates (an anvil escaping its hammer) while a
			// vertical pair does the killing used to play the horizontal squish for a vertical crush.
			bool horizontal = CrushingLeft && CrushingRight
				&& IsCrushAxisClosing( _crushingEntityLeft, _crushingEntityRight, vertical: false );
			TraceCrush( $"KILL probe-pair h={horizontal} pos=({X:0.00},{Y:0.00}) vel=({VelX:0.0},{VelY:0.0})"
				+ $" L={DescribeCrusher( CrushingLeft, _crushingEntityLeft )} R={DescribeCrusher( CrushingRight, _crushingEntityRight )}"
				+ $" D={DescribeCrusher( CrushingDown, _crushingEntityDown )} U={DescribeCrusher( CrushingUp, _crushingEntityUp )}" );
			// A twin-dashing body shrugs the kill off inside KilledByCrushing — no death, no presses.
			// Mimic squash survival intentionally still awards the crushing faces: the body bore the press.
			if ( !IsTwinDashing )
				AwardCrushKillPresses();
			KilledByCrushing( horizontal );
			return;
		}

		// UNPENETRATE FROM BLOCKS. Living sibling bodies are resolved once, symmetrically, by
		// GameStage after every player has ticked; resolving one here would make stable tick order matter.
		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block.PhasingIn ) continue; // a phasing Teleport block is intangible to the player
			if ( GetRect().Intersects( block.GetRect() ) )
				Unpenetrate( X, Y, block );
		}

		// The pass above resolves each block blindly (no destination check). If it left us genuinely
		// embedded in a solid, find a nearby validated push-out or die — a block forced us into a gap
		// we don't fit (e.g. an 8px slot over the block we stand on, ground against the arena wall),
		// which the sliver-lenient crush probes can't see.
		ResolveResidualPenetrationOrDie();
		if ( IsDead || IsSquashed || _rewindDeathRecoveredThisTick ) return;

		_wallHugCarryY = 0f;
		_wallHugStepVelocityY = 0f;
		HandleMovingPlatforms( dt );

		// ADJUST COLLISION FLAGS
		_onFloorLastTick = OnFloor; // bank the outgoing probe before it's overwritten (fall-damage gate)
		float THRESHOLD = 1.0f;
		CollidingLeft = IsPlayerColliding( X - THRESHOLD, Y, Direction.Left, out _collidingEntityLeft );
		CollidingRight = IsPlayerColliding( X + THRESHOLD, Y, Direction.Right, out _collidingEntityRight );
		CollidingDown = IsPlayerColliding( X, Y - THRESHOLD, Direction.Down, out _collidingEntityDown );
		CollidingUp = IsPlayerColliding( X, Y + THRESHOLD, Direction.Up, out _collidingEntityUp );
		// NOT while Stuck: the goo owns the gravity frame for the whole grip (EngageStick based us on
		// the stuck face). A fresh touch mid-grip — feet reaching the floor while climbing down a side
		// face, the block carrying us against a neighbour — must not re-adopt: the base flipping away
		// from the stuck face silently killed the tangent controls (ApplyStuckMovement reads climb input
		// from the axis the FACE implies, which HandleSurfaceGravityInput only feeds while the base
		// agrees) and TryAttachSurfaceGravityFloor could steal the attach slot outright (Attach(Riding)
		// over Stuck, no release). On release the base resumes from the face like any airborne shifter's.
		if ( HAS_SURFACE_GRAVITY && _attach != Attachment.Stuck )
		{
			ReleaseSeparatedSurfaceGravitySuppression();
			Direction freshContact = !_surfaceWasCollidingLeft && CollidingLeft ? Direction.Left
				: !_surfaceWasCollidingRight && CollidingRight ? Direction.Right
				: !_surfaceWasCollidingUp && CollidingUp ? Direction.Up
				: !_surfaceWasCollidingDown && CollidingDown ? Direction.Down
				: Direction.None;
			AdoptSurfaceGravity( freshContact );
			if ( IsDead ) return;
			TryAttachSurfaceGravityFloor( freshContact, CollidingEntity( freshContact ), "pass1" );
		}

		// WALL-KICK: must run AFTER the flags are set (it keys off the just-touched-a-wall edge) but
		// BEFORE AdjustXPosition (which zeroes VelX on impact) so a rebound this frame reverses the
		// still-live horizontal speed and the new VelX is what AdjustXPosition then moves us by.
		HandleWallKick( dt );

		// CHECK IF WE ARE TOUCHING SPIKES
		if ( CheckForSpikes() ) return;
		LastVelocity = Velocity;

		// A hang still passes through the normal crush, unpenetration, platform and spike checks above.
		// It only owns locomotion after those systems have had their turn for this fixed step.
		if ( _mantleHanging && HandleMantleHang() )
		{
			FinishMantleTick( dt );
			return;
		}

		// STICKY BLOCK: if we're stuck to a BlockSticky it owns this whole tick — glue + tangent-only
		// movement + jump/gravity lockout — so skip the normal position/gravity/jump path below.
		// EVERY body sticks — run player, Swarm clones and hostile impostors alike — but hostile
		// impostors never award the grab's press and their grips stay silent (see EngageStick).
		if ( HandleStickyBlocks( dt, newXDirection ) )
		{
			UpdateStickyPlayerGoo();
			_xDirection = newXDirection;
			foreach ( var ability in _abilityModules )
				ability.PostTick( this, dt );
			return;
		}
		// The stuck path can KILL (glued into a spiked wall / spiked obstacle face) — dying detaches, so
		// HandleStickyBlocks returns false; don't fall through into the normal move path dead.
		if ( IsDead || _rewindDeathRecoveredThisTick ) return;

		// ADJUST POSITION
		// Classified from the same pre-movement contact sample every hug/cling consumer reads; credited
		// after the move from our own displacement, so platform carry never counts as walking.
		DashRechargeSurface rechargeSurface = SampleDashRechargeSurface();
		Vector2 preMovePos = Pos;
		float preMoveWallCarryY = _wallHugCarryY; // Detach inside the sweep zeroes the field
		Vector2 fieldVel = FieldVelocity; // sampled once for both axes (stasis level can't change mid-tick)
		AdjustXPosition( ((VelX + ExtraVelX + _grappleReleaseVelocity.x) * _blinkPrepareFactor + fieldVel.x + _shockwaveVel.x + _swarmRepelVel.x) * dt );
		if ( IsDead || _rewindDeathRecoveredThisTick ) return;
		// Keep VelY world-relative for every other system, but integrate self-motion relative to a hugged
		// surface. Adding its resolved displacement in the same sweep avoids double carry and lets a floor
		// or ceiling stop the combined motion normally.
		float wallRelativeVelY = _attach == Attachment.WallHug ? VelY - _wallHugStepVelocityY : VelY;
		// An active wall-cling freeze (see ApplyGravity) pins the slide against EVERY force, not just
		// gravity: the external channels get no vertical vote while it holds. The carried channels are
		// zeroed by the freeze itself; the suppression here is what stops the per-tick RE-FEEDS — the
		// siren re-sings its full drift velocity every tick BEFORE this integration, so a post-hoc zero
		// alone never touches it. Horizontal components stay live: wind can still break the grip and
		// push us off the wall, which ends the hold naturally. Like every hug/cling consumer, the hold
		// runs on the tick's pre-movement contact sample, so a separation (wind peel-off, walking off a
		// ledge) trails by ONE tick: one extra tick of suppressed vertical re-feeds on the way out (the
		// carried channels were already zero from the freeze), one tick of delayed grab on the way in.
		// Deliberately NOT re-probed live — the cling would then disagree by a tick with the wall jump
		// arming, slide gravity and animation, which all read the same sampled flags.
		float clingHoldFactor = _wallClingHold ? 0f : 1f;
		AdjustYPosition( ((wallRelativeVelY + (ExtraVelY + _grappleReleaseVelocity.y) * clingHoldFactor) * _blinkPrepareFactor
			+ (fieldVel.y + _shockwaveVel.y + _swarmRepelVel.y) * clingHoldFactor) * dt + _wallHugCarryY );

		ClampToBounds( X, Y );
		// IsSquashed: the fall-damage kill inside AdjustYPosition squashes the Mimic form instead.
		if ( IsDead || IsSquashed || _rewindDeathRecoveredThisTick ) return;
		AccumulateDashRecharge( rechargeSurface, Pos - preMovePos, preMoveWallCarryY );
		if ( _mantleHanging )
		{
			FinishMantleTick( dt );
			return;
		}

		// If the shockwave knockback drove us into a solid, bounce ONLY that component — or DIE if that
		// surface is live spikes (see the method notes). Bail on the kill like every lethal phase.
		HandleShockwaveBounce();
		if ( IsDead || _rewindDeathRecoveredThisTick ) return;
		if ( HAS_SURFACE_GRAVITY )
		{
			RefreshSurfaceGravityContacts();
			ReleaseSeparatedSurfaceGravitySuppression();
			Direction contact = _surfaceContactThisTick;
			if ( !HasSurfaceGravitySupport( contact ) )
				contact = Direction.None;
			if ( contact == Direction.None && !HasSurfaceGravitySupport( EffectiveGravityDirection ) )
				contact = FindSupportedSurfaceGravityContact();
			AdoptSurfaceGravity( contact );
			if ( IsDead ) return;
			TryAttachSurfaceGravityFloor( contact, CollidingEntity( contact ), "pass2" );
			ReattachSurfaceGravityFloor();
			ApplySurfaceGravity( dt );
			HandleSurfaceGravityJump();
			UpdateAnimation( newXDirection );
			HandleRunningEffects( dt, newXDirection );
			_xDirection = newXDirection;
			foreach ( var ability in _abilityModules )
				ability.PostTick( this, dt );
			if ( _shifterTrace )
				TraceShifter( $"pos=({X:0.00},{Y:0.00}) vel=({VelX:0.0},{VelY:0.0}) extra=({ExtraVelX:0.0},{ExtraVelY:0.0}) "
					+ $"grav={EffectiveGravityDirection}(base {_surfaceGravityDirection}) attach={_attach}/{_attachFace}"
					+ $"{(_attachEntity is not null ? $" ent@({_attachEntity.X:0.0},{_attachEntity.Y:0.0}) entVel=({_attachEntity.Velocity.x:0.0},{_attachEntity.Velocity.y:0.0})" : "")}"
					+ $" LRUD={(CollidingLeft ? 1 : 0)}{(CollidingRight ? 1 : 0)}{(CollidingUp ? 1 : 0)}{(CollidingDown ? 1 : 0)}" );
			return;
		}

		ApplyGravity( dt );
		// ApplyGravity can now KILL (the ceiling-cling spiked-underside check): bail like every other
		// lethal phase above, or UpdateAnimation below overwrites the just-started death anim with a
		// live falling pose (Up is held during a cling, so it picked air_down_active) — and a dead
		// player never ticks again to fix it.
		if ( IsDead ) return;
		HandleGravityFlip();
		HandleVerticalJumping();
		HandleChargeJump( dt );
		if ( IsDead ) return; // a wall charge can now die on its face going live (same bail as ApplyGravity)
		HandleWallJumping( dt );
		HandleAirJump();
		HandleGroundPound();
		HandleVariableJumpHeight();
		HandleJumpBuffer();
		HandleBackFlip( dt );

		// Apply a scheduled block-press launch (see Unpenetrate) AFTER the jump/gravity handlers so their
		// velocity-zeroing on the pressed face doesn't cancel it.
		if ( _pressLaunchDir != Direction.None )
			ApplyPressLaunch( _pressLaunchDir );

		// UPDATE SPRITE ANIMATION
		UpdateAnimation( newXDirection );
		UpdateStickyPlayerGoo();
		_xDirection = newXDirection;

		HandleRunningEffects( dt, newXDirection );

		// Per-character ability modules (edge-wrap, …) run last, after movement + bounds are resolved.
		foreach ( var ability in _abilityModules )
			ability.PostTick( this, dt );
	}

	/// <summary>Rebuild movement tuning, ability toggles, modules and body sprite when
	/// <see cref="Character"/> is reassigned mid-run. Identity-level modules survive, while current-form
	/// modules, attachments, input gestures, orientation, and other character-local state are reset.</summary>
	void RefreshCharacter()
	{
		var character = Character ?? Characters.Original;
		if ( ReferenceEquals( character, _activeCharacter ) ) return;

		foreach ( var ability in _abilityModules )
			if ( !ability.PersistsAcrossCharacterChanges )
				ability.OnRemoved( this );
		_abilityModules.RemoveAll( ability => !ability.PersistsAcrossCharacterChanges );
		ResetCharacterLocalState();
		SolarMovementFactor = 1f;
		SolarJumpFactor = 1f;
		_activeCharacter = character;
		_move = character.Movement ?? CharacterMovement.Original;
		_abilities = character.Abilities ?? CharacterAbilities.Original;
		_hoverRemaining = HOVER_FRAMES;

		int firstNewAbility = _abilityModules.Count;
		AddCharacterAbilityModules();

		_sprite?.GameObject?.Destroy();
		_stickyPlayerGoo?.GameObject?.Destroy();
		DestroyChargeAimIndicator();
		_sprite = SpriteLayer.Add( GameObject, character.SpritePath, character.ArtSize, "idle" );
		CreateStickyPlayerGoo( character.ArtSize );
		CreateChargeAimIndicator();
		SetSpriteHead( HAS_SURFACE_GRAVITY ? new Vector2( 0f, 1f ) : null );
		if ( TintColor.HasValue )
			_sprite.Color = TintColor.Value;
		_currentAnimType = PlayerAnimType.Idle;
		_currentAnimName = "idle";
		_animInitialized = true;
		for ( int i = firstNewAbility; i < _abilityModules.Count; i++ )
			_abilityModules[i].OnSpawn( this );
		ResolveResidualPenetrationOrDie();
		if ( !IsDead ) RefreshContactsAfterTeleport();
	}

	void AddCharacterAbilityModules()
	{
		if ( _abilities.WrapsArenaEdges ) _abilityModules.Add( new WrapArenaEdgesAbility() );
		if ( _abilities.HasSwapPortal ) _abilityModules.Add( new SwapPortalAbility() );
		if ( _abilities.HasGunner ) _abilityModules.Add( new GunnerAbility() );
		if ( _abilities.HasSwarm ) _abilityModules.Add( new SwarmAbility() );
		if ( _abilities.HasRewind ) _abilityModules.Add( new RewindAbility() );
		if ( _abilities.HasGrappler ) _abilityModules.Add( new GrapplerAbility() );
		if ( _abilities.HasBlinker ) _abilityModules.Add( new BlinkerAbility() );
		if ( _abilities.HasSolar ) _abilityModules.Add( new SolarAbility() );
		if ( _abilities.HasHarden ) _abilityModules.Add( new HardenAbility() );
		if ( _abilities.HasTwinDash ) _abilityModules.Add( new TwinDashAbility() );
		if ( ReferenceEquals( _activeCharacter, Characters.Flipper ) )
			_abilityModules.Add( new FlipperDashEyeTintAbility() );
		if ( ReferenceEquals( _activeCharacter, Characters.Climber ) )
			_abilityModules.Add( new ClimberDashEyeTintAbility() );
		if ( _abilities.HasMimic && !_abilityModules.Any( ability => ability is MimicAbility ) )
			_abilityModules.Add( new MimicAbility() );
	}

	internal void RestoreReplayPresentation()
	{
		if ( IsSquashed ) UpdateSquashAnimation();
		foreach ( var ability in _abilityModules )
			ability.OnReplayPresentationRebuilt( this );
	}

	/// <summary>A mimic transform swapped <paramref name="old"/> for <paramref name="replacement"/> this
	/// tick (same pos/heading/StageIndex) — re-point every held reference so no sim decision ever waits
	/// on the destroyed instance's engine IsValid(), which flips at frame-flush cadence and NEVER inside
	/// a one-frame replay rebuild (the timeline-scrub desync). Attachments carry over seamlessly: the
	/// disguise IS the same block continuing.</summary>
	internal void OnBlockReplaced( Block old, Block replacement )
	{
		if ( ReferenceEquals( _attachEntity, old ) )
		{
			_attachEntity = replacement;
			_attachParentLastPosition = replacement.Position;
		}
		if ( ReferenceEquals( _slamBlock, old ) ) _slamBlock = replacement;
		if ( ReferenceEquals( _stickyEdgeReleaseBlock, old ) ) _stickyEdgeReleaseBlock = replacement;
		if ( ReferenceEquals( _mantleSurface, old ) ) _mantleSurface = replacement;
		if ( ReferenceEquals( _wallJumpWallEntity, old ) ) _wallJumpWallEntity = replacement;
		if ( ReferenceEquals( _wallKickWallEntity, old ) ) _wallKickWallEntity = replacement;
		if ( ReferenceEquals( _collidingEntityLeft, old ) ) _collidingEntityLeft = replacement;
		if ( ReferenceEquals( _collidingEntityRight, old ) ) _collidingEntityRight = replacement;
		if ( ReferenceEquals( _collidingEntityDown, old ) ) _collidingEntityDown = replacement;
		if ( ReferenceEquals( _collidingEntityUp, old ) ) _collidingEntityUp = replacement;
		if ( ReferenceEquals( _crushingEntityLeft, old ) ) _crushingEntityLeft = replacement;
		if ( ReferenceEquals( _crushingEntityRight, old ) ) _crushingEntityRight = replacement;
		if ( ReferenceEquals( _crushingEntityDown, old ) ) _crushingEntityDown = replacement;
		if ( ReferenceEquals( _crushingEntityUp, old ) ) _crushingEntityUp = replacement;
		foreach ( var ability in _abilityModules )
			ability.OnBlockReplaced( this, old, replacement );
	}

	void ResetCharacterLocalState()
	{
		ClearStuck( playUnstuck: false );
		Detach();
		Size = COLLISION_SIZE;

		_groundedLeniencyCounter = 0;
		_airJumpsUsed = 0;
		_jumpedThisTick = false;
		_jumpCutArmed = false;
		_wallClingCounter = 0;
		_wallClingHold = false;
		_clungLeftWall = false;
		_clungRightWall = false;
		_wallHugCarryY = 0f;
		_wallHugStepVelocityY = 0f;
		_backFlipping = false;
		_backFlipBackDir = 0f;
		_backFlipTimer = 0f;
		_jumpBufferCounter = 0;
		_hoverRemaining = 0;
		_groundPounding = false;
		_autoBounceCompressionFrames = 0;
		_pressLaunchDir = Direction.None;
		_pressLaunchBlockVel = 0f;

		_chargingJump = false;
		_chargeJumpTime = 0f;
		_chargeJumpAimTime = 0f;
		_chargeJumpSurfaceDirection = Direction.None;
		_suppressedChargeSurfaceDirection = Direction.None;
		_chargeWallJumpGravityTimer = 0f;
		_chargeGroundedFrames = 0;

		_wallJumpDirection = Direction.None;
		_wallDirection = Direction.None;
		_wallJumpWallEntity = null;
		_wallJumpTimer = 0f;
		_wallJumpLeniencyCounter = 0;
		_suppressWallRearm = false;
		_wallKickWall = Direction.None;
		_wallKickWallEntity = null;
		_wallKickTimer = 0f;
		_wallKickSpeed = 0f;

		_canDash = true;
		_dashedThisTick = false;
		_dashGesture.Reset();
		_dashGravityTimer = 0f;
		_dashCoastCeilingHit = false;
		_prevDashContact = false;
		_dashRechargeProgress = 0f;

		_selfGravityReversed = false;
		_gravityReversed = _fieldReversed;
		_surfaceGravityDirection = Direction.Down;
		_surfacePreviousGravityDirection = Direction.None;
		_surfaceContactThisTick = Direction.None;
		_surfaceJumpWindowFrame = Direction.Down;

		_slamBlock = null;
		_slamFace = Direction.None;
		_slamTimer = 0f;
		_stickyEdgeReleaseBlock = null;
		_stickyEdgeReleaseFace = Direction.None;
		_stickyEdgeReleaseCooldown = 0f;
		_directionalInputConsumed = false;
		_blinkPrepareFactor = 1f;
		_blinkPreparing = false;
		_sustainedShakeOffset = Vector2.Zero;
		_transientShakeOffset = Vector2.Zero;
		_transientShakeAmplitude = Vector2.Zero;
		_transientShakeMagnitude = 0f;
		_transientShakeStyle = ShakeStyle.Axis;
		_transientShakeFrames = 0;
		_gunnerPose = null;
		_gunnerPoseFace = Direction.None;
		CancelMantleHang( applyCooldown: false );
		_mantleRegrabFrames = 0;
	}

	// ----------------------------------------------------------------------------------------
	Direction HandleSurfaceGravityInput( float dt )
	{
		Vector2 tangent = SurfaceRightVector();
		float tangentSpeed = VelX * tangent.x + VelY * tangent.y;
		bool localLeft = RawDirectionHeld( OppositeDirection( SurfaceRightDirection() ) );
		bool localRight = RawDirectionHeld( SurfaceRightDirection() );
		Direction movement = Direction.None;
		float acceleration = HORIZONTAL_ACCELERATION * (OnFloor ? 1f : AIR_ACCEL_FACTOR) * StasisControl * dt;

		if ( localLeft )
		{
			tangentSpeed = Math.Max( tangentSpeed - acceleration, -CurrentMaxXSpeed );
			movement = Direction.Left;
		}
		else if ( localRight )
		{
			tangentSpeed = Math.Min( tangentSpeed + acceleration, CurrentMaxXSpeed );
			movement = Direction.Right;
		}
		else
		{
			float deceleration = HORIZONTAL_DECELERATION * (OnFloor ? GROUND_FRICTION_FACTOR : AIR_FRICTION_FACTOR) * dt;
			tangentSpeed = tangentSpeed > 0f ? Math.Max( 0f, tangentSpeed - deceleration ) : Math.Min( 0f, tangentSpeed + deceleration );
		}

		Vector2 gravity = DirectionVector( EffectiveGravityDirection );
		float normalSpeed = VelX * gravity.x + VelY * gravity.y;
		VelX = tangent.x * tangentSpeed + gravity.x * normalSpeed;
		VelY = tangent.y * tangentSpeed + gravity.y * normalSpeed;
		return movement;
	}

	Direction SurfaceRightDirection() => EffectiveGravityDirection switch
	{
		Direction.Down => Direction.Right,
		Direction.Up => Direction.Left,
		Direction.Left => Direction.Down,
		Direction.Right => Direction.Up,
		_ => Direction.Right,
	};

	Vector2 SurfaceRightVector() => DirectionVector( SurfaceRightDirection() );

	bool RawDirectionHeld( Direction direction ) => direction switch
	{
		Direction.Left => !_directionalInputConsumed && _input.Left,
		Direction.Right => !_directionalInputConsumed && _input.Right,
		Direction.Up => !_directionalInputConsumed && _input.Up,
		Direction.Down => !_directionalInputConsumed && _input.Down,
		_ => false,
	};

	bool TowardFloorPressed => RawDirectionHeld( EffectiveGravityDirection );

	Entity2D CollidingEntity( Direction direction ) => direction switch
	{
		Direction.Left => _collidingEntityLeft,
		Direction.Right => _collidingEntityRight,
		Direction.Up => _collidingEntityUp,
		Direction.Down => _collidingEntityDown,
		_ => null,
	};

	bool RawDirectionJustPressed( Direction direction ) => direction switch
	{
		Direction.Left => !_directionalInputConsumed && _input.LeftJust,
		Direction.Right => !_directionalInputConsumed && _input.RightJust,
		Direction.Up => !_directionalInputConsumed && _input.UpJust,
		Direction.Down => !_directionalInputConsumed && _input.DownJust,
		_ => false,
	};

	bool AdoptSurfaceGravity( Direction contact, bool force = false )
	{
		if ( contact == Direction.None ) return false;
		if ( !force && contact == _surfacePreviousGravityDirection && HasSurfaceGravitySupport( EffectiveGravityDirection ) ) return false;
		// A touch adopts the touched surface as the base floor — INCLUDING inside a Reverse field
		// (by design): the field only affects an airborne Shifter (see
		// ApplyReverseGravity's grounded-immunity gate), so once this adoption lands we're
		// base-supported, the field stops applying, and gravity presses into the touched surface.
		// That's also what keeps the old in-field floor↔ceiling ping-pong dead: the first touch
		// GROUNDS you, and grounded means immune.
		Direction newBaseDirection = contact;
		if ( newBaseDirection == _surfaceGravityDirection )
		{
			// Re-touching the current base adopts nothing, but it still ends this tick's airborne
			// field inversion: driven back onto our own floor mid-field, returning without the clear
			// kept inverted gravity for the rest of the tick — a small lift off the surface and a
			// missed same-tick attach (TryAttachSurfaceGravityFloor gates on contact == effective).
			ClearStaleFieldInversion();
			return false;
		}

		Direction oldBase = _surfaceGravityDirection;
		Direction oldSuppressed = _surfacePreviousGravityDirection;
		Vector2 oldSize = Size;
		float oldX = X, oldY = Y;
		_surfacePreviousGravityDirection = EffectiveGravityDirection;
		_surfaceGravityDirection = newBaseDirection;
		RotateSurfaceGravityHitbox( contact );
		// The rotation can wedge the new hitbox into geometry the old one fit (a 10-wide sideways body
		// in a 9px slot between two solids). That's a refused adoption, not a death: restore everything
		// and let the touch simply not take — contact break + retouch re-arms it. Successful resolves
		// (including small validated shoves out of a rotation overlap) commit exactly as before.
		if ( !TryResolveResidualPenetration( out _ ) )
		{
			Size = oldSize;
			X = oldX;
			Y = oldY;
			_surfaceGravityDirection = oldBase;
			_surfacePreviousGravityDirection = oldSuppressed;
			TraceShifter( $"ADOPT {oldBase}->{newBaseDirection} REFUSED (rotated hitbox doesn't fit) pos=({X:0.00},{Y:0.00})" );
			return false;
		}
		DetachPlatform();
		RefreshSurfaceGravityContacts();
		ClearStaleFieldInversion();
		// The gravity frame just changed: a coyote window or buffered press aimed at the OLD floor
		// must not fire a jump off the new one (see HandleSurfaceGravityJump).
		_groundedLeniencyCounter = 0;
		_jumpBufferCounter = 0;
		TraceShifter( $"ADOPT base {oldBase}->{newBaseDirection}{(force ? " (forced)" : "")} suppressed={_surfacePreviousGravityDirection}"
			+ $" pos=({X:0.00},{Y:0.00}) vel=({VelX:0.0},{VelY:0.0}) extra=({ExtraVelX:0.0},{ExtraVelY:0.0})" );
		return true;
	}

	/// <summary>The field only applies while AIRBORNE (ApplyReverseGravity gates on base support,
	/// read before this tick began). Once we're supported on the base mid-tick — a fresh adoption,
	/// or being driven back onto the existing base floor — this tick's inversion is stale: drop it
	/// so gravity presses into the floor for the rest of the tick instead of lifting away (and so
	/// TryAttachSurfaceGravityFloor's contact == effective gate can attach the same tick). The
	/// jump windows re-key themselves off the resulting frame change (see HandleSurfaceGravityJump).</summary>
	void ClearStaleFieldInversion()
	{
		if ( !_fieldReversed || !HasSurfaceGravitySupport( _surfaceGravityDirection ) ) return;
		_fieldReversed = false;
		_gravityReversed = _selfGravityReversed;
	}

	void TryAttachSurfaceGravityFloor( Direction contact, Entity2D entity, string traceSource = "fresh" )
	{
		if ( contact == Direction.None || contact != EffectiveGravityDirection ) return;
		if ( entity is not Block block || block.IsDead || block.PhasingIn ) return;
		Vector2 probe = DirectionVector( contact );
		if ( !IsPlayerColliding( X + probe.x, Y + probe.y, contact, out Entity2D liveFloor ) || liveFloor != entity ) return;
		// Same stand-able band the riding release and the reglue refusal enforce: gluing a corner-sliver
		// contact whose centre is already past the lean margin would attach past the exact band that
		// releases it next tick — an attach/release churn pinning the player at the lip.
		if ( PastSurfaceLedgeMargin( entity, contact ) ) return;
		if ( WindBreaksGrip( contact ) ) return;
		if ( _attach == Attachment.Riding && _attachEntity == entity && _attachFace == contact ) return;

		Attach( Attachment.Riding, entity, contact );
		TraceShifter( $"ATTACH[{traceSource}] Riding {contact} to {block.GetType().Name}@({block.X:0.0},{block.Y:0.0})"
			+ $" blockVel=({block.Velocity.x:0.0},{block.Velocity.y:0.0}) stopped={block.IsStopped} offset={_attachTangentOffset:0.00}" );
	}

	// RE-GLUE: a Riding grip can be dropped while we're still standing on the surface — the parent
	// instance replaced out from under us (mimic swap), or a past-edge release onto the flush
	// neighbour — and with the contact flag held true continuously there is no fresh-touch edge to
	// re-attach off, so the ride (block carry, inertia fling) would stay dead until we physically
	// separated. If we're Free but the current floor still supports us, re-attach to whatever block
	// is actually there. Gated on nothing commanding motion AWAY from the floor this tick: a fresh
	// sub-pixel launch (a weak inertia fling, a shockwave push-off) still reads as "supported" on the
	// 1px probe, and re-gluing would pin us flush again and swallow the launch. Riding is left alone
	// (HandleSurfaceGravityPlatform maintains it), and Stuck never reaches this pass.
	void ReattachSurfaceGravityFloor()
	{
		if ( _attach != Attachment.Free ) return;
		if ( !HasSurfaceGravitySupport( EffectiveGravityDirection ) ) return;

		Vector2 commanded = TotalVelocity;
		if ( Vector2.Dot( commanded, DirectionVector( EffectiveGravityDirection ) ) < 0f ) return;

		// The lip of a ledge still reads as supported (a couple px of overlap remain past the release
		// margin), but re-gluing there would undo the past-edge release the same tick it fired and hold
		// us hovering at the lip — the exact hang the margin exists to prevent. Same test as the release.
		Entity2D floor = CollidingEntity( EffectiveGravityDirection );
		if ( floor is null || PastSurfaceLedgeMargin( floor, EffectiveGravityDirection ) ) return;

		TryAttachSurfaceGravityFloor( EffectiveGravityDirection, floor, "reglue" );
	}

	// Past the stand-able band on the given face of the parent: our centre is more than the ledge lean
	// margin beyond the parent's extent along the face's tangent axis. Used by the re-glue refusal
	// above, where it runs POST-movement (both our position and the parent's are current, so raw
	// positions are lag-free). The riding release in HandleSurfaceGravityPlatform expresses the SAME
	// band via the tangent offset instead, because it runs pre-glue where raw position lags the parent
	// by a tick of its travel (see the note there) — keep the two bands identical if either changes.
	bool PastSurfaceLedgeMargin( Entity2D parent, Direction face )
	{
		bool verticalFace = face == Direction.Left || face == Direction.Right;
		float margin = (LEDGE_FALL_OFF_FRACTION - 0.5f) * (verticalFace ? Height : Width);
		return verticalFace
			? Y < parent.Bottom - margin || Y > parent.Top + margin
			: X < parent.Left - margin || X > parent.Right + margin;
	}

	void RefreshSurfaceGravityContacts()
	{
		const float threshold = 1.0f;
		CollidingLeft = IsPlayerColliding( X - threshold, Y, Direction.Left, out _collidingEntityLeft );
		CollidingRight = IsPlayerColliding( X + threshold, Y, Direction.Right, out _collidingEntityRight );
		CollidingDown = IsPlayerColliding( X, Y - threshold, Direction.Down, out _collidingEntityDown );
		CollidingUp = IsPlayerColliding( X, Y + threshold, Direction.Up, out _collidingEntityUp );
	}

	bool HasSurfaceGravitySupport( Direction direction )
	{
		if ( !IsCollidingDirection( direction ) ) return false;
		Entity2D surface = CollidingEntity( direction );
		if ( surface is null ) return true;

		const float overlapEpsilon = 0.01f;
		return direction == Direction.Left || direction == Direction.Right
			? MathF.Min( Top, surface.Top ) - MathF.Max( Bottom, surface.Bottom ) > overlapEpsilon
			: MathF.Min( Right, surface.Right ) - MathF.Max( Left, surface.Left ) > overlapEpsilon;
	}

	void ReleaseSeparatedSurfaceGravitySuppression()
	{
		if ( _surfacePreviousGravityDirection != Direction.None
			&& !HasSurfaceGravitySupport( _surfacePreviousGravityDirection ) )
			_surfacePreviousGravityDirection = Direction.None;
	}

	Direction FindSupportedSurfaceGravityContact()
	{
		if ( HasSurfaceGravitySupport( _surfacePreviousGravityDirection ) )
			return _surfacePreviousGravityDirection;
		if ( HasSurfaceGravitySupport( Direction.Left ) ) return Direction.Left;
		if ( HasSurfaceGravitySupport( Direction.Right ) ) return Direction.Right;
		if ( HasSurfaceGravitySupport( Direction.Up ) ) return Direction.Up;
		if ( HasSurfaceGravitySupport( Direction.Down ) ) return Direction.Down;
		return Direction.None;
	}

	void RecordSurfaceGravityContact( Direction contact, Entity2D entity )
	{
		if ( contact == Direction.None ) return;
		// A Reverse field can instantly turn the surface we were standing on into our ceiling. Residual
		// velocity may collide with that same face later in the tick, but it is not a NEW touch and must
		// not be adopted again or it cancels the field inversion (Right -> Left -> Right).
		if ( SurfaceWasColliding( contact ) ) return;
		if ( _surfaceContactThisTick != Direction.None
			&& contact == EffectiveGravityDirection && _surfaceContactThisTick != contact ) return;

		_surfaceContactThisTick = contact;
		TraceShifter( $"CONTACT {contact} ent={entity?.GetType().Name ?? "arena"}" );
	}

	bool SurfaceWasColliding( Direction direction ) => direction switch
	{
		Direction.Left => _surfaceWasCollidingLeft,
		Direction.Right => _surfaceWasCollidingRight,
		Direction.Up => _surfaceWasCollidingUp,
		Direction.Down => _surfaceWasCollidingDown,
		_ => false,
	};

	void RotateSurfaceGravityHitbox( Direction contact )
	{
		bool horizontalGravity = contact == Direction.Left || contact == Direction.Right;
		Vector2 targetSize = horizontalGravity
			? new Vector2( COLLISION_SIZE.y, COLLISION_SIZE.x )
			: COLLISION_SIZE;
		if ( Size == targetSize ) return;

		float contactFace = contact switch
		{
			Direction.Left => Left,
			Direction.Right => Right,
			Direction.Up => Top,
			Direction.Down => Bottom,
			_ => 0f,
		};
		Size = targetSize;
		switch ( contact )
		{
			case Direction.Left: Left = contactFace; break;
			case Direction.Right: Right = contactFace; break;
			case Direction.Up: Top = contactFace; break;
			case Direction.Down: Bottom = contactFace; break;
		}
	}

	void ApplySurfaceGravity( float dt )
	{
		Vector2 gravity = DirectionVector( EffectiveGravityDirection );
		float towardSpeed = VelX * gravity.x + VelY * gravity.y;
		if ( _stasisLevel >= 1 && _stasisStrength > 0.001f )
		{
			float strength = _stasisStrength;
			float baseFactor = _stasisLevel >= 2 ? STASIS_GRAVITY_FACTOR_P2 : STASIS_GRAVITY_FACTOR_P1;
			towardSpeed += GRAVITY * dt * (1f + (baseFactor - 1f) * strength);
			float damp = MathF.Exp( -(_stasisLevel >= 2 ? STASIS_DAMP_P2 : STASIS_DAMP_P1) * strength * dt );
			Vector2 stasisTangent = SurfaceRightVector();
			float stasisTangentSpeed = (VelX * stasisTangent.x + VelY * stasisTangent.y) * damp;
			towardSpeed *= damp;
			VelX = stasisTangent.x * stasisTangentSpeed + gravity.x * towardSpeed;
			VelY = stasisTangent.y * stasisTangentSpeed + gravity.y * towardSpeed;
			_shockwaveVel *= damp;
			ExtraVelX *= damp;
			ExtraVelY *= damp;
			_grappleReleaseVelocity *= damp;
			return;
		}

		bool towardHeld = RawDirectionHeld( EffectiveGravityDirection );
		float gravityFactor = RawDirectionHeld( OppositeDirection( EffectiveGravityDirection ) ) && !(ACTIVE_DOWN_OVERRIDES_UP && towardHeld)
			? ACTIVE_UP_GRAVITY_FACTOR
			: towardHeld ? ACTIVE_DOWN_GRAVITY_FACTOR : 1f;
		// SUSTAINED-FIELD ANTI-GRAVITY, mirrored along the Shifter's own gravity axis (see the channel
		// declarations): a field pulling away from THIS surface cancels gravity (scaled by the lift speed)
		// and bleeds off banked toward-surface speed instead of racing it. Rising away under our own
		// towardSpeed (< 0) is left alone, matching ApplyGravity.
		float fieldLift = -Vector2.Dot( FieldVelocity, gravity );
		if ( fieldLift > 0f && towardSpeed >= 0f )
		{
			float cancel = Math.Min( 1f, fieldLift / FIELD_LIFT_FULL_CANCEL_SPEED );
			gravityFactor *= 1f - cancel;
			towardSpeed = Math.Max( 0f, towardSpeed - GRAVITY * FIELD_FALL_ARREST_FACTOR * cancel * dt );
		}
		// Cap fall and rise independently, the surface-relative mirror of ApplyGravity's clamp — without
		// the rise side, an inertia fling off a fast riser (HandleSurfaceGravityPlatform hands over the
		// block's unclamped travel speed) launches away-from-floor speed far past MaxRiseSpeed.
		towardSpeed = Math.Clamp( towardSpeed + GRAVITY * gravityFactor * dt, -MAX_RISE_SPEED, MAX_FALL_SPEED );
		Vector2 tangent = SurfaceRightVector();
		float tangentSpeed = VelX * tangent.x + VelY * tangent.y;
		VelX = tangent.x * tangentSpeed + gravity.x * towardSpeed;
		VelY = tangent.y * tangentSpeed + gravity.y * towardSpeed;
	}

	void HandleSurfaceGravityJump()
	{
		Direction jumpDirection = OppositeDirection( EffectiveGravityDirection );
		Vector2 away = DirectionVector( jumpDirection );
		bool onFloorNow = IsTouchingFloorNow();

		// The coyote/buffer windows are FRAME-keyed. Adoption zeroes them (below), but a Reverse
		// field can invert the effective frame mid-air with no adoption at all — and a coyote window
		// earned walking off the OLD floor must not fire a jump away from the opposite,
		// never-supported floor. Zero both on any effective-frame change.
		if ( EffectiveGravityDirection != _surfaceJumpWindowFrame )
		{
			_surfaceJumpWindowFrame = EffectiveGravityDirection;
			_groundedLeniencyCounter = 0;
			_jumpBufferCounter = 0;
		}

		// COYOTE + JUMP BUFFER, surface-frame. The normal handlers (HandleVerticalJumping /
		// HandleJumpBuffer) are skipped entirely on the surface path, so the same counters are free to
		// reuse here — and they're already in the movement snapshot. Semantics mirror the normal path:
		// coyote refreshes only while settled on the floor (not already moving away, so a launch frame
		// can't hand out a second jump); the buffer latches an airborne away-from-floor press and fires
		// it while the coyote window is open (which includes the landing tick). Both windows are keyed
		// to the CURRENT gravity frame — adopting a new surface zeroes them (see AdoptSurfaceGravity),
		// so a press meant for the old floor can't fire off a freshly adopted one.
		float awaySpeed = VelX * away.x + VelY * away.y;
		if ( onFloorNow && awaySpeed <= 0f )
			_groundedLeniencyCounter = NUM_GROUNDED_LENIENCY_FRAMES;
		else if ( _groundedLeniencyCounter > 0 )
			_groundedLeniencyCounter--;
		if ( JUMP_BUFFER_FRAMES > 0 )
		{
			if ( _jumpBufferCounter > 0 ) _jumpBufferCounter--;
			if ( RawDirectionJustPressed( jumpDirection ) && !onFloorNow )
				_jumpBufferCounter = JUMP_BUFFER_FRAMES;
		}

		if ( _groundedLeniencyCounter <= 0 ) return;
		if ( !RawDirectionJustPressed( jumpDirection ) && _jumpBufferCounter <= 0 ) return;
		// Sim v42+: a probe contact with a spiked face adopts it as floor without touching it; no jump
		// away from teeth we haven't met (see LaunchFloorIsSpiked — the surface frame handles any side).
		if ( LaunchFloorIsSpiked ) return;

		float jumpPower = JUMP_POWER * RollFloorJumpStrengthFactor();
		Vector2 tangent = SurfaceRightVector();
		float tangentSpeed = VelX * tangent.x + VelY * tangent.y;
		VelX = tangent.x * tangentSpeed + away.x * jumpPower;
		VelY = tangent.y * tangentSpeed + away.y * jumpPower;
		_groundedLeniencyCounter = 0;
		_jumpBufferCounter = 0; // consume any buffered press (same as the grounded jump)
		DetachPlatform();
		TraceShifter( $"JUMP {jumpDirection} vel=({VelX:0.0},{VelY:0.0})" );
		Audio.PlaySfx( Character.Audio.Jump, Position, 0.8f );
	}

	// ----------------------------------------------------------------------------------------
	Direction HandleHorizontalInput( float dt )
	{
		Direction newXDirection = Direction.None;
		if ( _mantleHanging ) return newXDirection;

		// A launch is applied after movement, so OnFloor remains stale until the next collision refresh.
		// Spring has no air control: once velocity already points away from that stale floor, treat the
		// trajectory as committed immediately instead of granting one or two grounded steering frames.
		if ( HAS_CHARGE_WALL_JUMP && OnFloor && VelY * GravitySign > 0f )
			return newXDirection;

		// No horizontal steering while ground-pounding — the slam is committed to straight down. (Air
		// jumps / other abilities still work; an air jump ends the pound by sending us upward.)
		if ( _groundPounding )
			return newXDirection;

		// Charge jump: planted while winding up; directional input only aims the launch. Suppressing
		// steering here lets ground braking settle the player and keeps wall charging fixed to its surface.
		// Only the charge-jump character can be _chargingJump, so this is a
		// no-op for every other character.
		if ( _chargingJump )
			return newXDirection;

		// Stasis trail: weaken (but never zero) the player's own acceleration so they can only push
		// slowly against the field — less at phase 2 than phase 1. 1 = full control (not in a trail).
		float stasisControl = StasisControl;

		if ( LeftPressed )
		{
			float wallJumpFactor = (_wallJumpDirection == Direction.Right) ? GetWallJumpFactor() : 1;
			// Air control multiplier (1 when grounded OR ceiling-clinging; see HorizontalMovementIsGrounded).
			float airFactor = HorizontalMovementIsGrounded ? 1.0f : AIR_ACCEL_FACTOR * SolarAirControlFactor;
			// Skid: extra brake while still moving right but now pressing left (0 by default = no skid).
			float turn = (VelX > 0.0f) ? TURNAROUND_DECELERATION : 0.0f;
			float accel = (HORIZONTAL_ACCELERATION * wallJumpFactor * airFactor + turn) * stasisControl * dt;
			// Accelerate left toward -cap, but never yank UP speed that's already faster than the cap
			// (e.g. the air/ground cap just dropped): the overspeed floor is the more-negative of VelX
			// and -cap, so input can't push past the cap yet an existing overspeed is left for the
			// smooth bleed in ApplyHorizontalDeceleration rather than snapped in one tick.
			VelX = Math.Max( VelX - accel, Math.Min( VelX, -CurrentMaxXSpeed ) );
			newXDirection = Direction.Left;
		}
		else if ( RightPressed )
		{
			float wallJumpFactor = (_wallJumpDirection == Direction.Left) ? GetWallJumpFactor() : 1;
			float airFactor = HorizontalMovementIsGrounded ? 1.0f : AIR_ACCEL_FACTOR * SolarAirControlFactor;
			float turn = (VelX < 0.0f) ? TURNAROUND_DECELERATION : 0.0f;
			float accel = (HORIZONTAL_ACCELERATION * wallJumpFactor * airFactor + turn) * stasisControl * dt;
			// Mirror of the left case: accelerate toward +cap, but leave existing over-cap speed for the
			// bleed (ceiling of +cap or the current, already-higher VelX).
			VelX = Math.Min( VelX + accel, Math.Max( VelX, CurrentMaxXSpeed ) );
			newXDirection = Direction.Right;
		}

		// Dive gear (hold Down in the air): on top of the raised cap, the swoop itself builds speed
		// along the CURRENT heading toward that cap — no left/right hold needed. Skipped at a
		// standstill (no heading to swoop along) and while steering AGAINST the motion, so a dive
		// never overpowers a deliberate turnaround. Same clamp pattern as the steering above: never
		// pushes past the dive cap, never yanks down speed already beyond it (bleed handles that).
		if ( DiveGearEngaged && DIVE_HORIZONTAL_ACCELERATION > 0f && VelX != 0f )
		{
			bool steeringAgainst = VelX > 0f ? LeftPressed : RightPressed;
			if ( !steeringAgainst )
			{
				float boost = DIVE_HORIZONTAL_ACCELERATION * stasisControl * dt;
				if ( VelX > 0f )
					VelX = Math.Min( VelX + boost, Math.Max( VelX, CurrentMaxXSpeed ) );
				else
					VelX = Math.Max( VelX - boost, Math.Min( VelX, -CurrentMaxXSpeed ) );
			}
		}

		return newXDirection;
	}

	// ----------------------------------------------------------------------------------------
	void ApplyHorizontalDeceleration( float dt )
	{
		if ( _mantleHanging ) return;

		// An inactive twin, or an active twin released to begin switching, keeps its horizontal speed
		// through the current jump arc. Ground friction still settles it after landing.
		if ( _preserveUncontrolledAirMomentum && !HorizontalMovementIsGrounded )
			return;

		// Match HandleHorizontalInput's committed-launch guard: stale OnFloor must not apply Spring's
		// ground braking after it has already launched away from the surface.
		if ( HAS_CHARGE_WALL_JUMP && OnFloor && VelY * GravitySign > 0f )
			return;

		// GetWallJumpFactor() scales DOWN friction for up to WALL_JUMP_TIME (1s) after a wall jump
		// so the wall-jump push carries you through the air instead of being instantly braked. The
		// original applied this reduction unconditionally — but that also softened GROUND friction,
		// so wall-jumping off a block and then landing left you sliding for up to a second ("low
		// friction" slide reported after jumping around blocks). The momentum window should only
		// affect AIR control; once grounded, brake at full strength. CollidingDown here is last
		// frame's value (flags are recomputed later in Tick), which is exactly the just-landed state.
		// GROUND_FRICTION_FACTOR (1 = original) scales grounded braking for slippery / grippy feet.
		// Ceiling-clinging also brakes as grounded (see HorizontalMovementIsGrounded) so it steers tight.
		// AIR_FRICTION_FACTOR (1 = original) is the airborne equivalent: 0 removes horizontal air braking
		// entirely, committing the character to the velocity it left the surface with (used by the Flipper).
		float frictionFactor = HorizontalMovementIsGrounded ? GROUND_FRICTION_FACTOR : GetWallJumpFactor() * AIR_FRICTION_FACTOR;
		if ( !HorizontalMovementIsGrounded && HAS_CHARGE_WALL_JUMP && frictionFactor <= 0f )
			return;

		// Overspeed bleed: when |VelX| exceeds the current cap — which happens when the air/ground cap
		// flips below current speed (land from a fast flight with MaxAirXSpeed > MaxXSpeed, or the first
		// airborne tick when the air cap is lower) — ease it DOWN to the cap at OVERSPEED_DECAY instead
		// of snapping in one tick. Runs regardless of input so holding the run direction on touchdown
		// still bleeds smoothly rather than sticking at the old speed. Once at/under the cap the normal
		// friction path below resumes. No-op for the common case (air cap == ground cap), where VelX is
		// always held at/under the cap by HandleHorizontalInput and this branch never triggers.
		float cap = CurrentMaxXSpeed;
		if ( Math.Abs( VelX ) > cap )
		{
			float bleed = OVERSPEED_DECAY * dt;
			VelX = (VelX > 0.0f) ? Math.Max( VelX - bleed, cap ) : Math.Min( VelX + bleed, -cap );
			return;
		}

		if ( VelX > 0.0f && !RightPressed )
			VelX = Math.Clamp( VelX - HORIZONTAL_DECELERATION * dt * frictionFactor, 0.0f, cap );
		else if ( VelX < 0.0f && !LeftPressed )
			VelX = Math.Clamp( VelX + HORIZONTAL_DECELERATION * dt * frictionFactor, -cap, 0.0f );
	}

	// ----------------------------------------------------------------------------------------
	void HandleExtraVelocity( float dt )
	{
		// Back-flip owns the horizontal ExtraVel channel while active: skip its decay so the forward pop
		// and the sustained backward push (HandleBackFlip) integrate cleanly. Once the back-flip ends the
		// channel decays normally again, so the drift coasts to a stop.
		if ( !_backFlipping )
		{
			if ( ExtraVelX > 0.0f )
				ExtraVelX = Math.Clamp( ExtraVelX - EXTRA_VELOCITY_HORIZONTAL_DECELERATION * dt, 0.0f, MAX_EXTRA_X_SPEED );
			else if ( ExtraVelX < 0.0f )
				ExtraVelX = Math.Clamp( ExtraVelX + EXTRA_VELOCITY_HORIZONTAL_DECELERATION * dt, -MAX_EXTRA_X_SPEED, 0.0f );
		}

		// Decay the vertical ExtraVel channel SYMMETRICALLY toward zero (like ExtraVelX). Previously only
		// the downward (negative) side decayed, so a POSITIVE ExtraVelY (an up-dash's vertical
		// component) parked for the whole airtime: it never faded AND, because the rise cap in ApplyGravity
		// clamps VelY only, it stacked rise speed past MAX_RISE_SPEED. Decaying both directions makes an
		// up-dash coast to a stop just as the horizontal dash does, so the impulse no longer lingers.
		if ( ExtraVelY < 0.0f )
			ExtraVelY = Math.Clamp( ExtraVelY + EXTRA_VELOCITY_VERTICAL_DECELERATION * dt, -MAX_EXTRA_Y_SPEED, 0.0f );
		else if ( ExtraVelY > 0.0f )
			ExtraVelY = Math.Clamp( ExtraVelY - EXTRA_VELOCITY_VERTICAL_DECELERATION * dt, 0.0f, MAX_EXTRA_Y_SPEED );

		// Wind channel: while a lane is feeding us this tick, the cap in AddWindImpulse governs the speed
		// (no decay needed). The moment we leave the lane (not fed) the gust dies QUICKLY regardless of how
		// strong it was — decay EXPONENTIALLY (a % per tick) so a huge gust fades in the same short time as
		// a small one, then snap to zero below a threshold. Blocks tick before the player, so _windFedThisTick
		// already reflects this frame's lane membership; clear it here for the next tick.
		if ( !_windFedThisTick && _windVel != Vector2.Zero )
		{
			if ( _windVel.Length <= WIND_STOP_THRESHOLD )
				_windVel = Vector2.Zero;
			else
				_windVel *= MathF.Exp( -WIND_DECAY_RATE * dt );
		}
		_windFedThisTick = false;

		// Magnet channel: NO gradual decay — a magnet beam is a hard field, not coasting inertia. If no
		// lane fed us this tick (we've left the beam) the accumulated pull is dropped to zero instantly,
		// this same frame, before it's integrated into position. Blocks tick before the player, so the
		// flag already reflects whether we're in a beam this frame; clear it here for the next one.
		if ( !_magnetFedThisTick )
			_magnetVel = Vector2.Zero;
		_magnetFedThisTick = false;

		// Shockwave channel: a one-shot radial knockback that decays LINEARLY toward zero (like the ExtraVel
		// channels) so the punch fades over ~0.75s, snapped to zero below a small threshold. Direction is
		// preserved (magnitude-only decay) so a mid-flight reflection keeps its bounced heading. Clearing
		// _shockwaveStartMag at rest resets the bounce-eligibility reference for the next hit.
		if ( _shockwaveVel != Vector2.Zero )
		{
			float mag = _shockwaveVel.Length;
			float newMag = mag - SHOCKWAVE_DECEL * dt;
			if ( newMag <= SHOCKWAVE_STOP_THRESHOLD )
			{
				_shockwaveVel = Vector2.Zero;
				_shockwaveStartMag = 0f;
			}
			else
			{
				_shockwaveVel = Utils.Normalized( _shockwaveVel ) * newMag;
			}
		}

		if ( _swarmRepelVel != Vector2.Zero )
		{
			float mag = _swarmRepelVel.Length;
			float newMag = mag - SWARM_REPEL_DECEL * dt;
			_swarmRepelVel = newMag <= SWARM_REPEL_STOP_THRESHOLD
				? Vector2.Zero
				: Utils.Normalized( _swarmRepelVel ) * newMag;
		}

		if ( _swarmLaunchCooldown > 0f )
			_swarmLaunchCooldown -= dt;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Feed the wind channel a gust impulse (BlockWind, once per tick per overlapping lane).
	/// The wind-driven speed is capped at <see cref="WIND_TERMINAL"/> so a sustained gust tops out at
	/// a moderate push rather than accelerating without bound.</summary>
	public void AddWindImpulse( Vector2 delta )
	{
		if ( IsHardened || IsTwinDashing || IsSquashed ) return;
		// WindCatchFactor scales how hard this character catches the gust (and its top wind speed): 1 =
		// original, >1 = a light "sail" that rides gusts faster/further, 0 = immune, <0 = pushed upwind.
		float factor = WIND_CATCH_FACTOR;
		if ( factor == 0f ) return; // immune to wind (also avoids a zero-length normalize below)

		_windVel += delta * factor;
		float cap = WIND_TERMINAL * Math.Abs( factor );
		if ( _windVel.Length > cap )
			_windVel = Utils.Normalized( _windVel ) * cap;
		_windFedThisTick = true; // gates the leave-the-lane exponential decay in HandleExtraVelocity
	}

	/// <summary>Feed the magnet channel a pull impulse (BlockMagnet, once per tick per overlapping lane).
	/// Fed every tick, the channel integrates up toward <see cref="MAGNET_TERMINAL"/>, ramping the drag
	/// in. The away-from-the-floor component is capped separately (and much lower) at
	/// <see cref="MAGNET_LIFT_TERMINAL"/>: the anti-gravity rule in ApplyGravity cancels gravity while
	/// the beam lifts, so that cap is the actual reel-up speed — not a head start in a race against the
	/// fall speed gravity used to bank during the suspension.</summary>
	public void AddMagnetImpulse( Vector2 delta )
	{
		if ( IsHardened || IsTwinDashing || IsSquashed ) return;
		_magnetFedThisTick = true; // gates the instant cut-off in HandleExtraVelocity
		_magnetVel += delta;
		if ( _magnetVel.Length > MAGNET_TERMINAL )
			_magnetVel = Utils.Normalized( _magnetVel ) * MAGNET_TERMINAL;
		// Cap the lift along the EFFECTIVE gravity axis, not world Y: the Shifter's floor can be a wall
		// or the ceiling, so its away-from-floor direction is horizontal/inverted there. For ordinary
		// vertical gravity this reduces exactly to capping _magnetVel.y * GravitySign.
		Vector2 gravityDir = DirectionVector( EffectiveGravityDirection );
		float lift = -Vector2.Dot( _magnetVel, gravityDir );
		if ( lift > MAGNET_LIFT_TERMINAL )
			_magnetVel += gravityDir * (lift - MAGNET_LIFT_TERMINAL);
	}

	/// <summary>A singing Siren feeds its pull for this tick: a drift VELOCITY (px/s) toward the block,
	/// already envelope-scaled by the caller. Purely per-tick — the channel was reset at frame start
	/// (see <see cref="ResetStasis"/>), so the pull never integrates or accumulates the way the magnet
	/// channel does; overlapping songs sum, capped at <see cref="SIREN_DRIFT_MAX"/>.</summary>
	public void ApplySirenPull( Vector2 vel )
	{
		if ( IsHardened || IsTwinDashing || IsSquashed ) return;
		// SirenCatchFactor scales how hard this character is drawn in by the song (and its combined
		// drift cap): 1 = original, >1 = pulled in harder/faster to a higher cap, 0 = deaf, <0 = resists.
		float factor = SIREN_CATCH_FACTOR;
		if ( factor == 0f ) return; // deaf to the song (also avoids a zero-length normalize below)

		_sirenVel += vel * factor;
		float cap = SIREN_DRIFT_MAX * Math.Abs( factor );
		if ( _sirenVel.Length > cap )
			_sirenVel = Utils.Normalized( _sirenVel ) * cap;
	}

	/// <summary>Radial knockback from a Shockwave block's expanding ring, pushing the player outward
	/// along <paramref name="dir"/> (origin → player) at <paramref name="strength"/> px/s, on the isolated
	/// shockwave channel so it can BOUNCE off solids
	/// (see <see cref="HandleShockwaveBounce"/>) without touching any other force. A one-shot impulse, so
	/// it doubles as a boost — an upward blast launches you (usable as a jump on top of your own VelY), a
	/// sideways one flings you along.
	///
	/// <para>By default (<paramref name="additive"/> false) it OVERWRITES any still-decaying prior
	/// shockwave — a fresh ring resets the push (the Shockwave block's semantics). When
	/// <paramref name="additive"/> is true the impulse is instead ADDED to the current channel velocity.
	/// Either way it records the resulting magnitude as the reference the bounce gate measures against.</para></summary>
	public void ApplyShockwaveKnockback( Vector2 dir, float strength, bool additive = false )
	{
		if ( IsDead || IsHardened || IsTwinDashing || IsSquashed || strength <= 0f ) return;
		dir = Utils.Normalized( dir );
		if ( dir == Vector2.Zero ) return;

		if ( additive )
		{
			// Combine with whatever's already on the channel; the bounce gate references the new total.
			_shockwaveVel += dir * strength;
			_shockwaveStartMag = _shockwaveVel.Length;
		}
		else
		{
			// Overwrite (Shockwave block): a fresh pulse resets the push. Kept byte-identical to the
			// original so existing shockwave replays don't desync (startMag = strength exactly, not the
			// re-derived vector length which could differ by a float epsilon).
			_shockwaveVel = dir * strength;
			_shockwaveStartMag = strength;
		}

		// Leave any ridden/hugged platform so the launch actually throws us off it.
		DetachPlatform();
	}

	/// <summary>Add a soft, non-blocking push from overlapping another body in the same Swarm.</summary>
	public void ApplySwarmRepel( Vector2 direction, float strength )
	{
		if ( IsDead || strength <= 0f ) return;
		direction = Utils.Normalized( direction );
		if ( direction == Vector2.Zero ) return;

		_swarmRepelVel += direction * strength;
		if ( _swarmRepelVel.Length > SWARM_REPEL_MAX_SPEED )
			_swarmRepelVel = Utils.Normalized( _swarmRepelVel ) * SWARM_REPEL_MAX_SPEED;
	}

	/// <summary>True when this body can take part in a swarm launch (see ApplySwarmLaunch).</summary>
	public bool SwarmLaunchReady => !IsDead && _swarmLaunchCooldown <= 0f;

	/// <summary>The strong one-shot half of the swarm bounce, fired along one axis (the pair's
	/// min-overlap axis, chosen by the stage). Vertical goes through base VelY — never weaker than a
	/// jump already in flight — so it arcs under gravity; horizontal goes through ExtraVelX so it
	/// decays like a platform fling instead of being eaten by the walk-speed clamp. The downward kick
	/// uses the softer SWARM_LAUNCH_DOWN_POWER. The body being launched from takes a directional jolt —
	/// recoil AWAY from us, i.e. opposite our launch — even when its own launch is unavailable, so
	/// one-sided chain bounces still show which body acted as the surface and which way it got shoved.</summary>
	public void ApplySwarmLaunch( Vector2 direction, Player launchedFrom )
	{
		if ( IsDead ) return;
		// Sticky glue absorbs the bounce outright: the stuck tick zeroes VelY/ExtraVelX before they can
		// integrate, so firing would spend the cooldown and play the repel cue on a body that visibly
		// never moves. Return BEFORE both — the launch stays armed and fires the moment the glue lets go.
		// Deliberately NOT the tear-the-glue rule the echo/bullet repels use: a friendly body brushing a
		// glued one shouldn't rip it off a face it grabbed on purpose.
		if ( IsStuck ) return;
		_swarmLaunchCooldown = SWARM_LAUNCH_COOLDOWN;
		// Let go of any ridden/hugged platform (same as the shockwave/bullet/echo repels): the Riding
		// glue re-pins us to the block top every tick, so an upward launch off a copy while perched on a
		// block would otherwise jitter in place instead of flying clear. Sticky/ceiling grips untouched.
		DetachPlatform();
		Audio.PlaySfx( SfxType.SwarmRepel, Position, volume: 0.55f );
		launchedFrom?.ShakeDirectional( -direction * SWARM_LAUNCH_SHAKE_STRENGTH, SWARM_LAUNCH_SHAKE_FRAMES );
		if ( direction.y != 0f )
		{
			// GRAVITY-RELATIVE power split: the fling AWAY from the effective floor gets the full power,
			// the kick toward it the softer SWARM_LAUNCH_DOWN_POWER (the body nearer the effective floor
			// shouldn't be spiked into it as hard as the other flies). Keyed to GravitySign — the world-Y
			// sign of "away from the floor" (a jump is GravitySign * power) — so a reverse field mirrors
			// which end of the stack is which instead of driving the hard kick into the ceiling-floor.
			bool awayFromFloor = direction.y * GravitySign > 0f;
			float launch = MathF.Sign( direction.y ) * (awayFromFloor ? SWARM_LAUNCH_POWER : SWARM_LAUNCH_DOWN_POWER);
			VelY = launch > 0f ? MathF.Max( VelY, launch ) : MathF.Min( VelY, launch );
		}
		else
			ExtraVelX = Math.Clamp( ExtraVelX + MathF.Sign( direction.x ) * SWARM_LAUNCH_POWER,
				-MAX_EXTRA_X_SPEED, MAX_EXTRA_X_SPEED );
	}

	/// <summary>Repel from a Rewind echo through the shockwave channel so the launch still bounces off
	/// solids. Sticky glue tears first (same rules as a bullet repel) — the stuck path zeroes the
	/// shockwave channel every tick, so leaving the glue intact would repeat the repel's hit-stop
	/// forever against a player who visibly never moves. Opposing persistent movement is consolidated
	/// next, then cancelled on the repel axis; otherwise low-friction air movement resurfaces as the
	/// temporary force decays and produces a boomerang reversal.</summary>
	public void ApplyRewindEchoRepel( Vector2 dir, float strength )
	{
		if ( IsDead || IsHardened || IsTwinDashing || IsSquashed || strength <= 0f ) return;
		dir = Utils.Normalized( dir );
		if ( dir == Vector2.Zero ) return;

		if ( IsStuck )
			strength *= TearStickyGlueForRepel();

		if ( dir.x != 0f )
		{
			VelX += ExtraVelX + _grappleReleaseVelocity.x;
			ExtraVelX = 0f;
			_grappleReleaseVelocity = new Vector2( 0f, _grappleReleaseVelocity.y );
		}
		if ( dir.y != 0f )
		{
			VelY += ExtraVelY + _grappleReleaseVelocity.y;
			ExtraVelY = 0f;
			_grappleReleaseVelocity = new Vector2( _grappleReleaseVelocity.x, 0f );
		}

		float baseAlong = Vector2.Dot( Velocity, dir );
		float externalAlong = Vector2.Dot( FieldVelocity, dir );
		float opposingSpeed = Math.Max( 0f, -(baseAlong + externalAlong) );
		Velocity += dir * opposingSpeed;

		_shockwaveVel = dir * strength;
		_shockwaveStartMag = strength;
		DetachPlatform();
	}

	/// <summary>Tear sticky glue ahead of a repel impulse: plays the unstuck fx, opens the normal
	/// same-face re-grab grace window, and returns the strength factor the goo absorbs from the launch
	/// (80% survives on phase 1, 60% on phase 2). Callers check <see cref="IsStuck"/> first.</summary>
	float TearStickyGlueForRepel()
	{
		Block releasedBlock = StuckBlock;
		Direction releasedFace = _attachFace;
		float stickyFactor = StickyPhaseForContact( releasedBlock, releasedFace ) >= 2
			? REPEL_STICKY_PHASE2_FACTOR
			: REPEL_STICKY_PHASE1_FACTOR;

		ClearStuck( playUnstuck: true );
		_slamBlock = releasedBlock;
		_slamFace = releasedFace;
		_slamTimer = STICK_SLAM_RELEASE_TIME;
		return stickyFactor;
	}

	/// <summary>Apply a Gunner bullet's close-range repel. Sticky glue tears before the impulse is
	/// applied, reducing the force to 80% on phase 1 and 60% on phase 2 and opening the normal same-face
	/// re-grab grace window. Motion on the repel axis is consolidated into base velocity so an older,
	/// non-decaying jump velocity cannot resurface after a temporary opposing force fades.</summary>
	public void ApplyBulletRepel( Vector2 dir, float strength )
	{
		if ( IsDead || IsHardened || IsTwinDashing || IsSquashed || strength <= 0f ) return;

		if ( IsStuck )
			strength *= TearStickyGlueForRepel();

		DetachPlatform();
		ApplyGunDirectionalVelocity( dir, strength, BULLET_REPEL_COUNTER_MOMENTUM_FACTOR );
	}

	/// <summary>A recoil impulse opposite the Gunner's shot. While standing on the current floor the
	/// vertical component is removed, so shooting the ground underfoot cannot pop the player airborne —
	/// the FULL force is redirected along the remaining horizontal axis instead (a grounded diagonal
	/// shot kicks as hard as a grounded horizontal one, not a 0.71× remnant). The recoil axis is
	/// consolidated into base velocity rather than a separately decaying channel.
	///
	/// <para><paramref name="counterMomentumFactor"/> adds the component of the player's full integrated
	/// movement opposing recoil. At 1, recoil first cancels that opposing component, then leaves the
	/// complete base kick; movement perpendicular to recoil is unchanged.</para></summary>
	public void ApplyGunKickback( Vector2 shotDirection, float force, float counterMomentumFactor )
	{
		if ( IsDead || force <= 0f ) return;

		Vector2 baseKick = -Utils.Normalized( shotDirection ) * force;
		if ( OnFloor ) baseKick.y = 0f;
		if ( baseKick == Vector2.Zero ) return;

		ApplyGunDirectionalVelocity( baseKick, force, counterMomentumFactor );
	}

	/// <summary>Collapse every player-owned component on one Gunner-force axis into base velocity, then
	/// apply the impulse. Channels are consumed AXIS-BY-AXIS — the whole x and/or y component of a
	/// channel, including retained grapple release momentum, folds into base velocity when the force
	/// touches that axis. On a cardinal axis this is
	/// exactly the along-component (identical to a vector projection); on a diagonal it also relocates
	/// the channel's perpendicular residue into base velocity rather than leaving a ROTATED remnant
	/// behind (a projection would smear a purely horizontal ExtraVel into a phantom vertical component
	/// with the wrong decay/cap semantics). Total velocity is conserved either way. Environmental
	/// velocity is not consumed, but is included when calculating how much owned velocity is needed to
	/// cancel it.</summary>
	void ApplyGunDirectionalVelocity( Vector2 dir, float strength, float counterMomentumFactor )
	{
		dir = Utils.Normalized( dir );
		if ( dir == Vector2.Zero || strength <= 0f ) return;

		if ( dir.x != 0f )
		{
			VelX += ExtraVelX + _grappleReleaseVelocity.x + _shockwaveVel.x;
			ExtraVelX = 0f;
			_grappleReleaseVelocity = new Vector2( 0f, _grappleReleaseVelocity.y );
			_shockwaveVel = new Vector2( 0f, _shockwaveVel.y );
		}
		if ( dir.y != 0f )
		{
			VelY += ExtraVelY + _grappleReleaseVelocity.y + _shockwaveVel.y;
			ExtraVelY = 0f;
			_grappleReleaseVelocity = new Vector2( _grappleReleaseVelocity.x, 0f );
			_shockwaveVel = new Vector2( _shockwaveVel.x, 0f );
		}
		// _shockwaveStartMag is deliberately left as-is (matching the stasis damp): consuming part of
		// the channel must not RE-ARM the bounce gate. Resetting the reference to the smaller remainder
		// would make an already-decayed knockback read as 100% fresh, so firing a shot mid-flight could
		// revive wall ricochets the decay had already disqualified.
		if ( _shockwaveVel.Length <= SHOCKWAVE_STOP_THRESHOLD )
		{
			_shockwaveVel = Vector2.Zero;
			_shockwaveStartMag = 0f;
		}

		// With the owned channels folded in, the axis total is just base + environmental; cancel the
		// opposing part (per counterMomentumFactor) and stack the impulse on top.
		float baseAlong = Vector2.Dot( Velocity, dir );
		float externalAlong = Vector2.Dot( FieldVelocity, dir );
		float opposingSpeed = Math.Max( 0f, -(baseAlong + externalAlong) );
		Velocity += dir * (strength + opposingSpeed * Math.Max( 0f, counterMomentumFactor ));
	}

	/// <summary>Reflect the SHOCKWAVE channel (and only it) off any solid it's driving us into, with
	/// <see cref="SHOCKWAVE_BOUNCE_RESTITUTION"/> restitution. Run after the position adjust + arena clamp,
	/// so a fresh collide probe on each side reports whether we're now flush against a block/obstacle/wall.
	///
	/// Anti-jitter / don't-mirror-other-forces: a side is only bounced when the shockwave's OWN component
	/// points INTO that side (e.g. <c>_shockwaveVel.x > 0</c> against a right wall). If a different force
	/// (wind, walking) is what's pinning us there while the shockwave points away — or the shockwave was
	/// already reflected away — its component doesn't point into the contact, so nothing is mirrored and it
	/// can't ping-pong. The strength gate (only while ≥ <see cref="SHOCKWAVE_BOUNCE_MIN_FRACTION"/> of the
	/// applied force) further caps repeated bounces and skips a mostly-spent push (which just absorbs).
	///
	/// DEADLY SURFACES DON'T BOUNCE: driving into live spikes is motion-into-spikes and KILLS (the
	/// CheckForSpikes rule). Without this the reflect escaped both kill paths: it flipped the channel
	/// before next tick's CheckForSpikes could read it, and a reflect from inside the 1px window never
	/// attempts the penetrating step ClampToBounds' wall kill needs (echo repel bounced off a spiked
	/// floor unharmed). The kill returns without touching the channel — rewind recovery restores it.</summary>
	void HandleShockwaveBounce()
	{
		if ( _shockwaveVel == Vector2.Zero ) return;

		const float THRESHOLD = 1.0f;
		float speed = _shockwaveVel.Length;
		bool canBounce = _shockwaveStartMag > 0f && speed >= SHOCKWAVE_BOUNCE_MIN_FRACTION * _shockwaveStartMag;

		float vx = _shockwaveVel.x;
		float vy = _shockwaveVel.y;
		bool bounced = false;

		if ( vx > 0f && IsPlayerColliding( X + THRESHOLD, Y, Direction.Right, out _ ) )
		{
			if ( ShockwaveIntoSpikes( Direction.Right ) ) { KilledBySpikes( Direction.Right ); return; }
			if ( canBounce ) { vx = -vx * SHOCKWAVE_BOUNCE_RESTITUTION; bounced = true; } else vx = 0f;
		}
		else if ( vx < 0f && IsPlayerColliding( X - THRESHOLD, Y, Direction.Left, out _ ) )
		{
			if ( ShockwaveIntoSpikes( Direction.Left ) ) { KilledBySpikes( Direction.Left ); return; }
			if ( canBounce ) { vx = -vx * SHOCKWAVE_BOUNCE_RESTITUTION; bounced = true; } else vx = 0f;
		}

		if ( vy > 0f && IsPlayerColliding( X, Y + THRESHOLD, Direction.Up, out _ ) )
		{
			if ( ShockwaveIntoSpikes( Direction.Up ) ) { KilledBySpikes( Direction.Up ); return; }
			if ( canBounce ) { vy = -vy * SHOCKWAVE_BOUNCE_RESTITUTION; bounced = true; } else vy = 0f;
		}
		else if ( vy < 0f && IsPlayerColliding( X, Y - THRESHOLD, Direction.Down, out _ ) )
		{
			if ( ShockwaveIntoSpikes( Direction.Down ) ) { KilledBySpikes( Direction.Down ); return; }
			if ( canBounce ) { vy = -vy * SHOCKWAVE_BOUNCE_RESTITUTION; bounced = true; } else vy = 0f;
		}

		_shockwaveVel = new Vector2( vx, vy );

		// A small hit-stop on a genuine bounce, scaled by the shockwave speed that drove us into the solid.
		if ( bounced )
			Stage.RequestHitStop( (int)MathF.Round(
				Utils.Map( speed, SHOCKWAVE_BOUNCE_HITSTOP_MIN_SPEED, SHOCKWAVE_BOUNCE_HITSTOP_MAX_SPEED,
					1f, HIT_STOP_SHOCKWAVE_BOUNCE_MAX_FRAMES, true, EasingType.Linear ) ) );
	}

	/// <summary>Shockwave contact on <paramref name="side"/> is a spike death, not a bounce: a live
	/// spiked surface presses there (<see cref="SpikedSurfaceAt"/>, same 1px reach as the probe) AND
	/// net motion still points into it (the relative-motion rule — a stronger opposing force is a
	/// pull-away, not a grind-in).</summary>
	bool ShockwaveIntoSpikes( Direction side )
	{
		float along = side switch
		{
			Direction.Left => -TotalVelocity.x,
			Direction.Right => TotalVelocity.x,
			Direction.Down => -TotalVelocity.y,
			_ => TotalVelocity.y,
		};
		return along > 0f && SpikedSurfaceAt( side );
	}

	/// <summary>Clear the per-tick block-fed state (stasis flags, reverse-gravity flag, siren drift).
	/// Called at frame start (GameStage.Tick, before the blocks tick) so a block's report this frame is
	/// seen across the whole player tick — and holds NOTHING across frames.</summary>
	public void ResetStasis()
	{
		_stasisLevel = 0;
		_stasisStrength = 0f;
		_fieldReversed = false;
		_gravityReversed = _selfGravityReversed; // effective = field(false) XOR self
		_sirenVel = Vector2.Zero; // per-tick drift, never carried (see the channel's declaration)
	}

	/// <summary>A Stasis block reports the player is standing in its trail this tick, at the given LEVEL
	/// (1 = phase-1 trail, 2 = phase-2) and effect STRENGTH (0..1, the cell's fade curve). Latches the
	/// strongest overlapping trail (max level + max strength) so composed trails take the deeper stasis.</summary>
	public void ApplyTrailStasis( int level, float strength )
	{
		if ( IsTwinDashing ) return;
		_stasisLevel = Math.Max( _stasisLevel, level );
		_stasisStrength = Math.Max( _stasisStrength, strength );
	}

	/// <summary>A Reverse block reports the player is inside its gravity-flip field this tick. Latches for
	/// the tick (reset by <see cref="ResetStasis"/> at frame start); <see cref="ApplyGravity"/> then pulls
	/// the player UP instead of down. Overlapping fields simply re-set the same flag.</summary>
	public void ApplyReverseGravity()
	{
		if ( IsTwinDashing ) return;
		// SHIFTER is immune while standing on its base floor (by design): the field
		// only affects a surface-gravity player AIRBORNE, and entering/leaving a field never detaches
		// them from a surface. Blocks tick before the player, so this reads last tick's settled
		// contact flags — stable for the whole player tick.
		if ( HAS_SURFACE_GRAVITY && HasSurfaceGravitySupport( _surfaceGravityDirection ) ) return;
		_fieldReversed = true;
		_gravityReversed = !_selfGravityReversed; // effective = field(true) XOR the persistent baseline
	}

	/// <summary>Sets the persistent gravity baseline while preserving any temporary Reverse-field
	/// inversion active this tick.</summary>
	public void SetBaselineGravityReversed( bool reversed )
	{
		_selfGravityReversed = reversed;
		_gravityReversed = _fieldReversed ^ _selfGravityReversed;
	}

	/// <summary>Edge-triggered enter/leave sound cue for the stasis trail (a soft "phase" blip each way).
	/// Reads <c>_stasisLevel</c>, which the block sets before the player ticks this frame.</summary>
	void UpdateTrailAudio()
	{
		// Impostors stay SILENT (same reasoning as UpdateReverseGravityAudio below): several clones
		// crossing a trail edge would layer the cue into a storm of blips that aren't about the real player.
		bool inTrail = _stasisLevel >= 1;
		if ( inTrail && !_inTrailPrev )
		{
			if ( !IsImpostor ) Audio.PlaySfx( SfxType.TurnInvisible, Position, 0.45f, 1.25f );
		}
		else if ( !inTrail && _inTrailPrev )
		{
			if ( !IsImpostor ) Audio.PlaySfx( SfxType.TurnVisible, Position, 0.45f, 1.25f );
		}
		_inTrailPrev = inTrail;
	}

	/// <summary>Edge-triggered enter/leave sound cue for a reverse-gravity field (a warpy "flip" blip each
	/// way — low pitch on entry as gravity inverts, higher on exit as it rights). Reads
	/// <c>_gravityReversed</c>, which the block sets before the player ticks this frame.</summary>
	void UpdateReverseGravityAudio()
	{
		// Impostors keep a (smaller) visual poof but stay SILENT: several clones crossing a field edge
		// would otherwise layer the cue into a storm of pops that aren't about the real player.
		if ( _gravityReversed && !_inReverseFieldPrev )
		{
			if ( !IsImpostor ) Audio.PlaySfx( SfxType.TurnInvisible, Position, 0.6f, 0.7f );
			SpawnReverseGravityBurst();
		}
		else if ( !_gravityReversed && _inReverseFieldPrev )
		{
			if ( !IsImpostor ) Audio.PlaySfx( SfxType.TurnVisible, Position, 0.6f, 0.9f );
			SpawnReverseGravityBurst();
		}
		_inReverseFieldPrev = _gravityReversed;
	}

	/// <summary>A small translucent violet "poof" around the player when the reverse-gravity field turns on
	/// or off. Cosmetic (cosmetic Rng, translucent, non-colliding), so it never touches the sim/replay stream.</summary>
	void SpawnReverseGravityBurst()
	{
		if ( Stage is null ) return;
		// Impostors get a much smaller puff (and no sfx, see UpdateReverseGravityAudio) so a pack of
		// clones drifting through a field edge doesn't fill the arena with full-size bursts.
		int n = IsImpostor ? Rng.CosmeticInt( 3, 5 ) : Rng.CosmeticInt( 9, 15 );
		for ( int i = 0; i < n; i++ )
		{
			Vector2 vel = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 20f, 65f );
			ParticleKind kind = Rng.CosmeticValue() < 0.5f ? ParticleKind.ReverseField0 : ParticleKind.ReverseField1;
			Stage.AddFieldParticle(
				new Vector2( X + Rng.CosmeticInt( -6, 6 ), Y + Rng.CosmeticInt( -6, 6 ) ),
				vel, decel: 0.90f, gravity: 0f, kind,
				Rng.CosmeticFloat( 0.35f, 0.7f ), Rng.CosmeticInt( 2, 4 ) );
		}
	}

	/// <summary>True when the wind channel is pushing us AWAY from a parent surface in the given
	/// relative direction (the platform's direction from us), hard enough to peel us off it — so a
	/// strong gust breaks a block ride/hug instead of the glue re-pinning us every tick.</summary>
	bool WindBreaksGrip( Direction platformDir ) => platformDir switch
	{
		Direction.Down => _windVel.y > WIND_LIFT_SPEED,          // riding the top; wind blows us up
		Direction.Up => _windVel.y < -WIND_LIFT_SPEED,           // hanging under; wind blows us down
		Direction.Left => _windVel.x > WIND_GRIP_BREAK_SPEED,    // hugging its right face; wind blows us right
		Direction.Right => _windVel.x < -WIND_GRIP_BREAK_SPEED,  // hugging its left face; wind blows us left
		_ => false,
	};

	// ==========================================================================================
	// STICKY BLOCK (BlockSticky)
	// ==========================================================================================
	/// <summary>
	/// Drive the sticky-block trap. Returns true when we're actively stuck to a sticky block face
	/// and therefore own this whole tick (the caller then skips the normal move / gravity / jump path).
	/// Runs after the collision flags are set (so it knows which sticky block sits on each face) and
	/// before the position step (so it can glue us flush and drive tangent-only movement itself).
	/// </summary>
	bool HandleStickyBlocks( float dt, Direction newXDirection )
	{
		// Tick down the post-slam grace window; while it's open the flung-off side is off-limits to re-grab.
		if ( _slamTimer > 0f ) _slamTimer -= dt;
		bool slamWindowOpen = _slamTimer > 0f;
		if ( _stickyEdgeReleaseCooldown > 0f ) _stickyEdgeReleaseCooldown -= dt;

		// Walking past a phase-1 edge leaves the body overlapping the adjacent corner. Keep adjacent
		// faces ineligible until contact ends. The released face ITSELF sits out a short cooldown first:
		// climbing off the TOP of a side face exits at the slow climb speed, so gravity drops the player
		// back inside the edge tolerance within a frame or two and an immediate re-grab spams
		// stick/unstick (sfx + hit-stop each cycle). Once the cooldown lapses the face may re-grab as
		// normal, so reversing onto it cannot leave it permanently non-sticky. The record only clears on
		// contact ending AFTER the cooldown, so 1px probe flicker mid-cooldown can't reset it early.
		if ( _stickyEdgeReleaseBlock != null && _stickyEdgeReleaseCooldown <= 0f &&
			(_stickyEdgeReleaseBlock.Replaced || !TouchesStickyBlock( _stickyEdgeReleaseBlock )) )
		{
			_stickyEdgeReleaseBlock = null;
			_stickyEdgeReleaseFace = Direction.None;
		}

		var (block, face) = FindStickyContact(); // excludes the flung-off side while its window is open

		// Lost the owned sticky face (the block died/became invalid), or found no fresh contact: release it
		// with the small outward pop (a same-tick re-grab below re-zeros it — the new glue absorbs it).
		// Tangential walk/shove-off releases inside ApplyStuckMovement. May re-grab another face this tick.
		if ( IsStuck && !(block == _attachEntity && face == _attachFace) )
		{
			Direction releasedFace = _attachFace;
			ClearStuck( playUnstuck: true );
			ApplyUnstickFling( releasedFace );
		}

		// (Re-)grab a fresh contact. A grab during a slam window (i.e. onto a DIFFERENT side than the one
		// we were flung off — the forbidden one is already excluded by FindStickyContact) skips the
		// hit-stop, so getting flung into another face doesn't stack another freeze on the slam.
		if ( !IsStuck && block != null )
			EngageStick( block, face, suppressHitStop: slamWindowOpen );

		if ( !IsStuck )
			return false; // free (nothing to grab, or resting on the flung-off side mid-window)

		// A hard slam by the block frees us for the grace window, handing us its pre-slam velocity as an
		// inertia fling (unstick BEFORE the velocity is added, so a rising block shoots us up). Blocks tick
		// first, so SlammedThisTick + the banked PreImpactVelocity are both readable now.
		if ( StuckBlock.SlammedThisTick )
		{
			ReleaseFromSlam();
			return false; // free this tick -> normal physics carries the fling
		}

		// A Spikey graft settling on the stuck face itself kills: the glue zeroes all velocity, so
		// CheckForSpikes' relative-motion rule reads the contact as survivable flush rest — but the
		// goo pressing us into live teeth is a genuine press (same contract as the ceiling-cling
		// spiked-surface kill). Checked every stuck tick; growing/retracting teeth stay harmless.
		if ( StuckBlock.SideDeadly( Globals.GetOppositeDirection( _attachFace ) ) )
		{
			KilledBySpikes( _attachFace );
			if ( IsDead || _rewindDeathRecoveredThisTick ) return false;
		}

		// A charge wind-up FREEZES the timeout (accumulator, warning shake and all): winding is the
		// sanctioned way out of the goo, and it locks tangent movement (see ApplyStuckMovement), so the
		// player has no way to feed the timer or escape it. Without this a held charge — Spring reaches
		// max in 0.75s and doesn't auto-fire, so aiming a full charge is an open-ended hold — always
		// eventually got torn off the face with the wind-up wiped. Releasing without firing resumes the
		// count from where it paused (the under-charged branch clears _chargingJump the same tick).
		if ( !_chargingJump )
		{
			_stickyStuckTime += dt;
			float stickyTimeoutRemaining = STICK_TIMEOUT - _stickyStuckTime;
			if ( stickyTimeoutRemaining > 0f && stickyTimeoutRemaining <= STICK_TIMEOUT_SHAKE_TIME )
			{
				float shakeProgress = 1f - stickyTimeoutRemaining / STICK_TIMEOUT_SHAKE_TIME;
				ShakeRandom( MathX.Lerp( STICK_TIMEOUT_SHAKE_MIN, STICK_TIMEOUT_SHAKE_MAX, shakeProgress ), 2 );
			}
			if ( _stickyStuckTime >= STICK_TIMEOUT )
			{
				ReleaseFromStickyTimeout();
				return false;
			}
		}

		bool verticalFace = _attachFace == Direction.Left || _attachFace == Direction.Right;

		// CHARGE VS THE GLUE: a charge-jump character can wind a charged jump against the stuck face and
		// tear free (see HandleStickyChargeJump). When it fires we're already detached with the launch
		// velocity set — hand the tick back to the normal move path, exactly like the slam fling above.
		if ( HandleStickyChargeJump( dt ) )
			return false;

		// Refuse the jump/walljump off the block: gooey "nope" + a small tug (block jolts toward the face
		// we cling to) + a tiny freeze. On a HORIZONTAL face the blocked gesture is a jump (Up). On a
		// vertical face Up/Down climb (see ApplyStuckMovement), so the blocked gesture is the walljump-off
		// — pressing AWAY from the wall (the direction you'd launch). EXCEPT while winding a charge
		// against the EFFECTIVE overhead face: there the "jump" press IS the charge hold (pressing INTO
		// the face), not a jump. That comparison must be gravity-relative on BOTH sides: UpJustPressed
		// reads the physical DOWN key in a reverse field (the accessor swap), which is exactly the hold
		// key for a charge into a Down face there (HandleStickyChargeJump swaps back to physical) — a
		// literal Direction.Up here let the wind-up itself trigger the gooey "nope" every press.
		bool blockedJump = verticalFace
			? (_attachFace == Direction.Left ? RightJustPressed : LeftJustPressed) // press off the wall
			: UpJustPressed;
		if ( HAS_SURFACE_GRAVITY )
			blockedJump = RawDirectionJustPressed( OppositeDirection( _attachFace ) );
		Direction effectiveOverheadFace = _gravityReversed ? Direction.Down : Direction.Up;
		if ( blockedJump && !(_chargingJump && _attachFace == effectiveOverheadFace) )
			RefuseStickyJump();

		ApplyStuckMovement( dt, verticalFace, newXDirection );
		return IsStuck; // ApplyStuckMovement may have detached us at an edge this tick
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>The sticky block + face we're currently touching, or (null, None). Prefers the face
	/// we're already attached to (so touching two faces at a corner doesn't flip-flop). Only live
	/// sticky faces count.</summary>
	(Block, Direction) FindStickyContact()
	{
		// Once glued, the attachment itself owns contact with that face. Blocks tick before the player
		// and can move several pixels per fixed step, so the generic 1px collision probe may no longer
		// reach a fast block at its new position before ApplyStuckMovement carries us there. Requiring
		// that probe would falsely release the grip as the block accelerates. Tangential walk/shove-off
		// is validated later by ApplyStuckMovement; a hard slam is handled by HandleStickyBlocks.
		if ( IsStuck && StuckBlock is Block { Replaced: false } attached && StickyPhaseForContact( attached, _attachFace ) > 0 )
			return (attached, _attachFace);

		bool windowOpen = _slamTimer > 0f;
		foreach ( var face in Globals.GetAllDirections() )
		{
			var s = StickyOn( face );
			if ( s == null ) continue;
			// Don't enter a grip that ApplyStuckMovement would release immediately (at least Phase 1 would have released immediately). This also lets a
			// valid top/bottom contact win over a transient side probe while crossing a flush corner.
			if ( !WithinStickyFaceEdge( s, face ) ) continue;
			// Released block: adjacent faces blocked until contact ends; the released face blocked while
			// its re-grab cooldown runs (see HandleStickyBlocks).
			if ( s == _stickyEdgeReleaseBlock && (face != _stickyEdgeReleaseFace || _stickyEdgeReleaseCooldown > 0f) ) continue;
			if ( windowOpen && s == _slamBlock && face == _slamFace ) continue; // flung-off side: off-limits
			return (s, face);
		}
		return (null, Direction.None);
	}

	bool TouchesStickyBlock( Block block )
	{
		foreach ( var face in Globals.GetAllDirections() )
			if ( StickyOn( face ) == block ) return true;
		return false;
	}

	bool WithinStickyFaceEdge( Block block, Direction face )
	{
		bool verticalFace = face == Direction.Left || face == Direction.Right;
		float tangentOffset = verticalFace ? Y - block.Y : X - block.X;
		return MathF.Abs( tangentOffset ) <= StickyFaceEdgeLimit( block, verticalFace );
	}

	/// <summary>How far along a sticky face the player's centre may sit before the grip ends: the
	/// block's half-extent plus the same lean tolerance as a ledge. Single source for both the grab
	/// gate (<see cref="WithinStickyFaceEdge"/>) and the walk/shove-off release in
	/// <see cref="ApplyStuckMovement"/> — the two must agree or grabs/releases oscillate.</summary>
	float StickyFaceEdgeLimit( Block block, bool verticalFace )
		=> (verticalFace ? block.Height : block.Width) / 2f
			+ (LEDGE_FALL_OFF_FRACTION - 0.5f) * (verticalFace ? Height : Width);

	static int StickyPhaseForContact( Block block, Direction relativeFace )
		=> block.StickyPhase( Globals.GetOppositeDirection( relativeFace ) );

	/// <summary>The live sticky block face we're colliding with in the given relative direction, else null.</summary>
	Block StickyOn( Direction face )
	{
		Entity2D e = face switch
		{
			Direction.Down => _collidingEntityDown,
			Direction.Up => _collidingEntityUp,
			Direction.Left => _collidingEntityLeft,
			Direction.Right => _collidingEntityRight,
			_ => null,
		};
		return e is Block block && StickyPhaseForContact( block, face ) > 0 ? block : null;
	}

	// ----------------------------------------------------------------------------------------
	// SWARM JUICE SCALING: sticky grab/release/refused-jump feedback is per BODY, and a swarm runs up
	// to 7 bodies off one shared input — full-strength juice per body stacks into constant micro-
	// freezes and same-tick sfx pile-ups. Every swarm body (the run player and its clones alike — no
	// special treatment for the authoritative one) scales its sticky hit-stops by an ease-out on the
	// LIVE body count: full at 1 body, ~90% gone at 6, gone entirely at 7. The sfx dim more gently
	// (linear, to half volume at a full swarm) and each cue type is rate-limited swarm-wide so several
	// bodies sticking in one step read as ONE squelch, not a stacked blast. The hit-stop scale reads
	// only sim state (live body count), so it stays deterministic; the sfx limiter uses wall-clock
	// time like the group footstep window — cosmetic only, never fed back into the sim.
	const float STICKY_JUICE_EASE_EXPONENT = 1.3f; // (remaining/6)^1.3 → ~0.79 at 2 bodies, ~0.1 at 6
	const float STICKY_SFX_MIN_VOLUME = 0.5f;      // a full swarm plays sticky cues at half volume
	const float STICKY_SFX_MIN_INTERVAL = 0.02f;   // ≥ one fixed step between same-type sticky cues
	static readonly Dictionary<SfxType, float> _stickySfxLastPlay = new();

	/// <summary>Live bodies in this player's swarm (owner + clones); 1 for every non-swarm body.</summary>
	int SwarmBodyCount()
	{
		if ( !IsSwarmClone && !_abilities.HasSwarm ) return 1;
		Player root = IsSwarmClone ? SwarmOwner : this;
		if ( root is null ) return 1;
		int count = root.IsDead ? 0 : 1;
		foreach ( Player clone in Stage.Impostors )
			if ( !clone.IsDead && clone.IsSwarmClone && ReferenceEquals( clone.SwarmOwner, root ) )
				count++;
		return Math.Max( 1, count );
	}

	/// <summary>Request a sticky hit-stop scaled by the swarm ease-out (a rounded-to-zero freeze is
	/// skipped outright). Non-swarm bodies always pass the full frame count through.</summary>
	void RequestStickyHitStop( int frames )
	{
		int bodies = Math.Min( SwarmBodyCount(), SwarmAbility.MAX_BODIES );
		float factor = MathF.Pow(
			(SwarmAbility.MAX_BODIES - bodies) / (float)(SwarmAbility.MAX_BODIES - 1),
			STICKY_JUICE_EASE_EXPONENT );
		int scaled = (int)MathF.Round( frames * factor );
		if ( scaled > 0 ) Stage.RequestHitStop( scaled );
	}

	/// <summary>Play a sticky cue dimmed by swarm size and rate-limited per type, so identical cues
	/// from several bodies in the same step collapse into one.</summary>
	void PlayStickySfx( SfxType type, Vector2 pos )
	{
		if ( _stickySfxLastPlay.TryGetValue( type, out float last )
			&& RealTime.Now - last < STICKY_SFX_MIN_INTERVAL ) return;
		_stickySfxLastPlay[type] = RealTime.Now;

		int bodies = Math.Min( SwarmBodyCount(), SwarmAbility.MAX_BODIES );
		float volume = 1f - (1f - STICKY_SFX_MIN_VOLUME) * (bodies - 1) / (float)(SwarmAbility.MAX_BODIES - 1);
		Audio.PlaySfx( type, pos, volume );
	}

	// ----------------------------------------------------------------------------------------
	void EngageStick( Block block, Direction face, bool suppressHitStop )
	{
		// Sticky owns this contact outright. A Shifter may grab it while a competing old floor is still
		// touching, so bypass that floor's anti-oscillation suppression and make the owned face its base.
		if ( HAS_SURFACE_GRAVITY )
		{
			AdoptSurfaceGravity( face, force: true );
			if ( IsDead ) return;
			// force does NOT bypass the rotated-hitbox refusal (the rotated body doesn't fit the slot —
			// see AdoptSurfaceGravity): a refused adoption must refuse the GRAB too, else we'd be Stuck
			// to a face that never became the base and the tangent controls go dead (input routes into
			// the axis ApplyStuckMovement zeroes) until the 4s timeout tears us free. Checked as the
			// POST-condition (does the base now point at the face?) rather than the return value so the
			// benign same-base "false" — the face already IS our floor, the common sticky-top landing —
			// still engages. No grab juice has fired yet, so a refused tick is silent and free movement
			// continues; the touch simply re-tries while contact lasts and takes once the body fits.
			if ( _surfaceGravityDirection != face ) return;
		}

		// The in-flight charge's surface, read BEFORE the Attach below claims the single slot: whether
		// a stored Up meant the gravity-frame ceiling depends on the grip that owned it (a cling grip =
		// frame label; a prior sticky face = literal physical Up), and that ownership is unreadable
		// once Stuck replaces the attachment.
		Direction chargedSurface = _chargeJumpSurfaceDirection == Direction.None
			? EffectiveGravityDirection
			: _chargeJumpSurfaceDirection == Direction.Up && _attach == Attachment.CeilingCling
				? (_gravityReversed ? Direction.Down : Direction.Up) // frame ceiling → the physical face
				: _chargeJumpSurfaceDirection;

		// Single slot: also releases any platform/cling grip, and records where along the face we
		// grabbed (the tangent offset — Attach derives it from the face orientation).
		Attach( Attachment.Stuck, block, face );
		UpdateStickyPlayerGoo();
		// The grab tears up any free-standing charge wind-up mid-flight — its charged surface no longer
		// matches reality. If the hold is kept, HandleStickyChargeJump restarts it against the stuck face.
		// EXCEPT when the surface we were already winding against IS the face being grabbed: the goo just
		// closed around a charge that was aimed here all along (land on a sticky top, start charging, the
		// block's slam window lapses and it re-glues under us), so the wind-up carries straight over into
		// the sticky charge instead of being wiped from under the player. Both use the same hold key and
		// aim axis for a given face, so nothing about the in-progress charge needs re-basing —
		// _chargeJumpSurfaceDirection is left as-is (None = "the floor") so a later detach resumes the
		// free-charge bookkeeping it was started with.
		if ( _chargingJump && chargedSurface != face )
		{
			_chargingJump = false;
			_chargeJumpTime = 0f;
			_chargeJumpAimTime = 0f;
			_chargeJumpSurfaceDirection = Direction.None;
		}
		_stickyEdgeReleaseBlock = null;
		_stickyEdgeReleaseFace = Direction.None;
		_stickyEdgeReleaseCooldown = 0f;
		_slamTimer = 0f; // committed to a side; the grace window (which forbade the OLD side) is resolved
		_groundPounding = false; // a grab ends any ground pound (the sticky path skips HandleGroundPound's own clear)
		// Stuck ticks skip HandleWallJumping's window decay too: spend the wall coyote so a pre-grab window
		// can't fire a stale wall jump, or press its face from afar, the tick the goo lets go. A live hug
		// on release re-arms it the normal way.
		_wallJumpLeniencyCounter = 0;
		_wallDirection = Direction.None;
		_wallJumpWallEntity = null;
		// The goo absorbs any in-flight shockwave punch on the grab. ApplyStuckMovement keeps the channel
		// at zero every stuck tick anyway; clearing it HERE also covers the grab-then-released-same-tick
		// path (the block slams this very tick), which returns before ApplyStuckMovement runs.
		_shockwaveVel = Vector2.Zero;
		_shockwaveStartMag = 0f;
		_swarmRepelVel = Vector2.Zero;
		// The engine velocity we arrived with dies in the goo too (same same-tick-slam reasoning as the
		// shockwave above; ApplyStuckMovement re-kills it every stuck tick). Keep VelX on a top/bottom
		// grab — it's the input-driven walk speed the tangent slide reuses this very tick.
		VelY = 0f; ExtraVelY = 0f; ExtraVelX = 0f;
		if ( face == Direction.Left || face == Direction.Right ) VelX = 0f;

		// Touching a side presses it (as a normal contact would), so a sticky block still phases up.
		// Hostile impostors never press sides (same rule as Unpenetrate's press) — and their grips are
		// background action: the goo burst and block shake below still show them, but they never freeze
		// the sim or play the player's stick cue.
		if ( !IsImpostor || IsSwarmClone )
		{
			PressBlockSide( block, Globals.GetOppositeDirection( face ) );
			PlayStickySfx( SfxType.StickyStick, block.Position );
			if ( !suppressHitStop ) RequestStickyHitStop( HIT_STOP_STICK_FRAMES ); // no freeze when we grab mid-slam-window
		}
		block.AddShake( StuckFaceOutward() * STICK_ENGAGE_SHAKE ); // goo grabs us: jolt toward the face
		AddStickyGooBurst( engaging: true );
	}

	void CreateStickyPlayerGoo( Vector2 artSize )
	{
		_stickyPlayerGoo = SpriteLayer.Add( GameObject, "sprites/blocks/sticky_goo.sprite",
			artSize, "goo_down", childOrder: 1 );
		_stickyPlayerGoo.Opaque = false;
		_stickyPlayerGoo.AlphaCutoff = 0f;
		_stickyPlayerGoo.Color = new Color( 1f, 1f, 1f, 0.9f );
		_stickyPlayerGoo.Enabled = false;
	}

	void UpdateStickyPlayerGoo()
	{
		if ( _stickyPlayerGoo is null ) return;
		if ( !IsStuck )
		{
			_stickyPlayerGoo.Enabled = false;
			return;
		}

		_stickyPlayerGoo.Enabled = true;
		_stickyPlayerGoo.PlayAnimation( $"goo_{Globals.GetStringForDirection( _attachFace )}" );
		// The body may rotate for surface gravity, but the goo edge represents a world-space contact.
		_stickyPlayerGoo.GameObject.LocalPosition = _sprite.GameObject.LocalPosition;
		_stickyPlayerGoo.GameObject.LocalScale = _sprite.GameObject.LocalScale;
		_stickyPlayerGoo.GameObject.LocalRotation = Rotation.Identity;
	}

	/// <summary>Gooey pink burst at the contact seam that sells the state flip. On STICK the goo
	/// squashes out sideways ALONG the face (tangent-dominant, hugging the block) — we just got
	/// pressed into it. On UNSTICK the strands snap AWAY from the face (outward-dominant, faster,
	/// longer-lived) — we just tore free. Same pink pair as the block's drips; cosmetic Rng only,
	/// so the burst never advances the authoritative sim stream. Must run while _attachFace is
	/// still set (i.e. before the release paths clear the attachment).</summary>
	void AddStickyGooBurst( bool engaging )
	{
		Vector2 outward = StuckFaceOutward();                      // block -> player (off the stuck face)
		Vector2 along = new Vector2( -outward.y, outward.x );      // across the face
		bool verticalFace = _attachFace == Direction.Left || _attachFace == Direction.Right;
		float halfSpan = (verticalFace ? Height : Width) * 0.5f;   // our footprint along the seam
		// Nudge a few px off the face: spawned dead flush, a particle overlaps the block on its first
		// tick and the block-deflection scrambles the burst's direction before it can read.
		Vector2 seam = Position - outward * ((verticalFace ? Width : Height) * 0.5f - 3f);

		int n = engaging ? Rng.CosmeticInt( 8, 13 ) : Rng.CosmeticInt( 10, 16 );
		for ( int i = 0; i < n; i++ )
		{
			Vector2 pos = seam + along * Rng.CosmeticFloat( -halfSpan, halfSpan );

			Vector2 vel = engaging
				// squish: shoots out both ways along the seam, barely lifting off the face
				? along * Rng.CosmeticFloat( -1f, 1f ) * Rng.CosmeticFloat( 40f, 90f )
					+ outward * Rng.CosmeticFloat( 5f, 25f )
				// snap: flicks off the face in our escape direction, with a little sideways scatter
				: outward * Rng.CosmeticFloat( 50f, 110f )
					+ along * Rng.CosmeticFloat( -35f, 35f );

			ParticleKind kind = Rng.CosmeticInt( 0, 3 ) == 0 ? ParticleKind.StickyGoo1 : ParticleKind.StickyGoo0;
			Stage.AddParticle(
				pos, vel,
				Rng.CosmeticFloat( 0.90f, 0.95f ),
				Globals.GRAVITY_STR_BLOOD, // goo droops downward like the block's drips
				kind,
				engaging ? Rng.CosmeticFloat( 0.2f, 0.45f ) : Rng.CosmeticFloat( 0.35f, 0.7f ),
				Rng.CosmeticInt( 2, engaging ? 5 : 6 ) );
		}
	}

	/// <summary>Unit vector from the block toward the face we're clinging to (= opposite of the block's
	/// relative direction from us): block below -> up, block above -> down, block to our left -> right, …</summary>
	Vector2 StuckFaceOutward() => Globals.GetVectorForDirection( Globals.GetOppositeDirection( _attachFace ) );

	/// <summary>The block landed a hard slam: open the grace window against THIS side and hand us its
	/// pre-slam velocity as an inertia fling. The unstick happens HERE, before the velocity is added, so
	/// the momentum actually launches us (e.g. a rising block that slams shoots us up). Blocks move on one
	/// axis at a time, so only the slam axis contributes. The block's own impact supplies the shake /
	/// screenshake / slam sfx, so this only layers the gooey release cue on top — no extra freeze/shake.</summary>
	void ReleaseFromSlam()
	{
		// Record which side is off-limits (and for how long) so we can't instantly re-grab it.
		_slamBlock = StuckBlock;
		_slamFace = _attachFace;
		_slamTimer = STICK_SLAM_RELEASE_TIME;

		// Inherit the block's pre-slam velocity (banked in PreImpactVelocity when it stopped; its live
		// Velocity is already zero by now). VelY for an upward slam (gravity then arcs it back down);
		// ExtraVelX for a horizontal slam (decays like the platform fling). The inertia is purely along
		// the slam AXIS — when that axis is TANGENT to the stuck face (a downward slam while we hug a
		// side) it has no separating component at all, so ALSO add the small perpendicular pop: without
		// it the player just slides down the face they were glued to, which reads as nothing happening.
		ExtraVelX += StuckBlock.PreImpactVelocity.x;
		VelY += StuckBlock.PreImpactVelocity.y;
		ApplyUnstickFling( _attachFace );

		// Anchored to the PLAYER, not the block: a slam is also what triggers a Teleport block's jump, and
		// blocks tick first — by now it may already sit at its arrival spot, which would play the release
		// cue across the arena. For every ordinary slam we're flush to the block, so the two are identical.
		if ( !IsImpostor || IsSwarmClone )
			PlayStickySfx( SfxType.StickyUnstuck, Position );
		AddStickyGooBurst( engaging: false );

		// Free during the window (the block keeps a distinct _slamBlock/_slamFace record above).
		Detach();
	}

	/// <summary>Release a player who has been continuously held by sticky goo for too long. The small
	/// outward launch and one-face grace window ensure a stopped block cannot immediately capture them
	/// again on the next physics tick.</summary>
	void ReleaseFromStickyTimeout()
	{
		Block releasedBlock = StuckBlock;
		Direction releasedFace = _attachFace;
		Vector2 releaseVelocity = StuckFaceOutward() * STICK_TIMEOUT_RELEASE_SPEED;

		ClearStuck( playUnstuck: true );
		_slamBlock = releasedBlock;
		_slamFace = releasedFace;
		_slamTimer = STICK_SLAM_RELEASE_TIME;
		Velocity += releaseVelocity;
	}

	/// <summary>Fully release the attachment (lost contact / slid off an edge / died). Also clears the
	/// slam window (never open when this is reached — active while stuck, cleared for a clean death).</summary>
	void ClearStuck( bool playUnstuck )
	{
		if ( playUnstuck && IsStuck )
		{
			// Hostile-impostor grips release silently (no cue/freeze), like their grabs; visuals still play.
			if ( !IsImpostor || IsSwarmClone )
			{
				PlayStickySfx( SfxType.StickyUnstuck, StuckBlock?.Position ?? Position );
				RequestStickyHitStop( HIT_STOP_UNSTICK_FRAMES );
			}
			StuckBlock?.AddShake( -StuckFaceOutward() * STICK_RELEASE_SHAKE ); // goo lets go: snap back away from the face
			AddStickyGooBurst( engaging: false );
		}
		// Only release a STICKY attachment — this is also called on death while possibly Riding, and
		// stripping that is HandleMovingPlatforms' business, not the sticky cleanup's.
		if ( IsStuck )
			Detach();
		_stickyEdgeReleaseBlock = null;
		_stickyEdgeReleaseFace = Direction.None;
		_stickyEdgeReleaseCooldown = 0f;
		_slamBlock = null;
		_slamFace = Direction.None;
		_slamTimer = 0f;
	}

	/// <summary>Tiny outward pop, perpendicular to the released face, so the goo visibly "lets go" instead
	/// of the player silently peeling off. Used by the releases with no launch of their own (edge
	/// walk/shove-off, lost contact) AND layered onto the slam release, whose inertia fling is purely
	/// along the slam axis and so can be fully tangential. Timeout / charge-tear / grapple releases have
	/// their own perpendicular-capable launches and skip this. Same channel split as the slam fling:
	/// ExtraVelX so a horizontal pop decays like a platform fling, VelY so a vertical pop arcs under
	/// gravity. Takes the face explicitly (some call sites run after ClearStuck, which wipes _attachFace).</summary>
	void ApplyUnstickFling( Direction releasedFace )
	{
		Vector2 outward = Globals.GetVectorForDirection( Globals.GetOppositeDirection( releasedFace ) );
		ExtraVelX += outward.x * STICK_RELEASE_FLING_SPEED;
		VelY += outward.y * STICK_RELEASE_FLING_SPEED;
	}

	/// <summary>Feedback for a jump/walljump the sticky block won't let us take: the gooey "nope" sfx, a
	/// short visual-only player shake along the stuck face, and a tiny freeze.</summary>
	void RefuseStickyJump()
	{
		// A glued hostile impostor's AI mashing its blocked jump must not spam the "nope" cue or freeze
		// the sim — its refusal stays a body-local shake.
		if ( !IsImpostor || IsSwarmClone )
		{
			PlayStickySfx( SfxType.StickyBlocked, StuckBlock.Position );
			RequestStickyHitStop( HIT_STOP_STICK_BLOCKED_FRAMES );
		}
		Shake( StuckFaceOutward() * STICK_BLOCKED_SHAKE, STICK_BLOCKED_SHAKE_FRAMES );
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>CHARGE VS THE GLUE: a charge-jump character can wind up a charged jump against whichever
	/// face it's stuck to — hold TOWARD the face, aim along it, exactly like the free-standing charges
	/// (see <see cref="HandleChargeJump"/>). Releasing the hold below
	/// <see cref="CharacterAbilities.StickyChargeReleaseFraction"/> of the max charge isn't enough to
	/// tear the goo: the player just shakes (harder the longer it was wound) and stays stuck, the wind-up
	/// reset. At or above it, releasing the hold — or reaching max charge when auto-fire is enabled — tears
	/// them out and fires the jump at
	/// <see cref="CharacterAbilities.StickyChargePowerFactor"/> strength (pulling free costs some power),
	/// opening the same one-side re-grab grace window a slam does (the <see cref="_slamBlock"/> trio, as
	/// <see cref="DetachForGrapple"/> does) so a launch aimed along the face isn't instantly swallowed by
	/// the glue it just left. Returns true when the jump fired: the player is detached with the launch
	/// velocity set, and the stuck path hands the tick back to the normal move path (the launch integrates
	/// exactly like the slam fling). Deterministic: reads only the recorded hold/aim input, the stuck
	/// state and the fixed tick <c>dt</c> — the shake/sfx/freeze are cosmetic.</summary>
	bool HandleStickyChargeJump( float dt )
	{
		bool verticalFace = _attachFace == Direction.Left || _attachFace == Direction.Right;
		bool hasChargeForFace = verticalFace
			? HAS_CHARGE_WALL_JUMP
			: _attachFace == Direction.Up ? HAS_CHARGE_CEILING_JUMP : HAS_CHARGE_JUMP;
		if ( !hasChargeForFace )
			return false;

		// Hold TOWARD the stuck face. The Up/Down accessors are gravity-swapped (toward/away from the
		// current floor), but the face is a fixed physical side of the block — swap back to physical.
		bool holdingCharge = _attachFace switch
		{
			Direction.Left => LeftPressed,
			Direction.Right => RightPressed,
			Direction.Up => _gravityReversed ? DownPressed : UpPressed,
			_ => _gravityReversed ? UpPressed : DownPressed,
		};

		if ( !_chargingJump )
		{
			// Fresh wind-up the moment the toward-face key is held. No settle guard (unlike the free
			// ground charge): the glue already has us planted, so there's no landing to protect.
			if ( holdingCharge )
			{
				BeginChargeJump( _attachFace, dt, fromWall: verticalFace );
			}
			return false;
		}

		float maxChargeTime = verticalFace ? CHARGE_WALL_JUMP_MAX_TIME : CHARGE_JUMP_MAX_TIME;
		// Phase-2 goo grips harder: its own (near-full at the default 0.99) release fraction applies, and
		// it saps more of the launch on the way out. Both read the LIVE phase — a block phasing up
		// mid-wind tightens its grip immediately. The power factor is latched here because the fire path
		// below detaches (ClearStuck) before FireChargeJump, after which StuckBlock is gone.
		int stickyPhase = StickyPhaseForContact( StuckBlock, _attachFace );
		float releaseFraction = stickyPhase >= 2
			? STICKY_CHARGE_RELEASE_FRACTION_P2
			: STICKY_CHARGE_RELEASE_FRACTION;
		float powerFactor = stickyPhase >= 2
			? STICKY_CHARGE_POWER_FACTOR_P2
			: STICKY_CHARGE_POWER_FACTOR;
		bool fire = false;
		if ( holdingCharge )
		{
			AdvanceChargeJump( dt, fromWall: verticalFace );
			fire = CHARGE_JUMP_AUTO_FIRE_AT_MAX && _chargeJumpTime >= maxChargeTime;
		}
		else
		{
			fire = _chargeJumpTime >= maxChargeTime * releaseFraction;
			if ( !fire )
			{
				// Released too early: the goo wins. A gooey "nope" + a tug back along the face — scaled
				// by how far the wind-up got — sells the failed escape; the charge resets and can re-arm.
				float windFrac = Math.Clamp(
					_chargeJumpTime / Math.Max( 0.0001f, maxChargeTime * releaseFraction ), 0f, 1f );
				PlayStickySfx( SfxType.StickyBlocked, StuckBlock.Position );
				Shake( StuckFaceOutward() * STICK_BLOCKED_SHAKE * Utils.Map( windFrac, 0f, 1f, 0.7f, 2.0f, true, EasingType.Linear ),
					STICK_BLOCKED_SHAKE_FRAMES + (int)MathF.Round( windFrac * STICK_BLOCKED_SHAKE_FRAMES ) );
				RequestStickyHitStop( HIT_STOP_STICK_BLOCKED_FRAMES );
				_chargingJump = false;
				_chargeJumpTime = 0f;
				_chargeJumpAimTime = 0f;
				_chargeJumpSurfaceDirection = Direction.None;
			}
		}

		if ( !fire )
			return false;

		// Tore free. Open the slam-style grace window against the fired face BEFORE launching so next
		// tick's contact can't instantly re-glue a launch that travels along it.
		Block releasedBlock = StuckBlock;
		Direction releasedFace = _attachFace;
		ClearStuck( playUnstuck: true ); // unstuck sfx + hit-stop + goo snap + Detach
		_slamBlock = releasedBlock;
		_slamFace = releasedFace;
		_slamTimer = STICK_SLAM_RELEASE_TIME;
		FireChargeJump( powerFactor, releasedBlock );
		_chargingJump = false;
		return true;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Glue the player flush to the stuck face (locking the normal axis dead), slide them ALONG
	/// the face by input (walk on a top/bottom, climb Up/Down on a side), carry the block's own motion,
	/// and pop them off once their centre passes the edge.</summary>
	void ApplyStuckMovement( float dt, bool verticalFace, Direction newXDirection )
	{
		Block stuck = StuckBlock;
		RectF b = stuck.GetRect();

		// --- external force channels: drop them ENTIRELY (both axes), every stuck tick ---
		// Not just the normal-axis component: the glue skips position integration, so a wind/magnet lane
		// (or a siren song) overlapping us keeps FEEDING its channel with no motion to spend it on — the
		// magnet channel integrates toward its 400 px/s terminal — and the banked tangential pull fired
		// as an untelegraphed yank the instant we came unstuck. A held player has no drift; hold nothing.
		// The shockwave channel too: the goo absorbs a ring's punch outright (its knockback would never
		// integrate anyway, just decay silently on the channel) — the ring skips its hit-stop on a stuck
		// player for the same reason (see BlockShockwave.UpdateWaves). Swarm repel is the same shape of
		// problem: an overlapping copy re-feeds the channel every tick while the glue holds us still, so
		// the banked push would fire as a yank on release.
		_windVel = Vector2.Zero;
		_magnetVel = Vector2.Zero;
		_sirenVel = Vector2.Zero;
		_shockwaveVel = Vector2.Zero;
		_shockwaveStartMag = 0f;
		_swarmRepelVel = Vector2.Zero;

		// --- normal axis: snap flush to the face and kill any velocity along it. The TANGENT-axis engine
		// velocity dies in the goo too (all but the input-driven walk speed the tangent slide reuses
		// below — VelX on a top/bottom grip, and VelY on a surface-gravity side grip, where
		// HandleSurfaceGravityInput rebuilds it from input every tick): gravity and the move path are
		// skipped while stuck, so whatever velocity we ARRIVED with would otherwise sit frozen for the
		// whole grip and fire, stale, on any release that doesn't overwrite it — a wall grabbed mid-fall
		// would spit the player out at full pre-grab fall speed. Climb speed is rebuilt from input each
		// tick; the walk-off release seeds its exit velocity explicitly. ---
		if ( verticalFace )
		{
			if ( _attachFace == Direction.Left ) Left = b.Right; else Right = b.Left;
			VelX = 0f; ExtraVelX = 0f;
			ExtraVelY = 0f;
			if ( !HAS_SURFACE_GRAVITY ) VelY = 0f;
		}
		else
		{
			if ( _attachFace == Direction.Down ) Bottom = b.Top; else Top = b.Bottom;
			VelY = 0f; ExtraVelY = 0f;
			ExtraVelX = 0f;
		}

		// --- tangent axis: slide along any sticky face ---
		float edgeLimit = StickyFaceEdgeLimit( stuck, verticalFace );
		float inputVel = 0f;
		if ( !_chargingJump )
		{
			// Input velocity along the tangent: climb Up/Down on a side, or the accelerated walk speed on a
			// top/bottom (reusing VelX from HandleHorizontalInput so it feels like normal ground movement).
			if ( HAS_SURFACE_GRAVITY )
			{
				inputVel = verticalFace ? VelY : VelX;
			}
			else if ( verticalFace )
			{
				// UpPressed/DownPressed are gravity-swapped in a reverse field, but the climb applies in
				// world Y — flip through GravitySign (like the free wall-climb) so the physical key still
				// moves the player the way it points on screen.
				float climb = (UpPressed ? 1f : 0f) - (DownPressed ? 1f : 0f);
				inputVel = climb * GravitySign * STICK_CLIMB_SPEED * _gunnerReloadMovementFactor;
			}
			else
			{
				inputVel = VelX;
			}

			inputVel *= STICK_MOVE_SPEED_FACTOR;
			_attachTangentOffset += inputVel * dt;

			// Disconnect once our centre passes the block's edge (input walk-off), same lean tolerance as a ledge.
			if ( MathF.Abs( _attachTangentOffset ) > edgeLimit )
			{
				// Carry our travel momentum off the edge, then release.
				if ( verticalFace ) VelY = inputVel; else VelX = inputVel;
				Block releasedBlock = StuckBlock;
				Direction releasedFace = _attachFace;
				ClearStuck( playUnstuck: true );
				_stickyEdgeReleaseBlock = releasedBlock;
				_stickyEdgeReleaseFace = releasedFace;
				_stickyEdgeReleaseCooldown = STICK_EDGE_RELEASE_COOLDOWN;
				ApplyUnstickFling( releasedFace );
				return;
			}
		}
		else
		{
			// During a charge wind-up the player is planted; directional input is aim, not travel.
			if ( verticalFace ) VelY = 0f; else VelX = 0f;
		}

		// Ride the block's tangent motion via the relative offset (block X/Y is its centre).
		if ( verticalFace ) Y = stuck.Y + _attachTangentOffset;
		else X = stuck.X + _attachTangentOffset;

		// A DIFFERENT solid ramming us should shove us ALONG the face (both phases) rather than clip
		// through — the glue would otherwise snap us straight back into it. Push out of any other block /
		// obstacle we overlap, fold that displacement back into the tangent offset, and re-assert the flush
		// to the sticky face on the normal axis (the push is tangential; keep the grip). If a shove carries
		// us past the edge, we come unstuck.
		bool pushed = false;

		// The ARENA BOUNDS are solid to a stuck player too: the tangent walk/glue above runs entirely
		// outside the normal AdjustX/Y path, so nothing else clamps us — walking along a wall-flush sticky
		// block used to carry the body several px into the wall band, and a SPIKED wall never got its
		// ClampToBounds kill while stuck (the player sat inside it unharmed, dying only on release).
		// ClampToBounds owns both: the spiked-wall kill (always lethal) and the solid clamp (gated on the
		// phases-through-walls character flag), exactly as the normal move path applies them.
		if ( !ClampToBounds( X, Y ) )
		{
			if ( IsDead ) return; // spiked wall where the glue carried us
			pushed = true;        // clamped flush — fold the displacement back into the tangent offset below
		}

		foreach ( Block ob in Stage.GetBlocks() )
		{
			if ( ob.PhasingIn ) continue; // a phasing Teleport block is intangible to the player
			if ( ob != stuck && GetRect().Intersects( ob.GetRect() ) ) { Unpenetrate( X, Y, ob ); pushed = true; }
		}
		foreach ( Obstacle obs in Stage.GetObstacles() )
		{
			// Per-obstacle solidity like every other obstacle interaction (see ObstacleSolidToUs):
			// a wall-phasing character stuck to a goo face passes through normal obstacles exactly
			// as it does free-moving (no shove, no spiked-face kill from a face it phases through) —
			// GLASS stays solid to everyone.
			if ( !ObstacleSolidToUs( obs ) ) continue;
			if ( !GetRect().Intersects( obs.GetRect() ) || !Unpenetrate( X, Y, obs ) ) continue;
			pushed = true;
			// Same lethality as the normal move path (see UnpenetrateFromSolid): the obstacle face we
			// resolved against may carry deadly spikes — the glue dragging us along/into a spiked face
			// must kill exactly like moving into it ourselves would, not shove us harmlessly along it.
			// Contact-span deadliness like the move path; no graze exemption — there is no sweep here,
			// the glue pressing us into any part of a spiked face is a genuine press.
			if ( Stage.ObstacleFaceDeadlyForRect( _lastUnpenetrateDir, GetRect() ) )
			{
				KilledBySpikes( _lastUnpenetrateDir );
				return;
			}
		}
		foreach ( Player player in Stage.Players )
		{
			if ( ReferenceEquals( player, this ) || player.IsDead || !player.IsHardened ) continue;
			if ( GetRect().Intersects( player.GetRect() ) && Unpenetrate( X, Y, player ) )
				pushed = true;
		}

		if ( pushed )
		{
			// Re-glue the normal axis (the push may have nudged it off the face) and re-derive the offset
			// — but only through CLEAR space (the ride/hug glues' clearance rule): the sticky block can
			// pass through a glass pane the player can't, so when its face slides behind one the push-out
			// above lifts us out of the pane and this flush re-snap would put us straight back in, ending
			// every tick embedded (and eventually resolve-flipping through to the far side). If the flush
			// spot is blocked, the face escaped behind something we can't follow through: the goo TEARS —
			// keep the pushed-out (clear) position and release with the edge-release cooldown so the
			// still-adjacent face can't re-stick us next tick. No fling: we just come loose where we are.
			float flushX = X;
			float flushY = Y;
			if ( verticalFace )
				flushX = _attachFace == Direction.Left ? b.Right + Width / 2 : b.Left - Width / 2;
			else
				flushY = _attachFace == Direction.Down ? b.Top + Height / 2 : b.Bottom - Height / 2;
			if ( !IsSpotClearOfSolids( flushX, flushY ) )
			{
				Block releasedBlock = StuckBlock;
				Direction releasedFace = _attachFace;
				ClearStuck( playUnstuck: true );
				_stickyEdgeReleaseBlock = releasedBlock;
				_stickyEdgeReleaseFace = releasedFace;
				_stickyEdgeReleaseCooldown = STICK_EDGE_RELEASE_COOLDOWN;
				return;
			}

			if ( verticalFace )
			{
				if ( _attachFace == Direction.Left ) Left = b.Right; else Right = b.Left;
				_attachTangentOffset = Y - stuck.Y;
			}
			else
			{
				if ( _attachFace == Direction.Down ) Bottom = b.Top; else Top = b.Bottom;
				_attachTangentOffset = X - stuck.X;
			}

			if ( MathF.Abs( _attachTangentOffset ) > edgeLimit )
			{
				Block releasedBlock = StuckBlock;
				Direction releasedFace = _attachFace;
				ClearStuck( playUnstuck: true ); // shoved off the end of the block
				_stickyEdgeReleaseBlock = releasedBlock;
				_stickyEdgeReleaseFace = releasedFace;
				_stickyEdgeReleaseCooldown = STICK_EDGE_RELEASE_COOLDOWN;
				ApplyUnstickFling( releasedFace );
				return;
			}
		}

		UpdateStuckAnimation( verticalFace, inputVel, newXDirection );
	}

	/// <summary>Pose the player while stuck: cling (wall up/down) on a side, walk/idle on a top/bottom.</summary>
	void UpdateStuckAnimation( bool verticalFace, float inputVel, Direction newXDirection )
	{
		if ( HAS_SURFACE_GRAVITY )
		{
			SetSpriteHead( -DirectionVector( _attachFace ) );
			_sprite.FlipVertical = false;
			if ( newXDirection == Direction.Left ) _sprite.FlipHorizontal = true;
			else if ( newXDirection == Direction.Right ) _sprite.FlipHorizontal = false;
			PlayAnim( MathF.Abs( inputVel ) > 1f ? PlayerAnimType.Walk : PlayerAnimType.Idle );
			return;
		}

		// Reverse-gravity flip, same as UpdateAnimation's: the stuck path owns the whole tick (the
		// normal animation update never runs), so without this the sprite kept whatever orientation it
		// had at the grab — stale if a reverse field drifted over (or off) the player mid-stick.
		_sprite.FlipVertical = _gravityReversed;

		// A charge wind-up owns the stuck pose: squash into the face + rotate feet-to-face, exactly like
		// the free wall/ceiling charges. UpdateAnimation (and its rotation reset) never runs while stuck,
		// so both the rotation and its reset live here; the aim indicator is driven every stuck tick too
		// (it hides itself whenever no charge is winding).
		if ( HAS_CHARGE_JUMP || HAS_CHARGE_WALL_JUMP || HAS_CHARGE_CEILING_JUMP )
		{
			UpdateChargeAimIndicator();
			if ( _chargingJump )
			{
				SetSpriteHead( -DirectionVector( _attachFace ) );
				// The rotation above already plants the feet on the stuck face, so undo the reverse-gravity
				// flip set just above (same rule as UpdateAnimation's rotated charge pose): in the rotated
				// frame it runs along the face normal and would stand the player off the face on their head.
				_sprite.FlipVertical = false;
				// ChargeAnim reads _facing to mirror its aim lean, so the sprite's flip has to agree with it.
				// The flip-setting lines below never run on this path (we return), and aiming on a top/bottom
				// face updates _facing every tick — without this the pose leans opposite the launch.
				if ( _facing == Direction.Left ) _sprite.FlipHorizontal = true;
				else if ( _facing == Direction.Right ) _sprite.FlipHorizontal = false;
				float maxChargeTime = verticalFace ? CHARGE_WALL_JUMP_MAX_TIME : CHARGE_JUMP_MAX_TIME;
				float chargeFraction = Math.Clamp( _chargeJumpTime / Math.Max( 0.0001f, maxChargeTime ), 0f, 1f );
				PlayAnim( ChargeAnim( chargeFraction, _attachFace, rotatedPose: true ) );
				return;
			}
			SetSpriteHead( null );
		}

		if ( verticalFace )
		{
			_sprite.FlipHorizontal = (_attachFace == Direction.Left); // face the wall we cling to
			PlayAnim( inputVel > 0f ? PlayerAnimType.WallUp : PlayerAnimType.WallDown );
		}
		else
		{
			if ( newXDirection == Direction.Left ) _sprite.FlipHorizontal = true;
			else if ( newXDirection == Direction.Right ) _sprite.FlipHorizontal = false;
			PlayAnim( MathF.Abs( inputVel ) > 1f ? PlayerAnimType.Walk : PlayerAnimType.Idle );
		}
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>
	/// Detects the double-tap (press-then-release twice) and, when armed, fires a dash impulse.
	/// Re-arms the dash on configured floor contact and, when enabled, a fresh wall hug.
	/// Runs at the top of <see cref="Tick"/> using last frame's collision flags (which persist until
	/// recomputed mid-tick), consistent with the rest of the player physics.
	/// </summary>
	void HandleDash( float dt )
	{
		if ( !_abilities.CanDash ) return;
		if ( IsStuck )
		{
			// Glued to a sticky block; no dashing off it. Abandon any in-progress gesture rather than
			// freezing it — otherwise its timers pause here and the same tap can complete a dash seconds
			// later once released. The gesture tracks its own trigger keys, so it can't read a stale
			// press-edge on the frame we unstick.
			_dashGesture.Reset();
			return;
		}

		// ---- RE-ARM on an eligible surface contact --------------------------------------------
		// Contact is edge-triggered: the surface must be left and re-established. Ground-dash permission
		// is independent, so spending a dash while grounded does not immediately recharge it.
		// Optional wall contact counts only while actually hugging, matching the wall-hug model.
		// Bounces are re-armed directly in the bounce branch (they leave the surface the same frame).
		bool contact = (DASH_REFRESH_ON_FLOOR && OnFloor) || (DASH_REFRESH_ON_WALL_HUG && HuggingWall);
		if ( contact && !_prevDashContact )
			_canDash = true;
		_prevDashContact = contact;

		// Another ability owns directional input this tick (a rewind, a blink, a grapple pull), so no
		// dash can be steered from it — drop any in-progress gesture. Checked AFTER the re-arm above:
		// a surface contact made or broken on a consumed tick still has to register its edge, or the
		// charge can be left unarmed until the next landing.
		if ( _directionalInputConsumed )
		{
			_dashGesture.Reset();
			return;
		}

		// Short diagonal grace: a cardinal second press waits up to ~3 frames for a perpendicular key so
		// diagonals don't need both keys on the same tick. Kept short since every cardinal dash pays it.
		_dashGesture.Configure( DASH_TAP_RELEASE_TIME, DASH_SECOND_TAP_TIME, DASH_DIAGONAL_GRACE_TIME );
		bool allowRawUp = _gravityReversed ? CAN_DASH_DOWN : CAN_DASH_UP;
		bool allowRawDown = _gravityReversed ? CAN_DASH_UP : CAN_DASH_DOWN;
		if ( _dashGesture.TryTrigger( this, dt, out Vector2 dashDirection,
			CAN_DASH_HORIZONTAL, allowRawUp, allowRawDown ) )
			FireDash( dashDirection );
	}

	/// <summary>Restores a spent dash charge from a character-specific ability event.</summary>
	internal void RefreshDashCharge()
	{
		if ( IsDead || !_abilities.CanDash ) return;
		_canDash = true;
	}

	// ----------------------------------------------------------------------------------------
	// SURFACE-MOVEMENT DASH RECHARGE: a spent charge refills by MOVING on a surface — walking the
	// gravity floor (the ceiling under reverse gravity), climbing or sliding a hugged wall, or shuffling
	// along a ceiling cling. Merely landing does nothing. Each mode has its own distance
	// (DashRecharge*Distance, 0 = mode off); every tick adds moved/required to one pooled 0..1 progress
	// so mixed movement accumulates, and progress persists through the air until the dash is respent.
	// Distance is our OWN displacement (the Adjust*Position sweep), so a platform carrying us never
	// counts; the wall hug's frame carry is subtracted for the same reason. The dash's own travel is
	// excluded while its gravity suppression runs, or a ground/wall dash would refill itself.
	// ----------------------------------------------------------------------------------------
	enum DashRechargeSurface { None, Floor, Wall, Ceiling }

	bool HasSurfaceDashRecharge => _abilities.CanDash
		&& (DASH_RECHARGE_FLOOR_DISTANCE > 0f || DASH_RECHARGE_WALL_DISTANCE > 0f || DASH_RECHARGE_CEILING_DISTANCE > 0f);

	/// <summary>Pitch bend for the surface-movement sfx (walk / wall climb / ceiling climb): low with the
	/// dash freshly spent, rising with recharge progress, and 1 (the authored pitch) while charged — so
	/// the footsteps themselves tell you how close the dash is to coming back.</summary>
	float DashRechargeSfxPitchScale => !HasSurfaceDashRecharge || _canDash
		? 1f
		: Utils.Map( _dashRechargeProgress, 0f, 1f, DASH_RECHARGE_SFX_PITCH_EMPTY, DASH_RECHARGE_SFX_PITCH_FULL, true, EasingType.Linear );

	DashRechargeSurface SampleDashRechargeSurface()
	{
		if ( !_abilities.CanDash || _canDash || IsStuck || _dashGravityTimer > 0f ) return DashRechargeSurface.None;
		if ( CAN_CEILING_CLING && _attach == Attachment.CeilingCling )
			return DASH_RECHARGE_CEILING_DISTANCE > 0f ? DashRechargeSurface.Ceiling : DashRechargeSurface.None;
		if ( OnFloor )
			return DASH_RECHARGE_FLOOR_DISTANCE > 0f ? DashRechargeSurface.Floor : DashRechargeSurface.None;
		if ( HuggingWall )
			return DASH_RECHARGE_WALL_DISTANCE > 0f ? DashRechargeSurface.Wall : DashRechargeSurface.None;
		return DashRechargeSurface.None;
	}

	void AccumulateDashRecharge( DashRechargeSurface surface, Vector2 moved, float wallCarryY )
	{
		if ( surface == DashRechargeSurface.None || _canDash ) return;
		float step = surface switch
		{
			DashRechargeSurface.Floor => MathF.Abs( moved.x ) / DASH_RECHARGE_FLOOR_DISTANCE,
			DashRechargeSurface.Wall => MathF.Abs( moved.y - wallCarryY ) / DASH_RECHARGE_WALL_DISTANCE,
			_ => MathF.Abs( moved.x ) / DASH_RECHARGE_CEILING_DISTANCE,
		};
		_dashRechargeProgress += step;
		if ( _dashRechargeProgress < 1f ) return;
		_dashRechargeProgress = 0f;
		_canDash = true;
		PlayDashRechargeEffects( surface );
		foreach ( var ability in _abilityModules )
			ability.OnDashRecharged( this );
	}

	/// <summary>Cosmetic "charge clicked back in": a small spark burst kicked off the surface the charge
	/// was earned on (up off the floor, out from the hugged wall, down off the ceiling), a bright chime
	/// and a crisp haptic tick. Cosmetic Rng only — never touches the sim stream.</summary>
	void PlayDashRechargeEffects( DashRechargeSurface surface )
	{
		Direction surfaceDir = surface switch
		{
			DashRechargeSurface.Floor => EffectiveGravityDirection,
			DashRechargeSurface.Wall => HuggingLeftWall ? Direction.Left : Direction.Right,
			_ => _gravityReversed ? Direction.Down : Direction.Up,
		};
		Vector2 toSurface = DirectionVector( surfaceDir );
		Vector2 away = -toSurface;
		Vector2 tangent = new Vector2( away.y, -away.x );
		Vector2 origin = Position + new Vector2( toSurface.x * Width * 0.5f, toSurface.y * Height * 0.5f );
		int n = Rng.CosmeticInt( 6, 10 );
		for ( int i = 0; i < n; i++ )
		{
			Stage.AddParticle(
				origin + tangent * Rng.CosmeticFloat( -3f, 3f ),
				(away * Rng.CosmeticFloat( 0.5f, 1f ) + tangent * Rng.CosmeticFloat( -0.7f, 0.7f )) * Rng.CosmeticFloat( 45f, 85f ),
				Rng.CosmeticFloat( 0.88f, 0.93f ),
				Globals.GRAVITY_STR_DUST * 0.5f,
				Rng.CosmeticInt( 0, 4 ) == 0 ? ParticleKind.DashRecharge1 : ParticleKind.DashRecharge0,
				Rng.CosmeticFloat( 0.3f, 0.5f ),
				Rng.CosmeticInt( 1, 3 ) );
		}

		Audio.PlaySfx( SfxType.DashRecharge, Position, 0.55f );
		if ( DrivesHaptics ) Haptics.Pulse( 0.3f, 0.06f, 0f, Haptics.TONE_CRISP );
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Applies the dash impulse (if armed) in the given direction and spends the charge.
	/// A blocked attempt does nothing and keeps the charge (so it can be retried).</summary>
	void FireDash( Vector2 dashDir )
	{
		if ( !_canDash ) return;

		if ( OnFloor && !CAN_DASH_ON_GROUND ) return;

		dashDir = Utils.Normalized( dashDir );

		// Don't spend the charge trying to dash farther into a surface.
		if ( dashDir.x < 0f && CollidingLeft || dashDir.x > 0f && CollidingRight
			|| dashDir.y < 0f && CollidingDown || dashDir.y > 0f && CollidingUp ) return;

		// Reward only momentum already travelling along the dash. Opposing speed is cancelled by the
		// directional reset/consolidation below rather than being converted into extra launch force.
		float entrySpeed = Math.Max( 0.0f, VelX * dashDir.x + VelY * dashDir.y );
		// A dash toward the effective gravity blends toward its own force and gravity-suppression window.
		// The blend is the squared alignment: 1 straight along gravity, exactly 0.5 on a diagonal, 0 otherwise.
		Vector2 gravityDir = DirectionVector( EffectiveGravityDirection );
		float alignment = Math.Max( 0f, dashDir.x * gravityDir.x + dashDir.y * gravityDir.y );
		float alongGravity = alignment * alignment;
		float baseForce = DASH_FORCE + (DASH_FORCE_ALONG_GRAVITY - DASH_FORCE) * alongGravity;
		float force = (baseForce + DASH_MOMENTUM_FACTOR * entrySpeed) * SolarDashFactor;

		// Standard dashes replace vertical momentum so horizontal movement stays flat. Characters that
		// preserve it instead add the dash component below, letting an up dash brake a fall progressively.
		if ( !PRESERVE_DASH_VERTICAL_VELOCITY )
		{
			VelY = 0.0f;
			ExtraVelY = 0.0f;
		}

		if ( CONSOLIDATE_DASH_HORIZONTAL_VELOCITY )
		{
			float horizontalDashDirection = MathF.Sign( dashDir.x );
			if ( (VelX + ExtraVelX) * horizontalDashDirection < 0.0f )
			{
				VelX = 0.0f;
				ExtraVelX = 0.0f;
			}
			ApplyHorizontalLaunch( MathF.Sign( dashDir.x ), Math.Abs( dashDir.x ) * force );
		}
		else
			ExtraVelX = Math.Clamp( ExtraVelX + dashDir.x * force, -MAX_EXTRA_X_SPEED, MAX_EXTRA_X_SPEED );
		ExtraVelY = Math.Clamp( ExtraVelY + dashDir.y * force, -MAX_EXTRA_Y_SPEED, MAX_EXTRA_Y_SPEED );

		// A dash fired while frozen in a wall cling overrides the freeze — dashing up/down along the
		// hugged wall is the intended escape. The consumed-cling latch stays set (CancelWallCling never
		// clears it), so the same contact can't immediately re-grab and eat the dash a tick later.
		CancelWallCling();

		_canDash = false;
		_dashRechargeProgress = 0f;
		_dashedThisTick = true;
		_dashGravityTimer = DASH_GRAVITY_SUPPRESS_TIME + (DASH_GRAVITY_SUPPRESS_TIME_ALONG_GRAVITY - DASH_GRAVITY_SUPPRESS_TIME) * alongGravity;

		// Brief hit-stop to give the dash a punchy snap.
		Stage.RequestHitStop( HIT_STOP_DASH_FRAMES );

		// A puff of dust kicked out behind the dash, plus a quick whoosh (reused jump sfx, pitched up).
		Vector2 pos = new Vector2( X - dashDir.x * 4f, Y - dashDir.y * 4f + Rng.CosmeticInt( -4, 4 ) );
		Vector2 perpendicular = new Vector2( -dashDir.y, dashDir.x );
		int n = Rng.CosmeticInt( 4, 9 );
		for ( int i = 0; i < n; i++ )
		{
			Stage.AddParticle(
				pos,
				(-dashDir + perpendicular * Rng.CosmeticFloat( -0.4f, 0.6f )) * Rng.CosmeticFloat( 40.0f, 90.0f ),
				Rng.CosmeticFloat( 0.90f, 0.95f ),
				Globals.GRAVITY_STR_DUST,
				ParticleKind.Dust,
				Rng.CosmeticFloat( 0.25f, 0.5f ),
				Rng.CosmeticInt( 2, 5 )
			);
		}

		Audio.PlaySfx( SfxType.PlayerJump, Position, 0.6f, 1.5f );
		// Punchy dash kick, biased toward the dash direction.
		if ( DrivesHaptics ) Haptics.Pulse( 0.55f, 0.09f, dashDir.x * 0.5f, Haptics.TONE_NEUTRAL );
	}


	// ----------------------------------------------------------------------------------------
	/// <summary>
	/// Wall-kick: on the rising edge of touching a side wall, opens a short window and remembers the
	/// speed we hit it at; if the OPPOSITE direction is pressed during that window, reverses (and
	/// boosts) the horizontal velocity so we rebound off the wall, leaving vertical velocity untouched
	/// and spitting out a fast dust burst. Runs mid-<see cref="Tick"/> (after the collision flags,
	/// before <see cref="AdjustXPosition"/>).
	/// </summary>
	void HandleWallKick( float dt )
	{
		if ( !_abilities.CanWallKick ) return;
		if ( IsStuck ) return; // glued to a sticky block; no rebounding off it

		// Open (or refresh) the window on the rising edge of touching a side wall. Capture the
		// horizontal speed NOW: AdjustXPosition later this tick zeroes VelX on impact, so on the
		// following window frames the impact speed would be gone. No LeftPressed/RightPressed gate —
		// the kick works whether or not we're actively hugging the wall.
		if ( CollidingLeft && !_prevCollidingLeft )
		{
			_wallKickWall = Direction.Left;
			_wallKickWallEntity = _collidingEntityLeft;
			_wallKickTimer = WALL_KICK_TIME;
			_wallKickSpeed = Math.Abs( VelX );
		}
		else if ( CollidingRight && !_prevCollidingRight )
		{
			_wallKickWall = Direction.Right;
			_wallKickWallEntity = _collidingEntityRight;
			_wallKickTimer = WALL_KICK_TIME;
			_wallKickSpeed = Math.Abs( VelX );
		}

		_prevCollidingLeft = CollidingLeft;
		_prevCollidingRight = CollidingRight;

		if ( _wallKickWall == Direction.None ) return;

		// Count the window down and expire it.
		_wallKickTimer -= dt;
		if ( _wallKickTimer <= 0.0f )
		{
			_wallKickWall = Direction.None;
			_wallKickWallEntity = null;
			_wallKickTimer = 0.0f;
			return;
		}

		// Fire when the OPPOSITE direction is pressed inside the window, provided we hit the wall hard
		// enough for the rebound to be meaningful.
		bool fire = (_wallKickWall == Direction.Left && RightJustPressed)
				 || (_wallKickWall == Direction.Right && LeftJustPressed);

		if ( !fire || _wallKickSpeed < WALL_KICK_MIN_SPEED )
			return;

		float sign = (_wallKickWall == Direction.Left) ? 1.0f : -1.0f; // kick AWAY from the wall
		float kickSpeed = _wallKickSpeed * WALL_KICK_SPEED_BONUS;

		// Reverse the horizontal speed. The within-cap part goes in VelX; any overflow goes in
		// ExtraVelX so the "bit extra" actually exceeds MAX_X_SPEED and then decays (like dash/dive).
		VelX = sign * Math.Min( kickSpeed, MAX_X_SPEED );
		float excess = kickSpeed - MAX_X_SPEED;
		ExtraVelX = (excess > 0.0f) ? sign * excess : 0.0f;

		// Vertical velocity is deliberately left untouched.

		// Brief hit-stop to give the rebound a punchy snap (same pattern as the dash / wall dive).
		Stage.RequestHitStop( HIT_STOP_WALL_KICK_FRAMES );

		AddWallKickParticles( sign );
		Audio.PlaySfx( SfxType.PlayerJump, Position, 0.6f, 1.6f );
		// Wall-kick rebound, biased away from the wall.
		if ( DrivesHaptics ) Haptics.Pulse( 0.5f, 0.08f, sign * 0.5f, Haptics.TONE_NEUTRAL );

		// Consume any open wall-jump leniency window and block it from re-arming off the wall we just
		// kicked off (CollidingLeft/Right is stale-true while we rebound away). Otherwise an UpJustPressed
		// right after the kick would also fire a wall jump off the same wall — a free double launch. Same
		// suppression the wall jump itself uses; cleared once we separate or fall (see HandleWallJumping).
		_wallJumpLeniencyCounter = 0;
		_wallDirection = Direction.None;
		_wallJumpWallEntity = null;
		_suppressWallRearm = true;

		// Kicking off the wall awards its press, same as the wall jump: the kick window opens on the
		// PROBE's rising edge, and the reversed VelX is what AdjustXPosition moves by this very tick —
		// so a kick armed a hair short of the face rebounds without ever touching it.
		PressWallLaunchSurface( _wallKickWall, _wallKickWallEntity );

		// Spend the window.
		_wallKickWall = Direction.None;
		_wallKickWallEntity = null;
		_wallKickTimer = 0.0f;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Fast dust burst kicked off the wall on a wall-kick. Spawns at our wall-side edge and
	/// fans out in the kick direction (sign = away from the wall) with a wide vertical spread, faster
	/// than normal landing/running dust to sell the snappy rebound.</summary>
	void AddWallKickParticles( float sign )
	{
		Vector2 pos = new Vector2( X - sign * (Width / 2), Y + Rng.CosmeticInt( -5, 5 ) );
		int n = Rng.CosmeticInt( 3, 6 );
		for ( int i = 0; i < n; i++ )
		{
			Stage.AddParticle(
				pos,
				// x dominant in the kick direction + a wide vertical fan; fast.
				new Vector2( sign * Rng.CosmeticFloat( 0.6f, 1.0f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 40.0f, 80.0f ),
				Rng.CosmeticFloat( 0.90f, 0.95f ),
				Globals.GRAVITY_STR_DUST,
				ParticleKind.Dust,
				Rng.CosmeticFloat( 0.30f, 0.6f ),
				Rng.CosmeticInt( 2, 5 )
			);
		}
	}


	// ----------------------------------------------------------------------------------------
	void HandleMovingPlatforms( float dt )
	{
		if ( HAS_SURFACE_GRAVITY )
		{
			HandleSurfaceGravityPlatform();
			return;
		}

		// Only a platform attachment is our business here (PlatformEntity is non-null exactly for
		// Riding/WallHug — the one definition of "platform kinds", shared with DetachPlatform).
		// Stuck/CeilingCling can't ALSO be parented — the single slot makes that structurally impossible.
		//
		// KNOWN CONSEQUENCE of the slot: if a ceiling cling steals the slot mid-ride and the ridden block
		// slams to a stop DURING the cling, its inertia-fling edge goes unobserved — releasing the cling
		// re-attaches with _attachWasStopped seeded to the already-stopped state, so no fling fires. That
		// is deliberate: you're no longer standing on the block, so you don't inherit its momentum (the
		// old parallel-state code technically banked the horizontal fling mid-cling, but the cling glue
		// swallowed the vertical one and EndCeilingCling clobbered the horizontal on release — incoherent,
		// not a feature). The cling's own release momentum comes from EndCeilingCling instead.
		if ( PlatformEntity == null ) return;
		if ( _attachEntity is Player { IsDead: true } )
		{
			Detach();
			return;
		}

		// A ridden / side-hugged Teleport block that just vanished (it is phasing in at its new spot, so it's
		// intangible) must drop us off where we are: without this the glue below would yank us to the block's
		// new location (any tick its new column overlaps us), or the inertia-fling code would launch us from
		// its banked pre-teleport velocity. Just fall.
		if ( _attachEntity is Block phasingParent && phasingParent.PhasingIn )
		{
			Detach();
			return;
		}

		// A strong gust blowing off the surface we're riding/hugging peels us off it: detach so the wind
		// carries us clear, instead of the glue below re-pinning us to the block every tick (which reads
		// as a 1px nudge that immediately snaps back). Re-parenting is likewise suppressed while the gust
		// holds (see AdjustX/AdjustYPosition), so we don't re-grab until it eases.
		if ( WindBreaksGrip( _attachFace ) )
		{
			Detach();
			return;
		}

		// REVERSE-GRAVITY UNDERSIDE RIDE (Riding with _attachFace Up): we rest against a block's UNDERSIDE
		// (our top on its bottom), the reversed analogue of standing on a block top. This branch owns the
		// face-Up kind REGARDLESS of the current gravity — it must catch the attachment the tick gravity
		// un-reverses (the field drifted off us or its block died mid-hang) and detach, because falling
		// through to the top-ride glue below would run Bottom = parent.Top on it and snap us THROUGH the
		// solid block onto its top in one tick.
		if ( _attach == Attachment.Riding && _attachFace == Direction.Up )
		{
			// Glue to the underside so the block's horizontal motion carries us, mirroring the normal
			// top-ride below — including its stale-parent re-pick, inertia fling and edge machinery,
			// each with the vertical sense inverted. Detach outright if gravity un-reverses (back to
			// normal handling — we just fall away) or the block dies/phases.
			{
				Block gonePb = _attachEntity as Block;
				if ( _attachEntity == null || (gonePb?.IsDead ?? false) || (gonePb?.PhasingIn ?? false) || !_gravityReversed )
				{
					Detach();
					return;
				}
			}

			// STALE-PARENT RE-PICK (the top-ride's FindSupportBlock, mirrored): hanging straddling two
			// undersides, ride the one actually pressing on us — the LOWEST bottom we overlap (reverse
			// gravity pins us against it; a neighbour descending past it takes over via unpenetrate).
			Entity2D support = FindCeilingSupportBlock();
			if ( support != _attachEntity )
				Attach( Attachment.Riding, support, Direction.Up );

			Entity2D parent = _attachEntity;
			Block parentBlock = parent as Block;

			// INERTIA FLING (the top-ride's, mirrored): the hung block slamming to a dead stop hands us
			// its banked travel velocity instead of halting us with it. Horizontal momentum slides us
			// along the now-stationary underside and off the leading edge; a DESCENDING block that stops
			// launches us DOWN off its underside (the reversed analogue of a riser flinging us up off its
			// top — reverse gravity then arcs us back). A rising block that stops just leaves us pressed
			// against it. Attach() seeded _attachWasStopped, so grabbing an already-stopped block can't
			// phantom-fling.
			bool parentStopped = parentBlock != null && parentBlock.IsStopped;
			if ( parentStopped && !_attachWasStopped && parentBlock.InertiaHandoffThisTick && !STICK_TO_MOVING_BLOCKS )
			{
				if ( Math.Abs( parentBlock.PreImpactVelocity.x ) > INERTIA_FLING_MIN_SPEED )
					ExtraVelX += parentBlock.PreImpactVelocity.x;

				if ( parentBlock.PreImpactVelocity.y < -INERTIA_FLING_MIN_SPEED )
				{
					VelY += parentBlock.PreImpactVelocity.y; // gravity-governed, like the top-ride's launch
					Detach();
				}
			}
			if ( _attach == Attachment.Riding ) _attachWasStopped = parentStopped;

			// EDGE-HANG detach + glue (the top-ride's, mirrored): slide ~90% past the lip and we fall,
			// carrying the block's momentum; the fully-off shove only ever pushes us further OUT.
			// Tangent-offset test, not raw X, for the same one-tick-lag reason as the top-ride's (see there).
			if ( _attach == Attachment.Riding )
			{
				if ( MathF.Abs( _attachTangentOffset ) > parent.Width * 0.5f + LedgeHangMargin )
				{
					ExtraVelX += parent.VelX; // carry the platform's horizontal momentum off the edge
					VelY += parent.VelY;       // ...and its vertical momentum (gravity-governed, like the top-ride)

					if ( Right < parent.Left )
						X = Math.Min( X, parent.Left - Width / 2 - 0.5f );
					else if ( Left > parent.Right )
						X = Math.Max( X, parent.Right + Width / 2 + 0.5f );

					Detach();
				}
				else
				{
					// Clear-space rule, mirrored from the top-ride glue: the hung block can rise through a
					// glass pane we can't follow through (or carry us sideways into one) — if the flush
					// underside spot is blocked, let go instead of snapping into the solid (the shove-out /
					// resolve-flip / residual-kill sequence described at the top-ride's check). Same
					// moved-us gate so a static-parent no-op snap can never spuriously detach.
					float glueX = parent.X + _attachTangentOffset;
					float glueY = parent.Bottom - Height / 2;
					if ( (glueX != X || glueY != Y) && !IsSpotClearOfSolids( glueX, glueY ) )
					{
						Detach();
					}
					else
					{
						Top = parent.Bottom;                 // hang from its underside
						X = glueX;                           // follow it horizontally
					}
				}
			}
			return;
		}
		// REVERSE GRAVITY peels us off a block we're STANDING ON (Riding with _attachFace Down = top-ride):
		// gravity is now pulling us UP, off the block, but the top glue below (Bottom = parent.Top) would
		// pin our POSITION to the block top every tick while ApplyGravity keeps ACCUMULATING upward VelY
		// (nothing above collides to zero it) — so we'd look stuck, drifting up and snapping back, then
		// ROCKET up the instant we slid off the edge and detached with the built-up velocity. Detaching
		// lets the reverse gravity lift us cleanly.
		if ( _gravityReversed && _attach == Attachment.Riding )
		{
			Detach();
			return;
		}

		// WE ARE ON TOP OF A MOVING PLATFORM
		if ( _attach == Attachment.Riding )
		{
			// STALE-PARENT FIX (diverges from original): we ride a single block, but we can stand
			// straddling two at once (half on each). If the OTHER block rises, the unpenetrate step
			// lifts us onto it and our feet end up above the ridden block's top — then the glue below
			// would yank us back down/over to the stale parent ("teleported to block B"). Re-pick the
			// parent as the block our feet are actually resting on (highest top we overlap) so we ride
			// whichever platform is really carrying us. Seeded from the current one so seams don't churn.
			Entity2D support = FindSupportBlock();
			if ( support != _attachEntity )
				Attach( Attachment.Riding, support, Direction.Down );

			// INERTIA FLING (diverges from original): the block we're riding can slam to a dead stop
			// the instant it collides with another block (Block.Impact sets IsStopped immediately,
			// banking its travel velocity in PreImpactVelocity). While moving it carries us along via
			// the glue below (our X tracks the block's X), but that carry vanishes the moment it stops —
			// so normally we'd just halt with it. Real momentum should keep us going the way the block
			// was travelling. Blocks only move on one axis at a time, so this is either a horizontal
			// slide or an upward launch, never both.
			Block parentBlock = _attachEntity as Block;
			bool parentStopped = parentBlock != null && parentBlock.IsStopped;
			if ( parentStopped && !_attachWasStopped && parentBlock.InertiaHandoffThisTick && !STICK_TO_MOVING_BLOCKS )
			{
				// Horizontal: hand the block's pre-stop velocity to ExtraVelX so we slide across the
				// now-stationary top (AdjustXPosition grows the glue offset) and fling off the leading
				// edge. Gated on a meaningful stop speed (see INERTIA_FLING_MIN_SPEED).
				if ( Math.Abs( parentBlock.PreImpactVelocity.x ) > INERTIA_FLING_MIN_SPEED )
				{
					// DISABLED opposing-input cancel. Our VelX is our walk speed RELATIVE to the block
					// (the glue carries us), so plain addition below is physically seamless: ground speed
					// (blockVel + VelX) is unchanged across the stop, and holding "back" genuinely
					// dampens the fling — walking 86 left on a block doing 100 right, you were only
					// really moving 14 right, and 14 is what you keep. Re-enable these lines to
					// EXAGGERATE the fling instead: zeroing the opposing walk component launches you at
					// the block's full speed even when you were leaning against it, so the mechanic
					// reads big — at the cost of a velocity discontinuity (you briefly move faster than
					// you actually were).
					//if ( Math.Sign( VelX ) == -Math.Sign( parentBlock.PreImpactVelocity.x ) )
					//	VelX = 0f;

					ExtraVelX += parentBlock.PreImpactVelocity.x;
				}

				// Upward: a block that was rising and slammed to a halt should launch us up off its
				// top (provided we have room above — if not, we just unpenetrate harmlessly). We must
				// release the parent here: the glue pins Bottom = parent.Top, which would otherwise
				// just hold us back down. Use VelY (gravity then arcs us back down) rather than
				// ExtraVelY, which doesn't decay while positive. A descending block that stops leaves
				// us simply resting on it, so only launch when it was actually moving up.
				if ( parentBlock.PreImpactVelocity.y > INERTIA_FLING_MIN_SPEED )
				{
					VelY += parentBlock.PreImpactVelocity.y;
					Detach();
				}
			}
			if ( _attach == Attachment.Riding ) _attachWasStopped = parentStopped;

			// EDGE-HANG FIX (diverges from original): the original only detached once the player had
			// walked COMPLETELY off the block (Left > parent.Right). Until then it kept gluing us to
			// the platform top (Bottom = parent.Top), so we could stand with almost our whole body
			// hanging over the lip, held in mid-air instead of falling. Detach once we're ~90% off the
			// edge (LedgeHangMargin past it) so we can still lean well over a ledge but fall before the
			// pose looks absurd. The fully-off shoves below are a subset of this (if Right < parent.Left
			// then X is too), so they still fire for the genuine "platform slid out from under us" case.
			// (Skipped when an upward launch above already released the parent.)
			// Evaluated on the TANGENT OFFSET (our glued position relative to the parent, kept current by
			// the glue + TrackRidingDisplacement), not raw X: this runs BEFORE the glue re-pins us, so raw
			// X lags the parent by one tick of its travel. A parent moving at least the lean margin per
			// tick (~120 px/s) read its trailing-edge rider as past the lip EVERY tick — release, full
			// momentum handover, re-land, repeat — stacking ExtraVelX each cycle (found via the Shifter's
			// identical release; see HandleSurfaceGravityPlatform). Same band, lag-free.
			if ( _attach == Attachment.Riding )
			{
				Entity2D parent = _attachEntity;
				if ( MathF.Abs( _attachTangentOffset ) > parent.Width * 0.5f + LedgeHangMargin )
				{
					// Carry the platform's momentum on walk-off, regardless of which way we leave —
					// stepping off a moving block should fling/drift you the way it was travelling.
					// VelY too: ride a rising/falling block off the edge and keep that vertical momentum.
					// A stopped block's Velocity is zero, so walking off one during its stopped window
					// inherits nothing — its momentum was already handed over by the inertia fling above.
					ExtraVelX += parent.VelX;
					// VelY (not ExtraVelY) for the vertical carry, matching the inertia-fling above: VelY is
					// gravity-governed (arcs back down instead of parking for the whole airtime), is clamped by
					// MAX_RISE_SPEED (so riding a fast riser off the edge can't stack rise speed past a
					// character's cap, e.g. Swapper's 165), and is what the bounce impact measurement reads (-VelY).
					VelY += parent.VelY;

					// Fully-off shove, for the "platform slid out from under us" case. Min/Max so it
					// only ever pushes us further OUT past the edge, never back toward the block —
					// leaving fast (walked off with dash/fling momentum) can put us beyond the shove
					// target on the detach tick, and snapping back would be a backward yank.
					if ( Right < parent.Left )
						X = Math.Min( X, parent.Left - Width / 2 - 0.5f );
					else if ( Left > parent.Right )
						X = Math.Max( X, parent.Right + Width / 2 + 0.5f );

					Detach();
				}
				else
				{
					// Follow the receding top only through CLEAR space (the WallHug glue's rule, applied
					// to the vertical axis): the ridden block can pass through solids we can't (a GLASS
					// pane is intangible to blocks but solid to every player), so when it descends through
					// one this raw snap would place us inside the pane — the movement pass shoves us back
					// out each tick until the snap lands deep enough that the shortest way out flips to
					// DOWNWARD, teleporting us through the pane into the block, where the next tick's
					// re-snap wedges us between pane and block top and the residual-penetration net kills
					// us. If the flush spot is blocked, the top escaped behind something we can't follow
					// through: let go and stand on the pane instead. Gated on the glue actually moving us,
					// so a static-parent no-op snap can never spuriously detach (e.g. while compressed
					// against a hardened twin, which IsSpotClearOfSolids counts as solid).
					float glueX = parent.X + _attachTangentOffset;
					float glueY = parent.Top + Height / 2;
					if ( (glueX != X || glueY != Y) && !IsSpotClearOfSolids( glueX, glueY ) )
					{
						Detach();
					}
					else
					{
						Bottom = parent.Top;
						X = glueX;
					}
				}
			}
		}
		// WE ARE HUGGING THE SIDE OF A MOVING PLATFORM
		else if ( _attach == Attachment.WallHug )
		{
			if ( !CAN_WALL_HUG )
			{
				Detach();
				return;
			}

			// GROUNDED RELEASE: a hug is only ever ACQUIRED airborne (AdjustXPosition gates on
			// !OnFloor), but one that landed used to persist — its glue dragged a standing player
			// along with a receding block at block speed, and the stop fling below could launch
			// someone with their feet planted. Standing on the surface gravity holds us against
			// (the ceiling in a reverse-gravity field — OnFloor tracks the flip) means friction
			// owns us: release the hug and let the press just be a press. Placed BEFORE the
			// destroyed-parent re-pick and the fling so neither runs for a grounded player.
			if ( OnFloor )
			{
				Detach();
				return;
			}

			// Safety net for a hugged block destroyed outside the mimic swap's same-tick handoff
			// (OnBlockReplaced re-points _attachEntity, so a Replaced parent here means a missed path):
			// re-pick the live surface actually on the hugged side; nothing live there → just let go.
			// Keyed on the sim-side Replaced flag, never engine IsValid() (frame-flush cadence).
			if ( _attachEntity is Block { Replaced: true } )
			{
				float probeX = X + (_attachFace == Direction.Right ? 1f : -1f);
				if ( IsPlayerColliding( probeX, Y, _attachFace, out Entity2D wall ) && wall is not null and not Player )
				{
					Attach( Attachment.WallHug, wall, _attachFace );
				}
				else
				{
					Detach();
					return;
				}
			}

			Entity2D huggedSurface = _attachEntity;
			float carryY = huggedSurface is Block movingBlock
				? movingBlock.StepDisplacement.y
				: huggedSurface.Y - _attachParentLastPosition.y;
			_attachParentLastPosition = huggedSurface.Position;
			_wallHugCarryY = carryY;
			_wallHugStepVelocityY = dt > 0f ? carryY / dt : huggedSurface.VelY;

			// INERTIA FLING (side): wall-riding a block's side, its horizontal motion drags us along
			// (the glue pins our Left/Right to its edge). If it slams to a halt, keep that momentum and
			// launch off the wall in the block's travel direction. We must detach — otherwise the glue
			// re-pins us to the now-stationary edge and we can't leave.
			Block parentBlock = _attachEntity as Block;
			bool parentStopped = parentBlock != null && parentBlock.IsStopped;
			bool justStopped = parentStopped && !_attachWasStopped;
			_attachWasStopped = parentStopped;

			if ( justStopped && parentBlock.InertiaHandoffThisTick && !STICK_TO_MOVING_BLOCKS
				&& Math.Abs( parentBlock.PreImpactVelocity.x ) > INERTIA_FLING_MIN_SPEED )
			{
				// Opposing-input cancel — kept ACTIVE here (unlike the top-ride fling, where it's
				// disabled): staying attached to this wall REQUIRES holding into it, so when the fling
				// points away from the wall our VelX is structurally opposed to it — not a player
				// choice, the way holding "back" on top of a block is. Without the cancel the fling
				// nets to (blockVel - 86) and a slow-ish block barely launches you off its side.
				if ( Math.Sign( VelX ) == -Math.Sign( parentBlock.PreImpactVelocity.x ) )
					VelX = 0f;

				ExtraVelX += parentBlock.PreImpactVelocity.x;
				Detach();
			}
			else if ( (Bottom + carryY > _attachEntity.Top) ||
				 (Top + carryY < _attachEntity.Bottom) ||
				 (_attachFace == Direction.Left && !LeftPressed) ||
				 (_attachFace == Direction.Right && !RightPressed) )
			{
				Detach();
			}
			else
			{
				// Follow the receding face only through CLEAR space. The hugged block can pass through
				// solids we can't (a GLASS pane is intangible to blocks but solid to every player), so
				// when it slides beyond one this raw snap would place us inside the pane: the movement
				// pass shoves us back out each tick until the snap lands too deep to resolve, and the
				// residual-penetration net kills us (unlike Riding's tangent, sideways shoves are never
				// folded back into this glue — it re-asserts the flush X absolutely every tick). If the
				// flush spot is blocked, the wall escaped behind something we can't follow through: let
				// go instead of chasing it.
				float flushX = _attachFace == Direction.Left
					? _attachEntity.Right + Width / 2
					: _attachEntity.Left - Width / 2;
				if ( MathF.Abs( flushX - X ) > WALL_HUG_MAX_FOLLOW || !IsSpotClearOfSolids( flushX, Y ) )
				{
					Detach();
				}
				else if ( _attachFace == Direction.Left )
					Left = _attachEntity.Right;
				else
					Right = _attachEntity.Left;
			}
		}
	}

	void HandleSurfaceGravityPlatform()
	{
		if ( _attach != Attachment.Riding ) return;
		if ( _attachFace != EffectiveGravityDirection || _attachEntity is not Block parent
			|| parent.Replaced || parent.IsDead || parent.PhasingIn || WindBreaksGrip( _attachFace ) )
		{
			TraceShifter( $"DETACH face-or-parent (face={_attachFace} grav={EffectiveGravityDirection} entReplaced={(_attachEntity as Block)?.Replaced})" );
			Detach();
			return;
		}

		// Blocks tick before the player. Motion INTO us is resolved by the unpenetration pass above, but
		// motion AWAY from us (or a jump / corner transition that separated us) leaves a real gap. Never
		// close that gap with riding glue: doing so teleports Shifter back to the stale parent one frame
		// after its gravity changed. The attachment is valid only while the matching faces still touch.
		Vector2 parentDisplacement = parent.Position - _attachParentLastPosition;
		_attachParentLastPosition = parent.Position;
		const float FACE_CONTACT_TOLERANCE = 1.01f;
		float normalGap = _attachFace switch
		{
			Direction.Left => Left - parent.Right,
			Direction.Right => parent.Left - Right,
			Direction.Up => parent.Bottom - Top,
			Direction.Down => Bottom - parent.Top,
			_ => float.MaxValue,
		};
		float motionAway = MathF.Max( 0f, Vector2.Dot( parentDisplacement, DirectionVector( _attachFace ) ) );
		if ( MathF.Abs( normalGap ) > FACE_CONTACT_TOLERANCE + motionAway )
		{
			TraceShifter( $"DETACH gap normalGap={normalGap:0.00} motionAway={motionAway:0.00}" );
			Detach();
			return;
		}

		bool parentStopped = parent.IsStopped;
		if ( parentStopped && !_attachWasStopped && parent.InertiaHandoffThisTick && !STICK_TO_MOVING_BLOCKS )
		{
			Vector2 gravity = DirectionVector( _attachFace );
			Vector2 tangent = new Vector2( -gravity.y, gravity.x );
			float tangentSpeed = Vector2.Dot( parent.PreImpactVelocity, tangent );
			if ( MathF.Abs( tangentSpeed ) > INERTIA_FLING_MIN_SPEED )
			{
				ExtraVelX = Math.Clamp( ExtraVelX + tangent.x * tangentSpeed, -MAX_EXTRA_X_SPEED, MAX_EXTRA_X_SPEED );
				ExtraVelY = Math.Clamp( ExtraVelY + tangent.y * tangentSpeed, -MAX_EXTRA_Y_SPEED, MAX_EXTRA_Y_SPEED );
				TraceShifter( $"FLING tangent {tangentSpeed:0.0} -> extra=({ExtraVelX:0.0},{ExtraVelY:0.0}) (parent preImpact=({parent.PreImpactVelocity.x:0.0},{parent.PreImpactVelocity.y:0.0}))" );
			}

			float launchSpeed = Vector2.Dot( parent.PreImpactVelocity, -gravity );
			if ( launchSpeed > INERTIA_FLING_MIN_SPEED )
			{
				Velocity += -gravity * launchSpeed;
				TraceShifter( $"FLING launch {launchSpeed:0.0} away from {_attachFace} -> vel=({VelX:0.0},{VelY:0.0}) + DETACH" );
				Detach();
				return;
			}
		}
		_attachWasStopped = parentStopped;

		// Evaluate the ledge release on the TANGENT OFFSET, not raw position: this runs BEFORE the glue
		// below re-pins us to the parent's fresh position, so raw X/Y lags the parent by one tick of its
		// travel. A parent moving at least the lean margin per tick (~120 px/s) read its trailing-edge
		// rider as past the lip EVERY tick — release, full parent-velocity handover, re-attach via the
		// handover's push, repeat — stacking the handover into a huge tangent skid (the "shifter rockets
		// across the lower block" replay). The offset IS the glued position relative to the parent
		// (TrackRidingDisplacement keeps it current through walking and shoves), so it has no such lag.
		bool verticalFace = _attachFace == Direction.Left || _attachFace == Direction.Right;
		float leanMargin = (LEDGE_FALL_OFF_FRACTION - 0.5f) * (verticalFace ? Height : Width);
		float parentHalfExtent = (verticalFace ? parent.Height : parent.Width) * 0.5f;
		if ( MathF.Abs( _attachTangentOffset ) > parentHalfExtent + leanMargin )
		{
			// Walk-off momentum carry, mirroring the normal path's channel split: the tangent component
			// rides ExtraVel (clamped, self-decaying — a repeat can never stack own-velocity), the
			// normal-axis component rides Velocity so gravity + the rise cap govern it.
			Vector2 gravityDir = DirectionVector( _attachFace );
			Vector2 tangentDir = new Vector2( -gravityDir.y, gravityDir.x );
			float tangentCarry = Vector2.Dot( parent.Velocity, tangentDir );
			ExtraVelX = Math.Clamp( ExtraVelX + tangentDir.x * tangentCarry, -MAX_EXTRA_X_SPEED, MAX_EXTRA_X_SPEED );
			ExtraVelY = Math.Clamp( ExtraVelY + tangentDir.y * tangentCarry, -MAX_EXTRA_Y_SPEED, MAX_EXTRA_Y_SPEED );
			Velocity += gravityDir * Vector2.Dot( parent.Velocity, gravityDir );
			TraceShifter( $"DETACH past-edge offset={_attachTangentOffset:0.00} halfExtent={parentHalfExtent:0.0}+{leanMargin:0.0}"
				+ $" carry tangent={tangentCarry:0.0} -> vel=({VelX:0.0},{VelY:0.0}) extra=({ExtraVelX:0.0},{ExtraVelY:0.0})" );
			Detach();
			return;
		}

		// Clear-space rule, mirrored from the Riding/WallHug glues: the parent can pass through a glass
		// pane the player can't, and this raw snap would chase its face straight into the pane (shove-out
		// fight, then a resolve flip through the pane, then the residual-penetration kill — see the
		// top-ride glue's check). If the flush spot is blocked, let go instead. Same moved-us gate so a
		// static-parent no-op snap can never spuriously detach.
		Vector2 glue = _attachFace switch
		{
			Direction.Left => new Vector2( parent.Right + Width / 2, parent.Y + _attachTangentOffset ),
			Direction.Right => new Vector2( parent.Left - Width / 2, parent.Y + _attachTangentOffset ),
			Direction.Up => new Vector2( parent.X + _attachTangentOffset, parent.Bottom - Height / 2 ),
			_ => new Vector2( parent.X + _attachTangentOffset, parent.Top + Height / 2 ),
		};
		if ( (glue.x != X || glue.y != Y) && !IsSpotClearOfSolids( glue.x, glue.y ) )
		{
			TraceShifter( $"DETACH glue-spot-blocked ({glue.x:0.00},{glue.y:0.00}) face={_attachFace}" );
			Detach();
			return;
		}
		X = glue.x;
		Y = glue.y;
	}

	void HandleRunningEffects( float dt, Direction movementDirection )
	{
		if ( CAN_CEILING_CLING && _attach == Attachment.CeilingCling )
		{
			HandleCeilingClimbEffects( dt, movementDirection );
			return;
		}

		Vector2 gravity = DirectionVector( EffectiveGravityDirection );
		Vector2 tangent = HAS_SURFACE_GRAVITY ? SurfaceRightVector() : new Vector2( 1f, 0f );
		float tangentSpeed = Vector2.Dot( Velocity, tangent );
		if ( !OnFloor || MathF.Abs( tangentSpeed ) <= 0f ) return;

		_runningDustTimer -= dt;
		bool activelyWalking = movementDirection != Direction.None;
		float velocityAmount = Utils.Map( tangentSpeed, -MAX_X_SPEED, MAX_X_SPEED, -1f, 1f, true, EasingType.Linear );
		if ( _runningDustTimer < 0f && activelyWalking )
		{
			Vector2 position = Position + tangent * Rng.CosmeticInt( -4, 4 ) + gravity * 4f;
			Vector2 velocity = (tangent + -gravity * Rng.CosmeticFloat( 0f, 1f ))
				* Rng.CosmeticFloat( 30.0f, 50.0f ) * velocityAmount;
			Stage.AddParticle(
				position,
				velocity,
				Rng.CosmeticFloat( 0.90f, 0.95f ),
				Globals.GRAVITY_STR_DUST,
				ParticleKind.Dust,
				Rng.CosmeticFloat( 0.25f, 0.5f ) * MathF.Abs( velocityAmount ),
				Rng.CosmeticInt( 2, 5 )
			);
			_runningDustTimer = Rng.CosmeticFloat( RUNNING_DUST_INTERVAL_MIN, RUNNING_DUST_INTERVAL_MAX );
		}

		Vector2 extraVelocity = new Vector2( ExtraVelX, ExtraVelY );
		_runningSfxAmount += MathF.Abs( Vector2.Dot( Velocity + extraVelocity, tangent ) ) * dt;
		if ( _runningSfxAmount > RUNNING_SFX_REQ && activelyWalking )
		{
			// Swarm-group bodies share one walking track (any body may carry it — see
			// TryPlayGroupFootstep); solo characters always pass.
			if ( TryPlayGroupFootstep() )
			{
				Audio.PlaySfx( Character.Audio.Footstep, Position, 0.4f, pitchScale: DashRechargeSfxPitchScale );
				// A footfall's worth of texture under the walk. Rides the group gate above, so a Swarm
				// still ticks once per step rather than once per body.
				if ( DrivesHaptics ) Haptics.Pulse( 0.1f, 0.03f, 0f, Haptics.TONE_CRISP, EasingType.Linear );
			}
			_runningSfxAmount = 0f;
		}
	}

	void HandleCeilingClimbEffects( float dt, Direction movementDirection )
	{
		float speed = MathF.Abs( VelX );
		if ( movementDirection == Direction.None || speed <= 0f ) return;

		_ceilingClimbSfxAmount += speed * dt;
		if ( _ceilingClimbSfxAmount > CEILING_CLIMB_SFX_REQ )
		{
			Audio.PlaySfx( SfxType.PlayerCeilingClimb, Position, 0.3f, pitchScale: DashRechargeSfxPitchScale );
			if ( DrivesHaptics ) Haptics.Pulse( 0.09f, 0.03f, 0f, Haptics.TONE_CRISP, EasingType.Linear );
			_ceilingClimbSfxAmount = 0f;
		}

		_ceilingClimbParticleTimer -= dt;
		if ( _ceilingClimbParticleTimer > 0f ) return;

		Vector2 gravity = DirectionVector( EffectiveGravityDirection );
		Vector2 tangent = new Vector2( -gravity.y, gravity.x );
		Vector2 position = Position - gravity * (Height * 0.5f + 1f)
			+ tangent * Rng.CosmeticInt( -4, 4 );
		Vector2 velocity = gravity * Rng.CosmeticFloat( 8f, 18f )
			- tangent * MathF.Sign( VelX ) * Rng.CosmeticFloat( 2f, 8f );
		ParticleKind kind = Rng.CosmeticValue() < 0.5f ? ParticleKind.MimicCloud0 : ParticleKind.MimicCloud1;
		Stage.AddMimicCloud( position, velocity, kind, Rng.CosmeticFloat( 0.15f, 0.3f ), Rng.CosmeticInt( 2, 4 ) );
		_ceilingClimbParticleTimer = Rng.CosmeticFloat(
			CEILING_CLIMB_PARTICLE_INTERVAL_MIN, CEILING_CLIMB_PARTICLE_INTERVAL_MAX );
	}

	// ----------------------------------------------------------------------------------------
	// Finds the block our feet are actually resting on while parented to a platform, among blocks
	// whose top sits at our feet (~1px) and that we overlap horizontally (within the ledge margin):
	//  1. highest top wins  (a block that rose into us takes over carrying us),
	//  2. tie on height -> the block our CENTRE is actually over  (so running from block A onto a
	//     level adjacent block B hands the parent over smoothly the instant our centre crosses the
	//     seam, BEFORE the off-A detach path can fire its momentum boost / edge shove and fling us),
	//  3. otherwise keep the current parent (no needless re-parenting / churn).
	// Returns the current parent if we're momentarily resting on nothing, rather than dropping it.
	Entity2D FindSupportBlock()
	{
		Entity2D best = null;
		float bestTop = 0f;
		bool bestHasCenter = false;

		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block.PhasingIn ) continue; // a phasing Teleport block is intangible to the player
			if ( Math.Abs( Bottom - block.Top ) > 1.0f ) continue;
			if ( !(X > block.Left - LedgeHangMargin && X < block.Right + LedgeHangMargin) ) continue;

			bool hasCenter = (X >= block.Left && X <= block.Right);

			bool better;
			if ( best == null ) better = true;
			else if ( block.Top > bestTop + 0.01f ) better = true;
			else if ( block.Top < bestTop - 0.01f ) better = false;
			else if ( hasCenter != bestHasCenter ) better = hasCenter;
			else better = (block == _attachEntity);

			if ( better )
			{
				best = block;
				bestTop = block.Top;
				bestHasCenter = hasCenter;
			}
		}

		return best ?? _attachEntity;
	}

	/// <summary>The underside-ride mirror of <see cref="FindSupportBlock"/>: the block whose BOTTOM our
	/// head is actually pressed against (reverse gravity pins us upward), i.e. the LOWEST bottom we
	/// overlap — hanging straddling two blocks, a neighbour descending below the ridden one takes over.
	/// Ties prefer a block whose span contains our centre, then the current parent (so seams don't
	/// churn). Falls back to the current parent when nothing is within reach.</summary>
	Entity2D FindCeilingSupportBlock()
	{
		Entity2D best = null;
		float bestBottom = 0f;
		bool bestHasCenter = false;

		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block.PhasingIn ) continue; // a phasing Teleport block is intangible to the player
			if ( Math.Abs( Top - block.Bottom ) > 1.0f ) continue;
			if ( !(X > block.Left - LedgeHangMargin && X < block.Right + LedgeHangMargin) ) continue;

			bool hasCenter = (X >= block.Left && X <= block.Right);

			bool better;
			if ( best == null ) better = true;
			else if ( block.Bottom < bestBottom - 0.01f ) better = true;  // lower underside = the one pressing on us
			else if ( block.Bottom > bestBottom + 0.01f ) better = false;
			else if ( hasCenter != bestHasCenter ) better = hasCenter;
			else better = (block == _attachEntity);

			if ( better )
			{
				best = block;
				bestBottom = block.Bottom;
				bestHasCenter = hasCenter;
			}
		}

		return best ?? _attachEntity;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Whether an interior obstacle is solid to THIS character. Normal obstacles are gated
	/// on <see cref="CharacterAbilities.CollidesWithArenaWalls"/> (a phasing/wrap character passes
	/// through them and wraps across instead); GLASS panels are solid to EVERY character — the wrap
	/// character collides with a pane, it never wraps through one.</summary>
	bool ObstacleSolidToUs( Obstacle ob ) => ob.IsGlass || _abilities.CollidesWithArenaWalls;

	// ----------------------------------------------------------------------------------------
	bool IsPlayerColliding( float x, float y, Direction direction, out Entity2D other )
	{
		other = null;
		if ( IsCollidingWithBlock( x, y, direction, out other ) )
			return true;
		// Interior obstacles are solid to the player exactly like the arena walls — gated per
		// obstacle on ObstacleSolidToUs (inside IsCollidingWith), so a phasing/wrap character passes
		// through normal ones (and wraps across, see WrapArenaEdgesAbility) but still hits GLASS.
		// Returning the obstacle as `other` lets wall-jump/wall-slide (_collidingEntityLeft/Right)
		// work against obstacle sides for free.
		if ( IsCollidingWithObstacle( x, y, direction, out other ) )
			return true;
		// A living sibling is a dynamic solid: the shared contact flags then provide landing, wall-slide,
		// wall-jump, and cling behavior without making either body an immovable crush wall.
		if ( IsCollidingWithTwin( x, y, direction, out other ) )
			return true;
		// A character that phases through arena walls (e.g. the wrap character) is never "colliding"
		// with the bounds — it passes through and wraps instead. Block collision above is unaffected.
		return _abilities.CollidesWithArenaWalls && !IsInBounds( x, y, direction );
	}

	// ----------------------------------------------------------------------------------------
	bool IsPlayerBeingCrushed( Direction direction, out Entity2D other )
	{
		RectF rect = GetRect();

		bool outOfBounds = false;
		if ( _abilities.CollidesWithArenaWalls &&
			 ((direction == Direction.Left && rect.Left <= Arena.WALL_SIZE) ||
			  (direction == Direction.Right && rect.Right >= Arena.WIDTH - Arena.WALL_SIZE) ||
			  (direction == Direction.Down && rect.Bottom <= Arena.WALL_SIZE) ||
			  (direction == Direction.Up && rect.Top >= Arena.HEIGHT - Arena.WALL_SIZE)) )
		{
			outOfBounds = true;
		}

		other = null;

		RectF band = GetCrushProbeBand( direction );

		// A single block can't crush us against itself, so ignore the block already claimed by the
		// OPPOSITE direction's pass — but ONLY that one. Excluding blocks claimed by the other axis
		// (as this used to) let a deeply-embedded block get claimed by the Left pass (which runs
		// first) and then hidden from the Up pass, permanently disabling the genuine below+above
		// crush pair once the player's head was half-buried in a block.
		Entity2D oppositeCrusher = direction switch
		{
			Direction.Left => _crushingEntityRight,
			Direction.Right => _crushingEntityLeft,
			Direction.Down => _crushingEntityUp,
			Direction.Up => _crushingEntityDown,
			_ => null,
		};

		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block.PhasingIn ) continue; // a phasing Teleport block can't crush the player
			if ( block != oppositeCrusher )
			{
				if ( block.GetRect().Intersects( band ) )
				{
					other = block;
					break;
				}
			}
		}

		// An interior obstacle in the probe band is an immovable surface on this side, exactly like the
		// arena wall (per-obstacle gate: glass is solid to everyone) — a block shoving us into it
		// crushes us. Tracked like the wall (no `other`).
		bool obstacleWall = false;
		foreach ( Obstacle ob in Stage.GetObstacles() )
		{
			if ( !ObstacleSolidToUs( ob ) ) continue;
			if ( ob.GetRect().Intersects( band ) ) { obstacleWall = true; break; }
		}
		if ( !obstacleWall )
		{
			foreach ( Player player in Stage.Players )
			{
				if ( !ReferenceEquals( player, this ) && player.IsHardened && !player.IsDead
					&& player.GetRect().Intersects( band ) )
				{
					obstacleWall = true;
					break;
				}
			}
		}

		return outOfBounds || obstacleWall || (other != null);
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>The thin rect a crush test probes with for the given direction: centred on our
	/// edge midpoint, CRUSH_PROBE_EPS thick perpendicular to the edge (straddling it) and
	/// CRUSH_PROBE_HALF_EXTENT to each side along it. Public so the debug overlay can draw it.</summary>
	public RectF GetCrushProbeBand( Direction direction )
	{
		RectF r = GetRect();
		return direction switch
		{
			Direction.Left => new RectF( r.Left - CRUSH_PROBE_EPS, Y - CRUSH_PROBE_HALF_EXTENT, r.Left + CRUSH_PROBE_EPS, Y + CRUSH_PROBE_HALF_EXTENT ),
			Direction.Right => new RectF( r.Right - CRUSH_PROBE_EPS, Y - CRUSH_PROBE_HALF_EXTENT, r.Right + CRUSH_PROBE_EPS, Y + CRUSH_PROBE_HALF_EXTENT ),
			Direction.Down => new RectF( X - CRUSH_PROBE_HALF_EXTENT, r.Bottom - CRUSH_PROBE_EPS, X + CRUSH_PROBE_HALF_EXTENT, r.Bottom + CRUSH_PROBE_EPS ),
			Direction.Up => new RectF( X - CRUSH_PROBE_HALF_EXTENT, r.Top - CRUSH_PROBE_EPS, X + CRUSH_PROBE_HALF_EXTENT, r.Top + CRUSH_PROBE_EPS ),
			_ => default,
		};
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>PINNED-RETREAT SUPPORT probe for the face-press gate in UnpenetrateFrom: is a solid
	/// flush against us on the <paramref name="retreatDir"/> side, and if so how fast can we ACTUALLY
	/// move that way? Commanded motion into a support never becomes real motion (the floor under a
	/// down-blowing wind lane absorbs the whole gust), so the press gate clamps commanded retreat to
	/// the support's own velocity along the axis. Solidity rules mirror
	/// <see cref="IsPlayerBeingCrushed"/> — same band geometry, phasing blocks don't count, walls and
	/// obstacles only bind a character that collides with them, a hardened twin is a wall — anything
	/// that could crush us on that side can pin us on that side. <paramref name="pressedBlock"/> never
	/// counts: a block can't pin us against itself.</summary>
	bool TryGetRetreatSupport( Direction retreatDir, Block pressedBlock, out float supportVel )
	{
		// Down/Left retreat is negative-axis motion, so with several supports the binding one is the
		// LARGEST velocity component (a static floor binds tighter than a sinking block); Up/Right
		// mirror to the smallest.
		bool negativeAxis = retreatDir is Direction.Down or Direction.Left;
		bool vertical = retreatDir is Direction.Down or Direction.Up;
		bool found = false;
		supportVel = negativeAxis ? float.MinValue : float.MaxValue;

		RectF band = GetCrushProbeBand( retreatDir );

		// Moving supports: another block flush behind us binds retreat to ITS motion. A stopped
		// block's Velocity is zero (see CheckForCrushing), so a resting block reads as a static wall.
		foreach ( Block b in Stage.GetBlocks() )
		{
			if ( b == pressedBlock || b.PhasingIn ) continue;
			if ( !b.GetRect().Intersects( band ) ) continue;

			float v = vertical ? b.VelY : b.VelX;
			supportVel = negativeAxis ? MathF.Max( supportVel, v ) : MathF.Min( supportVel, v );
			found = true;
		}

		// Static supports (bind retreat to zero): the arena wall behind our edge, an interior
		// obstacle, or a hardened twin.
		bool staticSolid = false;
		if ( _abilities.CollidesWithArenaWalls )
		{
			RectF rect = GetRect();
			if ( (retreatDir == Direction.Left && rect.Left <= Arena.WALL_SIZE) ||
				 (retreatDir == Direction.Right && rect.Right >= Arena.WIDTH - Arena.WALL_SIZE) ||
				 (retreatDir == Direction.Down && rect.Bottom <= Arena.WALL_SIZE) ||
				 (retreatDir == Direction.Up && rect.Top >= Arena.HEIGHT - Arena.WALL_SIZE) )
			{
				staticSolid = true;
			}

		}
		if ( !staticSolid )
		{
			foreach ( Obstacle ob in Stage.GetObstacles() )
			{
				if ( !ObstacleSolidToUs( ob ) ) continue;
				if ( ob.GetRect().Intersects( band ) ) { staticSolid = true; break; }
			}
		}
		if ( !staticSolid )
		{
			foreach ( Player player in Stage.Players )
			{
				if ( !ReferenceEquals( player, this ) && player.IsHardened && !player.IsDead
					&& player.GetRect().Intersects( band ) )
				{
					staticSolid = true;
					break;
				}
			}
		}
		if ( staticSolid )
		{
			supportVel = negativeAxis ? MathF.Max( supportVel, 0f ) : MathF.Min( supportVel, 0f );
			found = true;
		}

		return found;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>SQUEEZE-ANVIL PRESS (the pinned-retreat clamp's mirror): a resolve whose press gate
	/// passed means <paramref name="hammer"/> is driving us along <paramref name="direction"/> — and
	/// any block flush on THAT side of us is the anvil we're being ground into, so its facing side
	/// bears the same force and presses too. Without this the anvil's press hinged on resolve order:
	/// only an Unpenetrate OUT of the anvil (hammer behind us satisfying the clamp) pressed it, so a
	/// hammer that shoved us exactly flush — or whose leftover overlap fell to the press-less
	/// residual pass or the no-fit kill — left the face we were crushed into untouched. Same band
	/// geometry as <see cref="TryGetRetreatSupport"/>; the closing gate is the clamp's formula with
	/// the roles swapped (pressed = the anvil, support = the hammer): bulldozed flush against the
	/// hammer we close on the anvil at least at ITS speed, our own commanded motion notwithstanding.
	/// Only Blocks have buttons (walls, obstacles, hardened twins don't); spiked and already-pressed
	/// faces are refused by PressSide itself. No press launch is scheduled — a squeeze is not a
	/// springboard (crush-kill presses don't fling either).</summary>
	void PressSqueezeAnvils( Direction direction, Block hammer )
	{
		RectF band = GetCrushProbeBand( direction );
		Direction face = OppositeDirection( direction ); // the anvil face we're ground into points back at us

		foreach ( Block b in Stage.GetBlocks() )
		{
			if ( b == hammer || b.PhasingIn ) continue;
			if ( !b.GetRect().Intersects( band ) ) continue;

			bool closing = face switch
			{
				Direction.Left => MathF.Max( TotalVelocity.x, hammer.VelX ) - b.VelX > 0f,
				Direction.Right => MathF.Min( TotalVelocity.x, hammer.VelX ) - b.VelX < 0f,
				Direction.Down => MathF.Max( TotalVelocity.y, hammer.VelY ) - b.VelY > 0f,
				Direction.Up => MathF.Min( TotalVelocity.y, hammer.VelY ) - b.VelY < 0f,
				_ => false,
			};
			if ( !closing ) continue;

			if ( PressBlockSide( b, face ) )
				TraceCrush( $"anvil-press {face} face of block@({b.X:0.0},{b.Y:0.0}) hammer@({hammer.X:0.0},{hammer.Y:0.0})" );
		}
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>DEBUG (see <see cref="ShowCrushProbeOverlay"/>): draws the four crush probe bands
	/// as world-space rect outlines — red where that direction's Crushing flag is set, green
	/// otherwise. Called by GameManager once per rendered frame, after transform sync.</summary>
	public void DrawCrushProbeOverlay()
	{
		float z = Globals.DepthToZ( Globals.DEPTH_TEXT );

		foreach ( var dir in Globals.GetAllDirections() )
		{
			bool hit = dir switch
			{
				Direction.Left => CrushingLeft,
				Direction.Right => CrushingRight,
				Direction.Down => CrushingDown,
				Direction.Up => CrushingUp,
				_ => false,
			};

			RectF b = GetCrushProbeBand( dir );
			Gizmo.Draw.Color = hit ? Color.Red : Color.Green;
			Gizmo.Draw.Line( new Vector3( b.Left, b.Bottom, z ), new Vector3( b.Right, b.Bottom, z ) );
			Gizmo.Draw.Line( new Vector3( b.Right, b.Bottom, z ), new Vector3( b.Right, b.Top, z ) );
			Gizmo.Draw.Line( new Vector3( b.Right, b.Top, z ), new Vector3( b.Left, b.Top, z ) );
			Gizmo.Draw.Line( new Vector3( b.Left, b.Top, z ), new Vector3( b.Left, b.Bottom, z ) );
		}
	}

	// ----------------------------------------------------------------------------------------
	// Which way the last successful Unpenetrate() pushed us (i.e. which axis actually resolved).
	// AdjustX/YPosition need this to tell a genuine wall/floor hit ON their movement axis from an
	// incidental cross-axis nudge (see the SPEED-BUMP FIX comment in AdjustXPosition).
	Direction _lastUnpenetrateDir = Direction.None;
	bool _lastUnpenetrateWasSurfaceGravityLedgeDeflect;

	// Set around movement substep resolves only. Unpenetrate uses these to distinguish a gravity-driven
	// corner catch from the blind tick-start pass and deflect the body past a ledge instead of stopping it.
	Direction _horizontalMoveDir = Direction.None;
	Direction _verticalMoveDir = Direction.None;

	/// <summary>A resolve CROSS to the active movement sweep is a corner graze, not contact with the
	/// resolved face — the sweeps step over cross resolves and keep moving (the speed-bump fixes), and
	/// every corner deflect resolves cross too. Shared rule between the block press gate and the
	/// obstacle-face spike kill; sweep-less resolves (the tick-start passes, the sticky glue) are
	/// never grazes.</summary>
	bool IsCrossAxisGrazeResolve( Direction resolveDir )
		=> _horizontalMoveDir != Direction.None
			? resolveDir == Direction.Up || resolveDir == Direction.Down
			: _verticalMoveDir != Direction.None
				&& (resolveDir == Direction.Left || resolveDir == Direction.Right);

	/// <summary>The resolve we just ran KILLED this body — for real, or with the death intercepted by an
	/// ability that MOVED us somewhere else entirely (the Swarm save adopting a copy, a Rewind recovery;
	/// both leave <see cref="IsDead"/> false). Either way the sweep in flight is over: everything after a
	/// contact — the landing's velocity zeroing, dust, fall-damage read, platform parenting, wall grab —
	/// describes a body that is no longer at that contact, and stamping it onto a recovered one parented
	/// it to the very solid it died on. Its ride glue then snapped the body straight back to that face
	/// (Bottom = parent.Top, from anywhere in the arena) and the spikes killed it again for good.</summary>
	bool ResolveEndedThisBody => IsDead || _rewindDeathRecoveredThisTick;

	bool UnpenetrateFromSolid( float x, float y, out Entity2D other, out Direction resolveDir )
	{
		other = null;
		resolveDir = Direction.None;

		RectF rect = GetRect( x, y );
		if ( !ClampToBounds( x, y ) )
		{
			// Report which wall clamped us as the direction we were pushed, so callers can tell a
			// side-wall stop from a floor/ceiling clamp.
			if ( rect.Left < Arena.WALL_SIZE ) resolveDir = Direction.Right;
			else if ( rect.Right > Arena.WIDTH - Arena.WALL_SIZE ) resolveDir = Direction.Left;
			else if ( rect.Bottom < Arena.WALL_SIZE ) resolveDir = Direction.Up;
			else resolveDir = Direction.Down;
			return true;
		}

		// Interior obstacles resolve like the arena walls (gated per obstacle — glass is solid to
		// everyone, see ObstacleSolidToUs) but reuse the min-translation block unpenetration, so
		// landing on an obstacle top reports resolveDir=Up (the top face's outward normal) — the
		// AdjustYPosition path then zeroes VelY, emits dust/sfx and marks us grounded; the
		// obstacle-face spike check keys on that same normal.
		if ( UnpenetrateFromObstacle( x, y, out other ) )
		{
			resolveDir = _lastUnpenetrateDir;
			// The face we resolved against (its outward normal == resolveDir) may carry deadly spikes.
			// Deadliness keys on the body's CONTACT SPAN along the face plane, not its centre point —
			// the centre hanging past the corner no longer spares a body standing on a spiked face's
			// last pixels, and the contact-line query catches the flush NEIGHBOUR's face at a seam,
			// not just whichever rect the unpenetrate loop resolved. Passes the post-resolve rect
			// (Unpenetrate already placed us flush) so the plane match is exact. A resolve CROSS to
			// the active sweep stays harmless (the press gate's graze rule): that's slipping past the
			// face's corner — running off a safe top whose SIDE is spiked, a corner deflect nudging
			// us by — not standing on or pressing into the face; any genuine stand/press re-resolves
			// ON the gravity/movement axis every tick and is killed there. Guard on !IsDead so the
			// corner-graze step loop (which continues past a sideways resolve) can't fire the death
			// burst more than once.
			if ( !IsDead && other is Obstacle && !IsCrossAxisGrazeResolve( resolveDir )
				&& Stage.ObstacleFaceDeadlyForRect( resolveDir, GetRect() ) )
				KilledBySpikes( resolveDir );
			return true;
		}

		if ( UnpenetrateFromHardenedPlayer( x, y, out other ) )
		{
			resolveDir = _lastUnpenetrateDir;
			return true;
		}

		if ( UnpenetrateFromBlock( x, y, out other ) )
		{
			resolveDir = _lastUnpenetrateDir;
			return true;
		}

		return false;
	}

	// ----------------------------------------------------------------------------------------
	// SIDE-PROBE VERTICAL TRIM: when probing for a LEFT/RIGHT wall, ignore this fraction of the
	// player's height at EACH end (top and bottom). Two problems with probing the full rect:
	// a corner graze — the player's very bottom edge against a block's very top edge (or head vs
	// bottom) — counted as a wall grab despite barely looking like contact; and any hairline
	// embedding in the block UNDERFOOT (glue float round-trip, a rising block catching our feet)
	// made the floor read as a side wall. A real wall press overlaps most of our body, so trimming
	// the ends only drops the corner cases. Flag probes only — physical collision (Unpenetrate /
	// AdjustX/YPosition) still uses the full rect, so this never lets us clip into anything.
	public const float WALL_PROBE_END_TRIM_FRAC = 0.10f;

	/// <summary>The probe rect the collision FLAGS use at (x,y): the full rect for Up/Down, vertically
	/// end-trimmed for Left/Right (see <see cref="WALL_PROBE_END_TRIM_FRAC"/>).</summary>
	RectF GetFlagProbeRect( float x, float y, Direction direction )
	{
		RectF r = GetRect( x, y );
		if ( direction == Direction.Left || direction == Direction.Right )
		{
			float trim = Height * WALL_PROBE_END_TRIM_FRAC;
			r.Bottom += trim;
			r.Top -= trim;
		}
		return r;
	}

	bool IsCollidingWithBlock( float x, float y, Direction direction, out Entity2D other )
		=> IsCollidingWith( Stage.GetBlocks(), x, y, direction, out other );

	bool IsCollidingWithTwin( float x, float y, Direction direction, out Entity2D other )
		=> IsCollidingWith( Stage.Players, x, y, direction, out other );

	/// <summary>Shared per-direction collision probe for both the moving blocks and the static interior
	/// obstacles and living sibling bodies: skip this body, dead siblings, and anything
	/// on the far side of us — it can't collide as we move <paramref name="direction"/> — then test the
	/// end-trimmed probe rect against each candidate.</summary>
	bool IsCollidingWith<T>( List<T> others, float x, float y, Direction direction, out Entity2D other ) where T : Entity2D
	{
		other = null;
		RectF probe = GetFlagProbeRect( x, y, direction );

		foreach ( T e in others )
		{
			if ( ReferenceEquals( e, this ) || e is Player { IsDead: true } ) continue;
			if ( e is Block pcb && pcb.PhasingIn ) continue; // a phasing Teleport block is intangible to the player
			if ( e is Obstacle o && !ObstacleSolidToUs( o ) ) continue; // wall-phasing char; glass stays solid
			if ( direction == Direction.Left && e.X > X ||
				 direction == Direction.Right && e.X < X ||
				 direction == Direction.Down && e.Y > Y ||
				 direction == Direction.Up && e.Y < Y )
			{
				continue;
			}

			if ( probe.Intersects( e.GetRect() ) )
			{
				other = e;
				return true;
			}
		}

		return false;
	}

	// ----------------------------------------------------------------------------------------
	bool UnpenetrateFromBlock( float x, float y, out Entity2D other )
	{
		other = null;

		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block.PhasingIn ) continue; // a phasing Teleport block is intangible to the player
			if ( Unpenetrate( x, y, block ) )
			{
				other = block;
				return true;
			}
		}

		return false;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Interior-obstacle counterpart of <see cref="IsCollidingWithBlock"/> (same per-direction
	/// filter). Per-obstacle solidity via <see cref="ObstacleSolidToUs"/> (applied inside
	/// <see cref="IsCollidingWith{T}"/>): a wall-phasing character skips normal obstacles but still
	/// collides with GLASS.</summary>
	bool IsCollidingWithObstacle( float x, float y, Direction direction, out Entity2D other )
		=> IsCollidingWith( Stage.GetObstacles(), x, y, direction, out other );

	// ----------------------------------------------------------------------------------------
	bool UnpenetrateFromObstacle( float x, float y, out Entity2D other )
	{
		other = null;

		foreach ( Obstacle ob in Stage.GetObstacles() )
		{
			if ( !ObstacleSolidToUs( ob ) ) continue;
			if ( Unpenetrate( x, y, ob ) )
			{
				other = ob;
				return true;
			}
		}

		return false;
	}

	bool UnpenetrateFromHardenedPlayer( float x, float y, out Entity2D other )
	{
		other = null;
		foreach ( Player player in Stage.Players )
		{
			if ( ReferenceEquals( player, this ) || player.IsDead || !player.IsHardened ) continue;
			if ( Unpenetrate( x, y, player ) )
			{
				other = player;
				return true;
			}
		}
		return false;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>
	/// Safety net behind the blind per-block unpenetrate pass in <see cref="Tick"/>. That pass applies
	/// each block's minimum-translation push-out WITHOUT checking the destination, so a block grinding
	/// us into a gap we don't fit (e.g. its underside passing 8px above the block we stand on while the
	/// arena wall blocks the shove) just oscillates us between the two solids and parks us overlapped —
	/// and the crush probes stay blind the whole time, because the crusher only clips the outer sliver
	/// of our body, outside their deliberately-narrow bands (their leniency assumes a sliver can always
	/// be shoved out sideways). So: still meaningfully embedded after the pass -> try every nearby
	/// push-out of every static solid we overlap, validated against static geometry; sibling occupancy
	/// is handled separately by GameStage's nonlethal pair solver. Push-outs are near-limited to each
	/// solid's nearest face plus, for a moving solid, its velocity-aligned faces (see
	/// RESIDUAL_SQUEEZE_MAX_DIST) — without that, a pinch whose top seam sat level with our head
	/// (a block pinning us against an obstacle lip, or two blocks closing on us) would "rescue" us
	/// a full body-length up instead of crushing. If no candidate is free
	/// there is genuinely nowhere to unpenetrate to, and the player is crushed.
	/// </summary>
	void ResolveResidualPenetrationOrDie()
	{
		if ( !TryResolveResidualPenetration( out bool deepestHorizontal ) )
			KilledByCrushing( deepestHorizontal );
	}

	/// <summary>Core of <see cref="ResolveResidualPenetrationOrDie"/>, without the kill: returns false
	/// when meaningfully embedded with no validated push-out (the caller decides whether that is a
	/// crush or a refused move — see <see cref="AdoptSurfaceGravity"/>'s rotation revert), true when
	/// nothing needed resolving or a validated push-out was applied. <paramref name="deepestHorizontal"/>
	/// reports the deepest penetration's axis for the squish animation.</summary>
	bool TryResolveResidualPenetration( out bool deepestHorizontal )
	{
		bool collidesWalls = _abilities.CollidesWithArenaWalls;

		// Judge from the bounds-clamped position (pure geometry — the real ClampToBounds, with its
		// spiked-wall kill, still runs at its usual call sites): the blind pass happily pushes us
		// THROUGH an arena wall, and the question that matters is whether we fit back inside it.
		float px = X, py = Y;
		if ( collidesWalls )
		{
			RectF r = GetRect( px, py );
			if ( r.Left < Arena.WALL_SIZE ) px += Arena.WALL_SIZE - r.Left;
			else if ( r.Right > Arena.WIDTH - Arena.WALL_SIZE ) px -= r.Right - (Arena.WIDTH - Arena.WALL_SIZE);
			if ( r.Bottom < Arena.WALL_SIZE ) py += Arena.WALL_SIZE - r.Bottom;
			else if ( r.Top > Arena.HEIGHT - Arena.WALL_SIZE ) py -= r.Top - (Arena.HEIGHT - Arena.WALL_SIZE);
		}

		RectF at = GetRect( px, py );
		List<(RectF rect, Vector2 vel)> penetrating = null;
		float deepest = 0f;
		bool deepestH = false; // local because a local function can't write an out param
		deepestHorizontal = false;

		// vel = the solid's current motion — zero for obstacles, statues, and resting blocks (a
		// stopped block's Velocity reads exactly zero; ImpactEffects banks the travel velocity in
		// PreImpactVelocity). The candidate loop grants the full rescue cap to faces pointing along
		// this velocity ("get out of the crusher's way") and squeeze-caps the rest.
		void Consider( RectF b, Vector2 vel )
		{
			if ( !at.Intersects( b ) ) return;
			float minH = Math.Min( at.Right - b.Left, b.Right - at.Left );
			float minV = Math.Min( at.Top - b.Bottom, b.Top - at.Bottom );
			float depth = Math.Min( minH, minV );
			if ( depth <= RESIDUAL_PEN_EPS ) return; // float-noise / sub-pixel lip: the normal machinery absorbs it
			penetrating ??= new List<(RectF, Vector2)>();
			penetrating.Add( (b, vel) );
			if ( depth > deepest ) { deepest = depth; deepestH = minH <= minV; }
		}

		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block.PhasingIn ) continue; // a phasing Teleport block is intangible to the player
			Consider( block.GetRect(), block.Velocity );
		}
		foreach ( Obstacle ob in Stage.GetObstacles() )
		{
			if ( !ObstacleSolidToUs( ob ) ) continue;
			Consider( ob.GetRect(), Vector2.Zero );
		}
		foreach ( Player player in Stage.Players )
			if ( !ReferenceEquals( player, this ) && !player.IsDead && player.IsHardened )
				Consider( player.GetRect(), Vector2.Zero );
		deepestHorizontal = deepestH;

		if ( penetrating == null )
			return true;

		float bestDist = float.MaxValue;
		float bestX = 0f, bestY = 0f;
		List<string> trace = _crushTrace ? new List<string>() : null;

		void TryCandidate( float cx, float cy, float maxDist, string face )
		{
			// Chebyshev distance from where we actually ARE (not the clamped probe), so the cap bounds
			// the visible snap the rescue can produce.
			float dist = Math.Max( Math.Abs( cx - X ), Math.Abs( cy - Y ) );
			trace?.Add( $"{face}=>({cx:0.00},{cy:0.00}) dist={dist:0.00} cap={maxDist:0} "
				+ (dist > maxDist ? "OVER-CAP" : !IsSpotClearOfSolids( cx, cy ) ? "BLOCKED" : dist >= bestDist ? "beaten" : "ok") );
			if ( dist > maxDist || dist >= bestDist ) return;
			if ( !IsSpotClearOfSolids( cx, cy ) ) return;
			bestDist = dist;
			bestX = cx;
			bestY = cy;
		}

		foreach ( (RectF b, Vector2 vel) in penetrating )
		{
			// Push-out magnitude of each face, from the (bounds-clamped) probe rect. Two kinds of
			// face rescue at the full cap: the solid's nearest face (the way we came in — the true
			// minimum translation), and a moving solid's faces pointing along its velocity — being
			// spat out AHEAD of a crusher is the designed bulldoze. Everything else (a static
			// solid's far faces, a mover's perpendicular or trailing faces) is a squeeze-out and
			// gets only the small allowance, so a pinch can't be cheated by hopping a
			// near-body-length over an obstacle lip or up out of the seam of two closing blocks
			// (see the constants block).
			float outLeft = at.Right - b.Left;
			float outRight = b.Right - at.Left;
			float outDown = at.Top - b.Bottom;
			float outUp = b.Top - at.Bottom;
			float minOut = Math.Min( Math.Min( outLeft, outRight ), Math.Min( outDown, outUp ) );
			float CapFor( float faceOut, float alignedVel ) => faceOut <= minOut || alignedVel > 0f
				? RESIDUAL_RESOLVE_MAX_DIST : RESIDUAL_SQUEEZE_MAX_DIST;

			trace?.Add( $"solid ({b.Left:0.0},{b.Bottom:0.0})-({b.Right:0.0},{b.Top:0.0}) vel=({vel.x:0.0},{vel.y:0.0})"
				+ $" out L={outLeft:0.00} R={outRight:0.00} D={outDown:0.00} U={outUp:0.00}" );
			TryCandidate( b.Left - Width / 2f, py, CapFor( outLeft, -vel.x ), "left" );    // pushed out of its left face
			TryCandidate( b.Right + Width / 2f, py, CapFor( outRight, vel.x ), "right" );  // ... right face
			TryCandidate( px, b.Bottom - Height / 2f, CapFor( outDown, -vel.y ), "down" ); // ... bottom face
			TryCandidate( px, b.Top + Height / 2f, CapFor( outUp, vel.y ), "up" );      // ... top face
		}

		if ( trace != null )
		{
			string header = $"residual pos=({X:0.00},{Y:0.00}) clamped=({px:0.00},{py:0.00}) vel=({VelX:0.0},{VelY:0.0})"
				+ $" totalVel=({TotalVelocity.x:0.0},{TotalVelocity.y:0.0}) deepest={deepest:0.00} h={deepestHorizontal}";
			TraceCrush( bestDist < float.MaxValue
				? $"{header} RESCUE to ({bestX:0.00},{bestY:0.00})"
				: $"{header} NO-FIT (no candidate fits)" );
			foreach ( string line in trace )
				TraceCrush( "  " + line );
		}

		if ( bestDist < float.MaxValue )
		{
			float prevX = X;
			float prevY = Y;
			X = bestX;
			Y = bestY;
			// Mirror Unpenetrate's sideways-shove bookkeeping so a riding attachment doesn't yank us back.
			TrackRidingDisplacement( X - prevX, Y - prevY );
		}

		return bestDist < float.MaxValue;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Whether our rect at (x, y) — shrunk by a hair so flush contact doesn't count — is inside
	/// the arena and clear of every static solid. Living siblings are intentionally excluded: they yield
	/// when possible and compress harmlessly when trapped. Pure geometry, no side effects; validates the rescue
	/// candidates in <see cref="ResolveResidualPenetrationOrDie"/>.</summary>
	bool IsSpotClearOfSolids( float x, float y )
	{
		const float SHRINK = 0.05f; // flush placements (e.g. x = b.Left - Width/2) can land an ULP inside
		RectF r = GetRect( x, y );
		r.Left += SHRINK;
		r.Right -= SHRINK;
		r.Bottom += SHRINK;
		r.Top -= SHRINK;
		return IsRectClearOfSolids( r );
	}

	bool IsRectClearOfSolids( RectF r )
	{
		if ( _abilities.CollidesWithArenaWalls )
		{
			if ( r.Left < Arena.WALL_SIZE || r.Right > Arena.WIDTH - Arena.WALL_SIZE ||
				 r.Bottom < Arena.WALL_SIZE || r.Top > Arena.HEIGHT - Arena.WALL_SIZE )
				return false;
		}

		foreach ( Obstacle ob in Stage.GetObstacles() )
		{
			if ( !ObstacleSolidToUs( ob ) ) continue;
			if ( r.Intersects( ob.GetRect() ) )
				return false;
		}

		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block.PhasingIn ) continue; // a phasing Teleport block is intangible to the player
			if ( r.Intersects( block.GetRect() ) )
				return false;
		}
		foreach ( Player player in Stage.Players )
		{
			if ( ReferenceEquals( player, this ) || player.IsDead || !player.IsHardened ) continue;
			if ( r.Intersects( player.GetRect() ) ) return false;
		}
		return true;
	}

	/// <summary>True if any live block's face with this outward normal (Up or Down — the ceiling
	/// cling's grip planes) lies flush against the rect's matching side and overlaps its extent
	/// along the face. The Block mirror of <see cref="GameStage.ObstacleFaceDeadlyForRect"/>: same
	/// plane epsilon, same strict-ends rule (zero tangent overlap is BESIDE the face, not on it),
	/// and checked against EVERY block rather than whichever one a contact probe happens to return
	/// first — a body straddling two flush block faces must not survive a spiked neighbour by
	/// block-iteration order.</summary>
	bool BlockFaceDeadlyForRect( Direction normal, RectF rect )
	{
		const float PLANE_EPS = 0.01f; // flush placements are float-exact; distinct planes sit whole pixels apart
		float contact = normal == Direction.Down ? rect.Top : rect.Bottom;
		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block.IsDead || block.PhasingIn || !block.SideDeadly( normal ) ) continue;
			RectF b = block.GetRect();
			float plane = normal == Direction.Down ? b.Bottom : b.Top;
			if ( plane < contact - PLANE_EPS || plane > contact + PLANE_EPS ) continue;
			if ( rect.Right > b.Left && rect.Left < b.Right ) return true;
		}
		return false;
	}

	/// <summary>Whether the pair solver may displace this body by <paramref name="displacement"/>: the
	/// target spot must be clear of static solids, and the displacement must not fight attachment glue.
	/// Each glue re-asserts the axis perpendicular to its face every tick (Riding's Bottom = parent.Top,
	/// WallHug's flush X, the ceiling cling's hold) and the sticky glue re-derives BOTH axes (flush
	/// normal + tangent offset), so a component on a glued axis would be yanked back next tick and
	/// recreate the overlap as a shove/yank fight — refuse it so the solver corrects the free body
	/// instead (or leaves the pair compressed, the designed trapped fallback). Riding's tangent IS
	/// accepted: <see cref="ApplyTwinResolution"/> folds it into the attachment offset
	/// (<see cref="TrackRidingDisplacement"/>), exactly like walking or being shoved along the platform;
	/// WallHug's tangent (vertical) is never glued at all.</summary>
	internal bool CanAcceptTwinResolution( Vector2 displacement )
	{
		if ( IsHardened || IsTwinDashing || IsSquashed ) return false;
		if ( _mantleHanging && displacement != Vector2.Zero ) return false;

		bool fightsGlue = _attach switch
		{
			Attachment.Stuck => displacement.x != 0f || displacement.y != 0f,
			Attachment.Riding or Attachment.CeilingCling => displacement.y != 0f,
			Attachment.WallHug => displacement.x != 0f,
			_ => false,
		};
		if ( fightsGlue ) return false;

		Vector2 target = Pos + displacement;
		return IsSpotClearOfSolids( target.x, target.y );
	}

	internal void ApplyTwinResolution( Vector2 displacement )
	{
		Pos += displacement;
		TrackRidingDisplacement( displacement.x, displacement.y );
	}

	internal void StopMotionIntoTwin( Vector2 outward )
	{
		EndGrappleReleaseMomentum( horizontalContact: outward.x != 0f, verticalContact: outward.y != 0f );

		if ( outward.x < 0f )
		{
			if ( VelX > 0f ) VelX = 0f;
			if ( ExtraVelX > 0f ) ExtraVelX = 0f;
		}
		else if ( outward.x > 0f )
		{
			if ( VelX < 0f ) VelX = 0f;
			if ( ExtraVelX < 0f ) ExtraVelX = 0f;
		}

		if ( outward.y < 0f )
		{
			if ( VelY > 0f ) VelY = 0f;
			if ( ExtraVelY > 0f ) ExtraVelY = 0f;
		}
		else if ( outward.y > 0f )
		{
			if ( VelY < 0f ) VelY = 0f;
			if ( ExtraVelY < 0f ) ExtraVelY = 0f;
		}
	}

	/// <summary>Re-sample the contact flags after the pair solver corrected this body, so landing /
	/// wall / ceiling state reflects the resolved position. Called ONLY for the members of a corrected
	/// pair: the flags are otherwise a start-of-tick probe that must survive to the next tick —
	/// <see cref="_onFloorLastTick"/>'s fall-damage gate and the pre-refresh readers (e.g. HandleDash)
	/// depend on that timing, so this must never run for an untouched body.</summary>
	internal void RefreshTwinResolvedContacts()
	{
		if ( IsDead ) return;
		const float threshold = 1f;
		CollidingLeft = IsPlayerColliding( X - threshold, Y, Direction.Left, out _collidingEntityLeft );
		CollidingRight = IsPlayerColliding( X + threshold, Y, Direction.Right, out _collidingEntityRight );
		CollidingDown = IsPlayerColliding( X, Y - threshold, Direction.Down, out _collidingEntityDown );
		CollidingUp = IsPlayerColliding( X, Y + threshold, Direction.Up, out _collidingEntityUp );
	}

	// ----------------------------------------------------------------------------------------
	void AdjustXPosition( float moveAmount )
	{
		Direction moveDirection = moveAmount < 0.0f ? Direction.Left : Direction.Right;

		while ( Math.Abs( moveAmount ) > 0 )
		{
			float currentMoveAmount = (moveAmount > 0) ? Math.Min( moveAmount, 1.0f ) : Math.Max( moveAmount, -1.0f );

			Entity2D other;
			_horizontalMoveDir = moveDirection;
			bool hitSolid = UnpenetrateFromSolid( X + currentMoveAmount, Y, out other, out Direction resolveDir );
			_horizontalMoveDir = Direction.None;
			if ( ResolveEndedThisBody ) return; // the resolve landed us on a spiked face and killed us
			if ( hitSolid )
			{
				if ( HAS_SURFACE_GRAVITY && resolveDir != Direction.None
					&& !_lastUnpenetrateWasSurfaceGravityLedgeDeflect )
					RecordSurfaceGravityContact( OppositeDirection( resolveDir ), other );
				// SPEED-BUMP FIX: only a resolution ON OUR MOVEMENT AXIS is a wall. A vertical
				// resolution here is a sub-pixel/corner graze — e.g. riding a vertically-moving block,
				// the glue's Bottom = parent.Top float round-trip can land one ULP inside it (the same
				// class of error the crush probes' epsilon absorbs), or a moving block's top sits a
				// hair above the neighbour we're running off. Treating that as a wall zeroed VelX +
				// ExtraVelX (a ~0.25s re-accelerate "speed bump", plus a phantom wall-thud sfx at full
				// run). Instead take the micro step up/down the resolution already applied and keep
				// moving. Only sub-pixel lips resolve vertically (a taller ledge has more vertical
				// than horizontal overlap after a <=1px step, so it still resolves as a wall).
				if ( resolveDir == Direction.Up || resolveDir == Direction.Down )
				{
					// Same second-overlap re-probe as AdjustYPosition's cross-axis branch (see there): a
					// vertical micro-nudge can leave the genuine wall at this probe unresolved on the
					// final substep — re-probe once and treat an on-axis remainder as the wall it is.
					// Sweep marker kept active for the re-probe, same reason as there.
					_horizontalMoveDir = moveDirection;
					bool secondHit = UnpenetrateFromSolid( X + currentMoveAmount, Y, out Entity2D second, out Direction secondDir );
					_horizontalMoveDir = Direction.None;
					if ( ResolveEndedThisBody ) return;
					if ( secondHit && secondDir is Direction.Left or Direction.Right )
					{
						if ( HAS_SURFACE_GRAVITY && !_lastUnpenetrateWasSurfaceGravityLedgeDeflect )
							RecordSurfaceGravityContact( OppositeDirection( secondDir ), second );
						other = second;
						resolveDir = secondDir;
					}
					else
					{
						X += currentMoveAmount;

						TrackRidingDisplacement( currentMoveAmount, 0f );

						moveAmount -= currentMoveAmount;
						continue;
					}
				}

				// TRAILING PUSH-OUT, NOT A WALL: a resolve pointing ALONG the sweep means the probe was
				// unpenetrated FORWARD out of a solid we were (sub-residual-EPS) embedded in behind the
				// move — there is nothing ahead to stop against. Treating it as a wall stop zeroed VelX
				// mid-air AND ran the wall grab below, attaching a WallHug to the TRAILING solid with the
				// LEADING face; the glue's flush snap then teleported us to that solid's far side in one
				// tick (the glass-pillars witch report: ground a hair into a glass pillar, the block
				// recedes, the rightward sweep gets a Right push-out from the pillar on our LEFT, attaches
				// face-Right to it, and the glue snaps Right = pillar.Left — through 15px of glass). The
				// resolve already carried us at least the substep forward; consume it and keep moving.
				if ( resolveDir == moveDirection )
				{
					moveAmount -= currentMoveAmount;
					continue;
				}

				EndGrappleReleaseMomentum( horizontalContact: true, verticalContact: false );

				if ( Math.Abs( VelX ) > MAX_X_SPEED * 0.95f )
				{
					float vol = Utils.Map( Math.Abs( VelX ), MAX_X_SPEED * 0.95f, MAX_X_SPEED, 0.5f, 1f, true, EasingType.SineEaseIn );
					if ( Character.Audio.PlayLandSfx ) Audio.PlaySfx( Character.Audio.Land, Position, vol );
					// Short, ultra-quick crisp tick on a hard sideways wall-stop.
					if ( DrivesHaptics ) Haptics.Pulse( vol, 0.045f, 0f, Haptics.TONE_CRISP, EasingType.ExpoEaseOut );
				}

				// Wall pogo: rebound off the wall (reverse + scale VelX) instead of stopping dead. Off by
				// default; gated on a minimum speed so a crawl into a wall doesn't jitter. Skips the moving-
				// parent grip below since we bounced away rather than clinging. Yields to the toward-wall
				// hold, exactly as the ground bounce yields to the toward-floor hold: holding into the wall
				// we hit means "grip it", so we stop and hug instead, and a charge-jumper's wall wind-up
				// can arm off that hug next tick. (The ceiling bounce needs no such yield — the cling grab
				// in ApplyGravity catches the same impact later this tick while Up is held.)
				bool holdingIntoWall = moveDirection == Direction.Left ? LeftPressed : RightPressed;
				if ( AUTO_BOUNCE_WALL && !holdingIntoWall && Math.Abs( VelX ) > AUTO_BOUNCE_WALL_MIN_SPEED )
				{
					float impactSpeed = Math.Abs( VelX ); // capture before we reverse VelX below
					float away = (VelX > 0f) ? -1f : 1f; // rebound away from the wall we hit
					// Reversed speed, floored to the minimum rebound strength. Overflow past the walk cap
					// rides ExtraVelX (decays like a dash) so a strong floor actually launches.
					float mag = Math.Max( impactSpeed * AUTO_BOUNCE_WALL_RESTITUTION, AUTO_BOUNCE_WALL_MIN_STRENGTH );
					VelX = away * Math.Min( mag, MAX_X_SPEED );
					ExtraVelX = (mag > MAX_X_SPEED) ? away * (mag - MAX_X_SPEED) : 0.0f;
					moveAmount = 0.0f;
					AddWallBounceParticles( away, impactSpeed );
					break;
				}

				// Block-press launch is applied at end of tick (see _pressLaunchDir / ApplyPressLaunch),
				// not here, because this VelX=0 would wipe it.
				VelX = 0.0f;
				ExtraVelX = 0.0f;
				moveAmount = 0.0f;

				// CHECK TO SEE IF WE'RE HUGGING A MOVING PARENT (IF WE'RE NOT ALREADY ATTACHED TO ANYTHING —
				// a ceiling cling / sticky grip owns us and must not be silently replaced by a side grab)
				if ( CAN_WALL_HUG && !HAS_SURFACE_GRAVITY && _attach == Attachment.Free && !OnFloor && !_blinkPreparing )
				{
					// Don't re-grip a block's side while a strong gust is blowing us off it.
					if ( other is not null and not Player && !WindBreaksGrip( moveDirection ) )
					{
						Attach( Attachment.WallHug, other, moveDirection );
						ExtraVelX = 0.0f;
					}
				}

				break;
			}
			else
			{
				X += currentMoveAmount;

				TrackRidingDisplacement( currentMoveAmount, 0f );

				moveAmount -= currentMoveAmount;
			}
		}
	}

	// ----------------------------------------------------------------------------------------
	void AdjustYPosition( float moveAmount )
	{
		Direction moveDirection = moveAmount < 0.0f ? Direction.Down : Direction.Up;

		while ( Math.Abs( moveAmount ) > 0 )
		{
			float currentMoveAmount = (moveAmount > 0) ? Math.Min( moveAmount, 1.0f ) : Math.Max( moveAmount, -1.0f );

			Entity2D other;
			_verticalMoveDir = moveDirection;
			bool hitSolid = UnpenetrateFromSolid( X, Y + currentMoveAmount, out other, out Direction resolveDir );
			_verticalMoveDir = Direction.None;
			if ( ResolveEndedThisBody ) return; // the resolve landed us on a spiked face and killed us
			if ( hitSolid )
			{
				if ( HAS_SURFACE_GRAVITY && resolveDir != Direction.None )
					RecordSurfaceGravityContact( OppositeDirection( resolveDir ), other );
				// SPEED-BUMP FIX (vertical twin of AdjustXPosition's): a HORIZONTAL resolution while
				// moving vertically is a corner graze / one-ULP side contact being pushed out sideways,
				// not a floor or ceiling. Treating it as a landing zeroed VelY mid-air (a hitch in the
				// jump/fall, plus phantom landing dust/sfx and a wrong platform-parenting). Take the
				// sideways nudge and keep moving vertically.
				if ( resolveDir == Direction.Left || resolveDir == Direction.Right )
				{
					// One resolve per UnpenetrateFromSolid call, so a cross-axis graze can leave a SECOND
					// overlap at this probe unresolved. Normally the next substep absorbs it, but the
					// grounded gravity hair is a single sub-pixel substep — so a rider deflected sideways
					// off a ceiling sliver every tick never reached its floor's resolve: no landing, VelY
					// never zeroed, and the accumulated hair sank it visibly into its own block within
					// ~7 ticks. Re-probe once from the deflected position: what remains resolving ON-AXIS
					// is the genuine floor/ceiling — fall through and land on it properly. The sweep
					// marker stays active for the re-probe — it's the same substep, and without it a
					// cross resolve here reads as sweep-less (never a graze): a spiked side face grazed
					// on the way past would kill, a grazed block face would press.
					_verticalMoveDir = moveDirection;
					bool secondHit = UnpenetrateFromSolid( X, Y + currentMoveAmount, out Entity2D second, out Direction secondDir );
					_verticalMoveDir = Direction.None;
					if ( ResolveEndedThisBody ) return;
					if ( secondHit && secondDir is Direction.Up or Direction.Down )
					{
						if ( HAS_SURFACE_GRAVITY && secondDir != Direction.None )
							RecordSurfaceGravityContact( OppositeDirection( secondDir ), second );
						other = second;
						resolveDir = secondDir;
					}
					else
					{
						Y += currentMoveAmount;
						TrackRidingDisplacement( 0f, currentMoveAmount );
						moveAmount -= currentMoveAmount;
						if ( IsLedgeGrabFallDirection( moveDirection ) && TryStartMantleHang() )
							break;
						continue;
					}
				}

				// TRAILING PUSH-OUT, NOT A FLOOR/CEILING (the vertical twin of AdjustXPosition's check):
				// a resolve pointing ALONG the sweep is a push FORWARD out of a solid we were embedded in
				// behind the move — a genuine landing resolves OPPOSITE the fall and a genuine bonk
				// opposite the rise. Treating it as a contact faked a landing on a solid ABOVE a faller
				// (whose Riding glue would then snap Bottom = parent.Top through it — the same far-side
				// teleport as the wall-hug case) or a ceiling bonk on a solid UNDER a riser (phantom
				// _ceilingHitThisTick, wrong cling engagement). Consume the substep and keep moving.
				if ( resolveDir == moveDirection )
				{
					moveAmount -= currentMoveAmount;
					continue;
				}

				float grappleImpactY = _grappleReleaseVelocity.y;
				EndGrappleReleaseMomentum( horizontalContact: false, verticalContact: true );

				// Moving into the GRAVITY-FRAME-overhead surface = a genuine ceiling contact (this same
				// resolve presses the contacted face). Up normally; DOWN in a reverse-gravity field, where
				// jumping downward into a block's topside is the ceiling bonk. Flag it so ceiling-cling
				// engages only on real contact, never on the proximity probe short of the block.
				if ( moveDirection == (GravitySign < 0f ? Direction.Down : Direction.Up) )
					_ceilingHitThisTick = true;

				// BOUNCE: holding Down on a downward impact converts the impact speed into an upward
				// bounce (diverges from original). Detected up-front so the springy bounce sfx below
				// can LAYER on top of the normal landing thud, the upward velocity is kept instead of
				// zeroed, and we emit a bigger/faster perpendicular dust burst + shake the block we
				// hit — all scaled by how fast we were falling.
				// GravitySign makes bounce + landing work in a reverse-gravity field: "toward the floor" is
				// DOWN normally but UP when reversed (the ceiling is the floor). We measure the impact speed
				// INTO that surface (positive on a real slam) and launch the bounce back AWAY from it — both
				// sign-flipped. DownPressed already reads the "toward-floor" key (swapped when reversed).
				float gSign = GravitySign;
				float impactSpeed = -VelY * gSign; // speed INTO the surface gravity holds us against (>0 on impact)
				bool towardFloor = moveDirection == (gSign > 0f ? Direction.Down : Direction.Up);
				// Fall damage uses actual closing speed at contact, including all force channels, relative
				// to the contacted surface. A late upward wind gust therefore brakes a dangerous fall,
				// while landing on an upward-moving block is correctly harsher than matching its descent.
				// Gated on having been AIRBORNE last tick: a hair of gravity re-lands us every tick while
				// grounded (see hardLanding below), so without the gate the commanded channel sum kills a
				// player merely standing still under a strong downward field (a magnet beam integrates to
				// 400 px/s) or riding atop a block that slams upward — no actual fall in either case.
				float totalVerticalSpeed = VelY + ExtraVelY + grappleImpactY
					+ FieldVelocity.y + _shockwaveVel.y;
				float surfaceVerticalSpeed = other?.Velocity.y ?? 0.0f;
				float relativeImpactSpeed = -(totalVerticalSpeed - surfaceVerticalSpeed) * gSign;
				if ( towardFloor && !_onFloorLastTick && FALL_DAMAGE_IMPACT_SPEED > 0.0f && relativeImpactSpeed >= FALL_DAMAGE_IMPACT_SPEED )
				{
					TraceCrush( $"KILL fall-damage impact={relativeImpactSpeed:0.0} pos=({X:0.00},{Y:0.00})" );
					KilledByCrushing( horizontal: false );
					return;
				}
				bool manualBounce = _abilities.CanBounce
					&& DownPressed && towardFloor && impactSpeed > BOUNCE_MIN_SPEED;
				// Automatic ground bounces yield to the toward-floor hold. A charge-jumper can use that
				// held landing to begin winding up on this impact frame (see HandleChargeJump).
				bool autoBounce = AUTO_BOUNCE_GROUND
					&& !DownPressed && towardFloor && impactSpeed > AUTO_BOUNCE_GROUND_MIN_SPEED;
				bool bouncing = manualBounce || autoBounce;

				// impactSpeed>25 = a hard landing thud; impactSpeed<-1 = a gentle contact moving off the surface
				// (reproduces the old VelY<-25 / VelY>1 split in normal gravity, mirrored when reversed).
				if ( impactSpeed > 25f || impactSpeed < -1f )
				{
					float vol = Utils.Map( Math.Abs( VelY ), 0f, 100f, 0f, 1f, true, EasingType.SineEaseIn );
					if ( Character.Audio.PlayLandSfx ) Audio.PlaySfx( Character.Audio.Land, Position, vol );
					// Short, ultra-quick crisp tick on landing (layers under the bounce rumble below).
					if ( DrivesHaptics ) Haptics.Pulse( vol, 0.045f, 0f, Haptics.TONE_CRISP, EasingType.ExpoEaseOut );
				}

				if ( bouncing )
				{
					float impactFeedbackFactor = autoBounce
						? Math.Max( 0f, AUTO_BOUNCE_GROUND_IMPACT_FEEDBACK_FACTOR )
						: 1f;
					if ( autoBounce )
						_autoBounceCompressionFrames = Math.Max( 0, AUTO_BOUNCE_GROUND_COMPRESSION_FRAMES );
					AddBounceParticles( impactSpeed );

					// Shake the landed-on block INTO the floor (down normally, up when reversed), proportional to
					// impact speed (ground = no block).
					if ( other is Block hitBlock )
						hitBlock.AddShake( new Vector2( 0f, -impactSpeed * BOUNCE_SHAKE_STRENGTH * gSign ) );

					Stage.AddVerticalScreenshake( impactSpeed * impactFeedbackFactor );

					// Brief hit-stop, but only on a genuinely hard slam (well above the bounce threshold),
					// scaled by how hard we hit. Soft bounces stay snappy with no freeze.
					if ( impactSpeed > HIT_STOP_BOUNCE_MIN_SPEED )
					{
						int hitStopFrames = (int)MathF.Round( Utils.Map( impactSpeed, HIT_STOP_BOUNCE_MIN_SPEED, 400f, HIT_STOP_BOUNCE_MIN_FRAMES, HIT_STOP_BOUNCE_MAX_FRAMES, true, EasingType.Linear ) * impactFeedbackFactor );
						if ( hitStopFrames > 0 ) Stage.RequestHitStop( hitStopFrames );
					}

					// Springy bounce sfx: reuse the fireball "boing" (a real bounce sound), made LOUDER
					// and HIGHER-pitched the faster we hit so a big slam reads as a punchy, high spring
					// and a soft tap stays gentle. amt = 0..1 over the usable impact range drives both;
					// pitch stays >1 throughout to keep it clearly distinct from the dull landing thud.
					float amt = Utils.Map( impactSpeed, BOUNCE_MIN_SPEED, 400f, 0f, 1f, true, EasingType.Linear );
					float bounceVol = Utils.Map( amt, 0f, 1f, 0.7f, 1.15f, true, EasingType.Linear );
					float bouncePitch = Utils.Map( amt, 0f, 1f, 1.2f, 1.75f, true, EasingType.Linear );
					Audio.PlaySfx( SfxType.FireballBounce, Position, bounceVol, bouncePitch );
					// Springy bounce: centred, heavy low-frequency rumble scaled by how fast we hit.
					if ( DrivesHaptics )
						Haptics.Pulse( Utils.Map( amt, 0f, 1f, 0.4f, 1f, true, EasingType.Linear ), 0.16f, 0f, Haptics.TONE_HEAVY );

					VelY = gSign * impactSpeed * (manualBounce ? BOUNCE_RESTITUTION * SolarBounceFactor : AUTO_BOUNCE_GROUND_RESTITUTION); // launch AWAY from the floor
					// Auto-bounce floor: a weak auto ground bounce is boosted up to the minimum strength (magnitude).
					if ( !manualBounce && AUTO_BOUNCE_GROUND_MIN_STRENGTH > 0f )
						VelY = gSign * Math.Max( gSign * VelY, AUTO_BOUNCE_GROUND_MIN_STRENGTH );
					ExtraVelY = 0.0f;
					moveAmount = 0.0f;

					if ( other is Block bouncedBlock )
						foreach ( var ability in _abilityModules )
							ability.OnBouncedOnBlock( this, bouncedBlock );

					// Bouncing off a surface re-arms the dash (counts as "bounced on the ground").
					_canDash = true;

					// Don't parent to the surface — we're leaving it immediately.
					break;
				}

				// CEILING BOUNCE: an upward (away-from-floor) impact into an overhead surface reflects the
				// rise back into a fall instead of dead-stopping against it (the vertical twin of the wall
				// pogo above). !towardFloor + a vertical contact (side grazes already `continue`d) = a real
				// ceiling hit; `VelY * gSign` is the speed INTO that overhead surface (positive on the hit),
				// and the reflection `-gSign * impact * restitution` sends us back toward our floor — both
				// gravity-sign aware, so it also bounces off the "floor above" in a reverse-gravity field.
				if ( !towardFloor && AUTO_BOUNCE_CEILING )
				{
					float ceilingImpact = VelY * gSign; // speed away from the floor, into the ceiling
					if ( ceilingImpact > AUTO_BOUNCE_CEILING_MIN_SPEED )
					{
						AddBounceParticles( ceilingImpact );
						if ( other is Block hitCeil )
							hitCeil.AddShake( new Vector2( 0f, ceilingImpact * BOUNCE_SHAKE_STRENGTH * gSign ) );
						Stage.AddVerticalScreenshake( ceilingImpact );

						float amt = Utils.Map( ceilingImpact, BOUNCE_MIN_SPEED, 400f, 0f, 1f, true, EasingType.Linear );
						float bounceVol = Utils.Map( amt, 0f, 1f, 0.7f, 1.15f, true, EasingType.Linear );
						float bouncePitch = Utils.Map( amt, 0f, 1f, 1.2f, 1.75f, true, EasingType.Linear );
						Audio.PlaySfx( SfxType.FireballBounce, Position, bounceVol, bouncePitch );
						if ( DrivesHaptics )
							Haptics.Pulse( Utils.Map( amt, 0f, 1f, 0.4f, 1f, true, EasingType.Linear ), 0.16f, 0f, Haptics.TONE_HEAVY );

						VelY = -gSign * ceilingImpact * AUTO_BOUNCE_CEILING_RESTITUTION; // reflect back toward the floor
						ExtraVelY = 0.0f;
						moveAmount = 0.0f;
						break;
					}
				}

				// A genuine landing means we came down with real speed. This is FALSE for the hair of
				// gravity that re-lands us every tick while idling on the ground (VelY ≈ -5), so effects
				// keyed on it only fire on an actual touchdown, not continuously while grounded.
				bool hardLanding = impactSpeed > 30f;

				// ADD LANDING DUST PARTICLES
				if ( hardLanding )
				{
					float amt = Utils.Map( impactSpeed, 10f, 400f, 0.0f, 1.0f, true, EasingType.Linear );
					int numParticles = (int)MathF.Floor( amt * 12f );
					float velAdd = amt * 40f;

					Vector2 pos = new Vector2( X + Rng.CosmeticInt( -4, 4 ), Y - 4 * gSign );
					for ( int i = 0; i < numParticles; i++ )
					{
						Stage.AddParticle(
							pos,
							new Vector2( Rng.CosmeticFloat( -1f, 1f ) * 2f, Rng.CosmeticFloat( -1f, 1f ) * 0.15f ) * Rng.CosmeticFloat( 0f + velAdd, 30f + velAdd ),
							Rng.CosmeticFloat( 0.90f, 0.95f ),
							Globals.GRAVITY_STR_DUST,
							ParticleKind.Dust,
							Rng.CosmeticFloat( 0.25f, 0.5f ),
							Rng.CosmeticInt( 2, 5 )
						);
					}

					float landingScreenshakeAmt = Math.Abs( VelY ) * Utils.Map( amt, 0.0f, 1.0f, 0.0f, 1.0f, true, EasingType.SineEaseIn );
					Stage.AddVerticalScreenshake( landingScreenshakeAmt );
				}

				VelY = 0.0f;
				ExtraVelY = 0.0f;
				moveAmount = 0.0f;

				// Landing momentum retention: bleed horizontal speed on a genuine touchdown (1 = keep all
				// = original, so a no-op by default). Gated on hardLanding so idling on the ground — where a
				// hair of gravity re-lands us every tick — doesn't continuously drain speed.
				if ( hardLanding && LANDING_MOMENTUM_RETENTION < 1.0f )
				{
					VelX *= LANDING_MOMENTUM_RETENTION;
					ExtraVelX *= LANDING_MOMENTUM_RETENTION;
				}

				// CHECK TO SEE IF WE'VE LANDED ON A MOVING PLATFORM (IF WE'RE NOT ALREADY ON ONE).
				// Gated on NOT reverse gravity: descending onto a block top there is a head-bonk (gravity
				// pulls up, off the block), not a landing — attaching parented us as a top-ride for one tick
				// before HandleMovingPlatforms' reverse-gravity peel detached it again, pure churn.
				// Surface gravity owns its floor attachment (TryAttachSurfaceGravityFloor, with stricter
				// guards): this resolve is a landing only when Down IS the Shifter's gravity — and then the
				// same contact is recorded and attached by the surface pass — otherwise it's a tangential
				// graze and a face-Down ride would be the same one-tick churn the reverse-gravity gate stops.
				if ( !HAS_SURFACE_GRAVITY && _attach == Attachment.Free && !_blinkPreparing && !_gravityReversed && moveDirection == Direction.Down && !WindBreaksGrip( Direction.Down ) )
				{
					// Don't ride a block that's ALSO reading as our side wall (the shared floor-vs-wall
					// predicates, negated: a hairline-embedded contact claimed by a side probe isn't a
					// clean top to stand on).
					if ( other is not null and not Player && !LeftIsFloor && !RightIsFloor )
					{
						Attach( Attachment.Riding, other, Direction.Down );
						ExtraVelY = 0.0f;
					}
				}
				// REVERSE-GRAVITY UNDERSIDE RIDE: rising into a block's underside is the reversed analogue
				// of landing on a block top. Parent to it (face Up) so we ride its horizontal motion, just
				// like standing does normally. Only a real (moving) block — the static arena ceiling has no
				// motion to follow — and, mirroring the landing attach above, not a block that's ALSO
				// reading as our side wall (Left/RightIsFloor compare against the ceiling contact while
				// reversed, so they're the same hairline-embed guard here). Keyed on moveDirection Up — we
				// ROSE into the underside; the min-translation resolve itself pushed us back DOWN (a side
				// graze already `continue`d above).
				// (Also skipped for surface gravity, same as the landing attach above: a Shifter in a reverse
				// field is REPELLED from a freshly adopted underside, so this ride would detach next tick.)
				else if ( !HAS_SURFACE_GRAVITY && _attach == Attachment.Free && !_blinkPreparing && _gravityReversed && moveDirection == Direction.Up && !WindBreaksGrip( Direction.Up ) )
				{
					if( other is not null and not Player && !LeftIsFloor && !RightIsFloor )
					{
						Attach( Attachment.Riding, other, Direction.Up );
						ExtraVelY = 0.0f;
					}
				}

				break;
			}
			else
			{
				Y += currentMoveAmount;
				TrackRidingDisplacement( 0f, currentMoveAmount );
				moveAmount -= currentMoveAmount;
				if ( IsLedgeGrabFallDirection( moveDirection ) && TryStartMantleHang() )
					break;
			}
		}
	}

	// ----------------------------------------------------------------------------------------
	bool TryStartMantleHang()
	{
		if ( !CAN_MANTLE || HAS_SURFACE_GRAVITY || _mantleHanging || _mantleRegrabFrames > 0 ) return false;
		if ( _directionalInputConsumed ) return false; // another ability owns locomotion this tick
		if ( OnFloor || _groundPounding || _chargingJump || _blinkPreparing ) return false;
		if ( _attach is not (Attachment.Free or Attachment.WallHug) ) return false;
		float verticalSpeed = VelY + ExtraVelY + _grappleReleaseVelocity.y
			+ FieldVelocity.y + _shockwaveVel.y;
		if ( verticalSpeed * GravitySign >= -CORNER_DEFLECT_MIN_FALL_SPEED ) return false;
		if ( HasMantleBreakingForce() ) return false;

		Direction first = _facing == Direction.Left ? Direction.Left : Direction.Right;
		if ( TryStartMantleHangOnSide( first ) ) return true;
		return TryStartMantleHangOnSide( OppositeDirection( first ) );
	}

	bool TryStartMantleHangOnSide( Direction side )
	{
		Direction away = side == Direction.Left ? Direction.Right : Direction.Left;
		if ( RawDirectionHeld( away ) ) return false;

		float probeX = X + (side == Direction.Left ? -1f : 1f);
		RectF probe = GetFlagProbeRect( probeX, Y, side );
		// A side probe can touch several stacked solids. The first wall hit may have a buried
		// corner below a thin glass cap, so keep searching for an exposed, reachable edge.
		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block is BlockSticky ) continue;
			if ( TryStartMantleHangOnSurface( block, side, probe ) ) return true;
		}
		foreach ( Obstacle obstacle in Stage.GetObstacles() )
		{
			if ( !ObstacleSolidToUs( obstacle ) ) continue;
			if ( TryStartMantleHangOnSurface( obstacle, side, probe ) ) return true;
		}
		return false;
	}

	bool TryStartMantleHangOnSurface( Entity2D surface, Direction side, RectF probe )
	{
		if ( side == Direction.Left && surface.X > X || side == Direction.Right && surface.X < X ) return false;
		if ( !probe.Intersects( surface.GetRect() ) ) return false;
		if ( !MantleSurfaceValid( surface ) ) return false;

		Direction gravityDirection = EffectiveGravityDirection;
		float edgeY = gravityDirection == Direction.Up ? surface.Bottom : surface.Top;
		float handY = gravityDirection == Direction.Up ? Bottom + MANTLE_HAND_INSET : Top - MANTLE_HAND_INSET;
		if ( Math.Abs( handY - edgeY ) > MANTLE_CORNER_TOLERANCE ) return false;

		Vector2 hang = GetMantleHangPoint( surface, side, gravityDirection );
		if ( Math.Abs( X - hang.x ) > MANTLE_CORNER_TOLERANCE ) return false;
		if ( !MantleSideIsSafe( surface, side, hang ) ) return false;
		if ( !IsMantlePathClear( Pos, hang ) ) return false;

		Detach();
		Pos = hang;
		Velocity = Vector2.Zero;
		TraceCrush( $"mantle-hang start side={side} at ({hang.x:0.00},{hang.y:0.00})" );
		_mantleHanging = true;
		_mantleSurface = surface;
		_mantleSide = side;
		_mantleGravityDirection = gravityDirection;
		_mantleUpArmed = !UpPressed;
		_mantleGunnerReloadArmed = CrouchHeld;
		_facing = side;
		if ( Character.Audio.PlayLandSfx ) Audio.PlaySfx( Character.Audio.Land, Position, 0.45f, 1.25f );

		// The ledge owns the next Up press; no pre-grab coyote or wall window may leak through it.
		_groundedLeniencyCounter = 0;
		_wallJumpLeniencyCounter = 0;
		_wallDirection = Direction.None;
		_wallJumpWallEntity = null;
		_jumpBufferCounter = 0;
		return true;
	}

	bool HandleMantleHang()
	{
		if ( !CAN_MANTLE || HAS_SURFACE_GRAVITY || _mantleGravityDirection != EffectiveGravityDirection
			|| !MantleSurfaceValid( _mantleSurface )
			|| Velocity.Length > MANTLE_FORCE_EPSILON || HasMantleBreakingForce() )
		{
			CancelMantleHang( applyCooldown: true );
			return false;
		}

		Vector2 hang = GetMantleHangPoint( _mantleSurface, _mantleSide, _mantleGravityDirection );
		if ( !MantleSideIsSafe( _mantleSurface, _mantleSide, hang ) || !IsMantlePathClear( Pos, hang ) )
		{
			CancelMantleHang( applyCooldown: true );
			return false;
		}

		Pos = hang;
		Velocity = Vector2.Zero;

		Direction away = _mantleSide == Direction.Left ? Direction.Right : Direction.Left;
		if ( DownJustPressed || RawDirectionJustPressed( away ) )
		{
			CancelMantleHang( applyCooldown: true );
			return false;
		}

		if ( !UpPressed ) _mantleUpArmed = true;
		if ( !_mantleUpArmed || !UpJustPressed ) return true;

		float jumpSign = _mantleGravityDirection == Direction.Up ? -1f : 1f;
		VelY = jumpSign * JUMP_POWER * LEDGE_GRAB_JUMP_POWER_FACTOR;
		if ( _mantleSurface.VelY * jumpSign > 0f )
			VelY += _mantleSurface.VelY;
		_jumpedThisTick = true;
		_jumpCutArmed = true;
		CancelMantleHang( applyCooldown: true );
		Audio.PlaySfx( Character.Audio.Jump, Position, 0.70f );
		if ( DrivesHaptics ) Haptics.Pulse( 0.3f, 0.06f, 0f, Haptics.TONE_NEUTRAL );
		return true;
	}

	void FinishMantleTick( float dt )
	{
		UpdateAnimation( Direction.None );
		_xDirection = Direction.None;
		foreach ( var ability in _abilityModules )
			ability.PostTick( this, dt );
	}

	void CancelMantleHang( bool applyCooldown )
	{
		_mantleHanging = false;
		_mantleSurface = null;
		_mantleSide = Direction.None;
		_mantleGravityDirection = Direction.None;
		_mantleUpArmed = false;
		_mantleGunnerReloadArmed = false;
		if ( applyCooldown ) _mantleRegrabFrames = MANTLE_REGRAB_COOLDOWN_FRAMES;
	}

	bool HasMantleBreakingForce()
	{
		return Math.Abs( ExtraVelX ) > MANTLE_FORCE_EPSILON
			|| Math.Abs( ExtraVelY ) > MANTLE_FORCE_EPSILON
			|| _grappleReleaseVelocity.Length > MANTLE_FORCE_EPSILON
			|| _windVel.Length > MANTLE_FORCE_EPSILON
			|| _magnetVel.Length > MANTLE_FORCE_EPSILON
			|| _shockwaveVel.Length > MANTLE_FORCE_EPSILON
			|| _sirenVel.Length > MANTLE_FORCE_EPSILON
			|| _stasisLevel > 0;
	}

	bool MantleSurfaceValid( Entity2D surface )
	{
		return surface switch
		{
			Block block => !block.IsDead && !block.PhasingIn && Stage.GetBlocks().Contains( block ),
			Obstacle obstacle => Stage.GetObstacles().Contains( obstacle ),
			_ => false,
		};
	}

	bool IsLedgeGrabFallDirection( Direction moveDirection )
		=> !HAS_SURFACE_GRAVITY && moveDirection == EffectiveGravityDirection;

	Vector2 GetMantleHangPoint( Entity2D surface, Direction side, Direction gravityDirection )
	{
		float outsideX = side == Direction.Right ? surface.Left - Width / 2f : surface.Right + Width / 2f;
		float edgeY = gravityDirection == Direction.Up ? surface.Bottom : surface.Top;
		float hangY = edgeY + (gravityDirection == Direction.Up ? 1f : -1f) * (Height / 2f - MANTLE_HAND_INSET);
		return new Vector2( outsideX, hangY );
	}

	bool MantleSideIsSafe( Entity2D surface, Direction side, Vector2 hang )
	{
		if ( !IsMantleCornerExposed( surface, side ) ) return false;

		Direction sideFace = OppositeDirection( side );
		return surface switch
		{
			Block block => !block.SideDeadly( sideFace ),
			// Contact-span deadliness (like the resolve kills): any part of the hanging body against
			// a spiked stretch of the face plane refuses the grab, not just the centre's segment.
			Obstacle => !Stage.ObstacleFaceDeadlyForRect( sideFace, GetRect( hang.x, hang.y ) ),
			_ => false,
		};
	}

	bool IsMantleCornerExposed( Entity2D surface, Direction side )
	{
		// The body hangs outside the wall, so its clearance alone accepts buried corners in
		// stacked/overlapping solids. Require a pixel of grip inside the edge, with room for
		// the hands above it (below under reversed gravity). Touching the support is allowed.
		float gripWidth = Math.Min( 1f, surface.Width );
		float left = side == Direction.Right ? surface.Left : surface.Right - gripWidth;
		float edgeY = EffectiveGravityDirection == Direction.Up ? surface.Bottom : surface.Top;
		float bottom = EffectiveGravityDirection == Direction.Up ? edgeY - MANTLE_HAND_INSET : edgeY;
		return IsRectClearOfSolids( new RectF( left, bottom, left + gripWidth, bottom + MANTLE_HAND_INSET ) );
	}

	bool IsMantlePathClear( Vector2 from, Vector2 to )
	{
		Vector2 delta = to - from;
		int steps = Math.Max( 1, (int)MathF.Ceiling( Math.Max( Math.Abs( delta.x ), Math.Abs( delta.y ) ) ) );
		for ( int step = 1; step <= steps; step++ )
		{
			float amount = step / (float)steps;
			Vector2 point = from + delta * amount;
			if ( !IsMantleSpotClear( point.x, point.y ) ) return false;
		}
		return true;
	}

	bool IsMantleSpotClear( float x, float y )
	{
		if ( !IsSpotClearOfSolids( x, y ) ) return false;

		RectF body = GetRect( x, y );
		body.Left += 0.05f;
		body.Right -= 0.05f;
		body.Bottom += 0.05f;
		body.Top -= 0.05f;
		foreach ( Player player in Stage.Players )
		{
			if ( ReferenceEquals( player, this ) || player.IsDead ) continue;
			if ( body.Intersects( player.GetRect() ) ) return false;
		}
		return true;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Bounce landing dust: a bigger, faster burst than a normal landing, spreading mostly
	/// horizontally (perpendicular to the vertical impact). Count and speed both scale with how fast
	/// we hit the ground.</summary>
	void AddBounceParticles( float impactSpeed )
	{
		// 0 at the min bounce speed, ramping up past it; drives both particle count and outward speed.
		float amt = Utils.Map( impactSpeed, BOUNCE_MIN_SPEED, 400f, 0.0f, 1.0f, true, EasingType.Linear );
		int numParticles = (int)MathF.Floor( 6f + amt * 22f );
		float velAdd = 30f + amt * 90f;

		Vector2 pos = new Vector2( X + Rng.CosmeticInt( -4, 4 ), Y - 4 * GravitySign );
		for ( int i = 0; i < numParticles; i++ )
		{
			Stage.AddParticle(
				pos,
				// x dominant (*2), y nearly flat (*0.15) => perpendicular spread along the ground.
				new Vector2( Rng.CosmeticFloat( -1f, 1f ) * 2f, Rng.CosmeticFloat( -1f, 1f ) * 0.15f ) * Rng.CosmeticFloat( velAdd * 0.5f, velAdd ),
				Rng.CosmeticFloat( 0.90f, 0.95f ),
				Globals.GRAVITY_STR_DUST,
				ParticleKind.Dust,
				Rng.CosmeticFloat( 0.30f, 0.6f ),
				Rng.CosmeticInt( 2, 5 )
			);
		}
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>A small dust puff (1–2 clouds) off a WALL auto-bounce, spread mostly VERTICALLY
	/// (perpendicular to the horizontal impact) and pushed away from the wall we hit (<paramref name="away"/>
	/// = the rebound direction, ±1). Deliberately lighter than <see cref="AddBounceParticles"/> — a wall pogo
	/// is a lighter tap than a ground slam. Speed scales with the impact; cosmetic RNG only (no sim effect).</summary>
	void AddWallBounceParticles( float away, float impactSpeed )
	{
		float amt = Utils.Map( impactSpeed, AUTO_BOUNCE_WALL_MIN_SPEED, 400f, 0.0f, 1.0f, true, EasingType.Linear );
		float velAdd = 25f + amt * 55f;

		// Spawn at the contact edge (the wall is on the -away side of us), vertically jittered.
		Vector2 pos = new Vector2( X - away * 4f, Y + Rng.CosmeticInt( -4, 4 ) );
		int n = Rng.CosmeticInt( 1, 3 ); // 1–2 clouds
		for ( int i = 0; i < n; i++ )
		{
			Stage.AddParticle(
				pos,
				// y dominant (*2) => spread up/down along the wall; x pushes out away from it.
				new Vector2( Rng.CosmeticFloat( 0f, 1f ) * away, Rng.CosmeticFloat( -1f, 1f ) * 2f ) * Rng.CosmeticFloat( velAdd * 0.5f, velAdd ),
				Rng.CosmeticFloat( 0.90f, 0.95f ),
				Globals.GRAVITY_STR_DUST,
				ParticleKind.Dust,
				Rng.CosmeticFloat( 0.30f, 0.6f ),
				Rng.CosmeticInt( 2, 5 )
			);
		}
	}

	// ----------------------------------------------------------------------------------------
	void ApplyGravity( float dt )
	{
		float wallSurfaceVelocityY = WallHugSurfaceVelocityY;

		// The cling freeze re-decides its force-hold every tick (integration runs BEFORE this function,
		// so it reads the previous tick's decision). Cleared up front so every non-freeze path — stasis,
		// charge windows, an expired or broken cling — releases the external channels again.
		_wallClingHold = false;

		// Both gravity-suppression windows are sampled and advanced UP FRONT, ahead of every early return
		// below. Their only job is to time out, so a tick that leaves this function through another path
		// (a stasis trail, a charge launch) still has to burn its share of the window — parking the dash
		// timer inside stasis goo froze the hang and handed out a fresh FULL flat window on the way out.
		// Each is sampled BEFORE its decrement, so the tick that drains a timer still counts as suppressed.
		bool suppressChargeWallGravity = _chargeWallJumpGravityTimer > 0.0f;
		if ( suppressChargeWallGravity )
			_chargeWallJumpGravityTimer = Math.Max( 0f, _chargeWallJumpGravityTimer - dt );

		// Brief post-dash hang: gravity is skipped for a short window so the dash stays flat (the actual
		// skip is the coast return further down, after ceiling-cling has had its turn).
		bool dashGravitySuppressed = _dashGravityTimer > 0.0f;
		if ( dashGravitySuppressed )
		{
			_dashGravityTimer = Math.Max( 0f, _dashGravityTimer - dt );

			// COAST CEILING GRACE. The impact that pins us to a ceiling mid-dash fires _ceilingHitThisTick
			// exactly ONCE: the resolve zeroes VelY and ExtraVelY, and no gravity re-presses us into the
			// surface, so the player then hangs there dead still for the rest of the flat window with
			// nothing left to grab. A crisp double-tap RELEASES Up before we ever reach the ceiling, so the
			// natural "tap tap, then hold Up to hang" input arrived a tick or two late and found nothing.
			// Latch the impact for as long as the coast holds us against that surface so the late press
			// still catches it. This does NOT weaken the genuine-contact rule the flag exists for (no
			// latching onto proximity short of the block): we only extend an impact that actually happened,
			// only while the gravity-frame ceiling contact stays live, and the grab still re-probes before it plants. Cleared the
			// moment the window ends — the grace lasts exactly as long as the coast keeps us flush.
			if ( _ceilingHitThisTick )
				_dashCoastCeilingHit = true;      // the impact tick itself (the contact flag is still the pre-move sample)
			else if ( !(_gravityReversed ? CollidingDown : CollidingUp) )
				_dashCoastCeilingHit = false;     // drifted off the gravity-frame ceiling — nothing to grab any more
		}
		else
			_dashCoastCeilingHit = false;

		// Stasis trail: replace normal gravity with a stasis field. A very tiny gravity gives a slow,
		// heavy sink, and a viscous damping bleeds the player's velocity toward zero so they're "held" —
		// thicker (more frozen) at phase 2. Weakened input (see StasisControl in HandleHorizontalInput)
		// still lets them push against it, so the controls never feel dead.
		//
		// The damping rule is INERTIA vs FIELDS: it soaks every momentum the player is coasting on —
		// their own VelX/VelY, the ExtraVel dash/fling channel, and the shockwave knockback (a one-shot
		// impulse that coasts after the hit; the ring doesn't keep pushing) — a dash and a shockwave
		// fling crossing the same wake must slow the same way, and the goo doubles as soft cover against
		// a ring. SUSTAINED fields (a fed wind lane, a magnet beam, a siren song) are NOT damped — the
		// feeding block tops the channel back up every tick, so an exponential damp on the channel is a
		// near no-op — but their shove is resisted where it's spent instead: the channels' contribution
		// to motion is scaled by StasisFieldFactor at integration (see FieldVelocity), so a field pushes
		// a held player at reduced strength and regains its full push the instant the trail is left.
		// Swarm repel (pure depenetration keeping overlapping swarm bodies readable) and block
		// unpenetration are left at full strength: stasis holds the PLAYER, not the world.
		//
		// The whole effect SCALES by the cell's fade strength (_stasisStrength): held near full for most of
		// the trail, then eased to nothing at the fading tail. Gravity blends from the tiny stasis factor
		// back toward normal, and the damping rate toward zero, as strength drops. At (near) zero strength we
		// fall through to the normal gravity path below, so the tail is a true no-op.
		if ( _stasisLevel >= 1 && _stasisStrength > 0.001f )
		{
			float s = _stasisStrength;
			float baseFactor = _stasisLevel >= 2 ? STASIS_GRAVITY_FACTOR_P2 : STASIS_GRAVITY_FACTOR_P1;
			// GravitySign keeps the stasis sink pointed at the CURRENT floor: overlapping a reverse-gravity
			// field, the slow heavy sink drifts UP toward the ceiling instead of pulling down as if gravity
			// were normal. A true no-op outside a reverse field (sign = 1).
			float damp = MathF.Exp( -(_stasisLevel >= 2 ? STASIS_DAMP_P2 : STASIS_DAMP_P1) * s * dt );
			VelX *= damp;
			if ( _attach == Attachment.WallHug && wallSurfaceVelocityY != 0f )
			{
				float relativeVelY = VelY - wallSurfaceVelocityY;
				if ( !suppressChargeWallGravity )
					relativeVelY -= GravitySign * GRAVITY * dt * (1f + (baseFactor - 1f) * s);
				VelY = relativeVelY * damp + wallSurfaceVelocityY;
			}
			else
			{
				if ( !suppressChargeWallGravity )
					VelY -= GravitySign * GRAVITY * dt * (1f + (baseFactor - 1f) * s);
				VelY *= damp;
			}
			// Direction-preserving, matching the channel's own magnitude-only decay. _shockwaveStartMag is
			// left as-is on purpose: the bounce gate (>= 75% of the applied force) then disqualifies wall
			// ricochets sooner inside a wake — the goo soaks the bounce too.
			_shockwaveVel *= damp;
			ExtraVelX *= damp;
			ExtraVelY *= damp;
			_grappleReleaseVelocity *= damp;
			return;
		}

		// A charged wall jump can opt into a short flat launch window. This suppresses only gravity;
		// collision resolution and external force channels continue to move the player normally.
		if ( suppressChargeWallGravity )
			return;

		// Gravity sign: +1 normally (pull down), −1 in a reverse-gravity field (pull up). Every "toward the
		// floor" test below uses the signed velocity `VelY * gSign` (>0 = moving AWAY from the floor / rising)
		// and the gravity add is `-= gSign * currentGravity`, so the whole path — wall-cling, wall-climb,
		// hover, apex-hang, the base-gravity factors and the rise/fall clamp — works identically when flipped.
		// It's a true no-op in normal gravity (gSign = 1). Ceiling-cling runs in the same gravity frame
		// via its own ceilDir/probe locals below (reversed, it grips the TOPSIDE of a block underneath —
		// the overhead block there is the floor, owned by the underside ride).
		float gSign = GravitySign;

		// The shared wall-hug predicate (see its declaration): includes the floor-is-not-a-wall
		// exclusion, so a hairline-embedded floor block no longer reads as a wall for cling/climb/
		// slide/gravity — previously only the wall-jump paths applied it.
		bool huggingWallLeft = HuggingLeftWall;
		bool huggingWallRight = HuggingRightWall;
		bool huggingWall = huggingWallLeft || huggingWallRight;
		float wallRelativeVelY = huggingWall ? VelY - wallSurfaceVelocityY : VelY;
		float verticalMotion = huggingWall ? wallRelativeVelY : VelY;

		// Wall-cling: a fresh wall hug sticks with ZERO slide for the first WALL_CLING_FRAMES ticks, then
		// falls through to the normal slow slide below. Off by default (0 frames = no-op).
		//
		// Each wall grants ONE cling per contact, tracked PER SIDE. A side's cling re-arms only once we've
		// left that wall (its contact drops) and re-touched it. This blocks two exploits while still
		// allowing every legitimate grab:
		//   * A single-tick direction tap (release then re-press while still glued to the SAME wall) can't
		//     refill the counter — we never left the wall, so its flag stays set.
		//   * Wedged in a one-body-wide gap touching BOTH walls, you still earn one cling off EACH wall (a
		//     genuine switch to the other side you hadn't clung), but alternating L/R/L/R can't refill
		//     indefinitely — each side gives its single cling and no more until you actually leave it.
		// Grounded contact is NOT cling territory: standing (or walking) pressed against a wall counts
		// as a hug but must not consume the cling — the grab below is gated on being airborne, and a
		// floor visit re-arms the per-side latches outright. Landing is a legitimate reset (it re-arms
		// the dash and air jumps too); the latches exist to block tap-repress spam ON the wall, and
		// without the grounded re-arm a flush landing kept the latch consumed forever (side contact
		// never drops while standing at the wall, or while dashing straight up flush against it).
		// OnFloor is gravity-framed, so a reverse-gravity ceiling-floor counts the same way.
		bool groundedForCling = OnFloor;
		if ( !CollidingLeft || groundedForCling ) _clungLeftWall = false;   // off the left wall → its cling re-arms on re-contact/landing
		if ( !CollidingRight || groundedForCling ) _clungRightWall = false; // off the right wall → its cling re-arms on re-contact/landing

		// GRAVITY-FRAME CEILING: the surface a cling grips is the one opposite the pull — world-Up
		// normally, world-DOWN in a reverse-gravity field (the topside of a block below you, gripped
		// against the upward pull). Every probe, glue plane, face normal and contact flag in the cling
		// block below runs in this frame; UpPressed is already frame-swapped at the input choke point
		// and the sprite already flips on _gravityReversed, so input and presentation come for free.
		// The charge state machine follows the same convention: a stored surface of Direction.Up MEANS
		// the gravity-frame ceiling (literal Down stays reserved for the sticky named-face charge).
		Direction ceilDir = _gravityReversed ? Direction.Down : Direction.Up;
		float ceilProbeDY = _gravityReversed ? -2f : 2f;
		bool collidingCeilingward = _gravityReversed ? CollidingDown : CollidingUp;

		bool chargingFromCeiling = _chargingJump && _chargeJumpSurfaceDirection == Direction.Up;
		bool ceilingChargeRearmSuppressed = HAS_CHARGE_CEILING_JUMP
			&& _suppressedChargeSurfaceDirection == Direction.Up;
		// Same re-arm philosophy as the wall suppression in HandleChargeJump: separating from the ceiling
		// OR releasing Up (a re-press is a fresh grab) clears it, so a launch that stays near the surface
		// (a flush floor below) doesn't leave the ceiling permanently un-grabbable.
		if ( ceilingChargeRearmSuppressed && (!UpPressed || !IsPlayerColliding( X, Y + ceilProbeDY, ceilDir, out _ )) )
		{
			_suppressedChargeSurfaceDirection = Direction.None;
			ceilingChargeRearmSuppressed = false;
		}
		bool canGripCeiling = CAN_CEILING_CLING
			|| (HAS_CHARGE_CEILING_JUMP && !ceilingChargeRearmSuppressed);
		bool hasCeilingContact = _ceilingHitThisTick || _dashCoastCeilingHit
			|| _attach == Attachment.CeilingCling
			|| (HAS_CHARGE_CEILING_JUMP && collidingCeilingward);
		bool siblingCeiling = IsPlayerColliding( X, Y + ceilProbeDY, ceilDir, out Entity2D ceilingContact )
			&& ceilingContact is Player;
		bool ceilingGripEligible = !_blinkPreparing && canGripCeiling
			&& (UpPressed || chargingFromCeiling) && hasCeilingContact && !siblingCeiling;
		bool ceilingChargeOwnsContact = HAS_CHARGE_CEILING_JUMP && ceilingGripEligible;

		// No dash-coast exemption here (unlike the coast return below): dashing INTO a fresh wall is a
		// grab like any other — the cling catches the dash instead of letting its ExtraVelY coast the
		// player along the wall for the flat window. Dashing OUT of a freeze still works: FireDash
		// consumes the active cling (and the per-side latch blocks a same-contact re-grab), so an
		// up/down dash along the hugged wall escapes cleanly.
		bool activelyWallClimbing = WALL_CLIMB_SPEED > 0f && UpPressed;
		if ( WALL_CLING_FRAMES > 0 && !groundedForCling && !activelyWallClimbing && !ceilingChargeOwnsContact
			&& huggingWall && wallRelativeVelY * gSign <= 0.0f )
		{
			bool alreadyClung = huggingWallLeft ? _clungLeftWall : _clungRightWall;
			if ( !alreadyClung )
			{
				_wallClingCounter = WALL_CLING_FRAMES; // fresh grab of a wall we haven't clung this contact
				if ( huggingWallLeft ) _clungLeftWall = true; else _clungRightWall = true;
				_dashGravityTimer = 0f; // a dash the wall just caught is spent — no leftover flat-coast float
			}
			if ( _wallClingCounter > 0 )
			{
				_wallClingCounter--;
				VelY = wallSurfaceVelocityY; // zero slide in the hugged surface's frame
				// The freeze ABSORBS vertical momentum and forces rather than merely outlasting them: a
				// dash's ExtraVelY (or a grapple fling) no longer coasts through it, and accumulated
				// field pushes (wind/magnet/shockwave/swarm) can't survive to fling us the moment the
				// cling ends. Vertical components only — horizontal ones keep their existing grip-break
				// behavior (wind blowing us off the wall, shockwave knockback). _wallClingHold makes the
				// freeze authoritative at integration against per-tick re-feeds (see AdjustYPosition).
				ExtraVelY = 0f;
				_grappleReleaseVelocity.y = 0f;
				_windVel.y = 0f;
				_magnetVel.y = 0f;
				_sirenVel.y = 0f;
				_shockwaveVel.y = 0f;
				_swarmRepelVel.y = 0f;
				_wallClingHold = true;
				return;
			}
		}

		// Ceiling-cling: hang from the GRAVITY-FRAME-OVERHEAD surface with Up held (the surface gravity
		// pulls you away from: an underside normally, a block/obstacle TOPSIDE in a reverse-gravity
		// field). Persists for as long as Up is held and
		// we stay in contact — there is NO time limit (a held Up hangs indefinitely). Ordinary cling engages
		// only on a genuine impact; ceiling-charge characters may also grab an existing CollidingUp contact.
		// While clinging we RIDE the grabbed surface: glue our
		// contacted edge to its face (follow it vertically) AND carry its horizontal movement, on top of our
		// own shimmy (horizontal input is applied earlier in the tick). Sliding out from under it, releasing
		// Up, or the block dying ends the cling — and EndCeilingCling peels us off with its momentum.
		if ( ceilingGripEligible )
		{
			// GRAVITY-FLIP HANDOVER: gravity flipping mid-cling turns the gripped face into the floor
			// you're standing on (or vice versa) — this frame's glue math against the stale face would
			// snap us straight through the block. Release via EndCeilingCling, whose inherit guard skips
			// momentum on a frame mismatch: this is a handover to the ride/floor adoption, not a peel-off.
			if ( _attach == Attachment.CeilingCling && _attachFace != ceilDir )
				EndCeilingCling();

			if ( (_ceilingHitThisTick || _dashCoastCeilingHit
					|| (HAS_CHARGE_CEILING_JUMP && collidingCeilingward))
				&& _attach != Attachment.CeilingCling )
			{
				// Latch the grabbed surface + its X (via Attach) exactly ONCE, on the grab. Re-latching on
				// later re-contact ticks (which a sustained upward force such as a lift lane triggers EVERY
				// tick, re-setting _ceilingHitThisTick) would reset _ceilingLastX to the block's current X
				// before rideDX is computed below, zeroing the carry so a moving block slides out from
				// under us.
				if ( IsPlayerColliding( X, Y + ceilProbeDY, ceilDir, out Entity2D ceiling ) && ceiling is not Player )
				{
					// Fresh live grab. Plant only after the probe succeeds: the contact flag was sampled
					// before movement and can be stale for one tick after launching away from the ceiling.
					VelX = 0f; ExtraVelX = 0f; _windVel.x = 0f; _magnetVel.x = 0f;
					Attach( Attachment.CeilingCling, ceiling, ceilDir );
				}
			}

			// Safety net for a clung block destroyed outside the mimic swap's same-tick handoff
			// (OnBlockReplaced re-points _attachEntity, so a Replaced parent here means a missed path):
			// re-pick whatever live surface is actually overhead (null = the arena ceiling, a legitimate
			// cling). Nothing overhead → release via Detach, NOT EndCeilingCling: the destroyed
			// instance's frozen Velocity must not be inherited. Keyed on the sim-side Replaced flag,
			// never engine IsValid() (frame-flush cadence).
			if ( _attach == Attachment.CeilingCling && _attachEntity is Block { Replaced: true } )
			{
				if ( IsPlayerColliding( X, Y + ceilProbeDY, ceilDir, out Entity2D replacement ) && replacement is not Player )
					Attach( Attachment.CeilingCling, replacement, ceilDir );
				else
					Detach();
			}

			// FLUSH-SEAM HANDOFF: two flush surfaces (adjacent obstacles, or a block parked flush
			// against one) read as ONE continuous underside, but the cling latches a single entity —
			// shimmying across the invisible seam slides us out of the latched rect while solid
			// ceiling is still overhead, and the slid-out release below is unrecoverable mid-hang
			// (a re-grab needs a genuine upward impact, which gravity forbids while hanging). Hand
			// the cling to whatever live surface IS overhead before that release can fire (null =
			// the arena ceiling, a legitimate cling). Skipped while charging: that pose pins X to
			// the grab tangent and never slides out. Nothing overhead → fall through to the normal
			// slid-off release, momentum inherit and all.
			if ( _attach == Attachment.CeilingCling && _attachEntity is not null && !chargingFromCeiling )
			{
				RectF latched = _attachEntity.GetRect();
				if ( (X <= latched.Left - Width * 0.5f || X >= latched.Right + Width * 0.5f)
					&& IsPlayerColliding( X, Y + ceilProbeDY, ceilDir, out Entity2D neighbor )
					&& neighbor is not Player && !ReferenceEquals( neighbor, _attachEntity ) )
				{
					Attach( Attachment.CeilingCling, neighbor, ceilDir );
				}
			}

			// Glue + validity only for an ESTABLISHED cling. Eligibility alone doesn't mean we're
			// clinging: CollidingUp is sampled before AdjustXPosition, so a rider's own walk can carry
			// them past a ceiling edge between the sample and the fresh-grab probe — a stale-contact
			// tick where the grab correctly didn't happen. Without this guard that tick fell through
			// here with _attachEntity still the RIDDEN platform (or hugged wall block) and glued the
			// player to ITS underside, plus a rideDX carry measured from ride-attach — a one-frame
			// teleport below the block they were standing on.
			if ( _attach == Attachment.CeilingCling )
			{
				bool valid = true;
				float glueY = Y;
				float rideDX = 0f;
				if ( _attachEntity != null )
				{
					RectF cr = _attachEntity.GetRect();
					if ( ((_attachEntity as Block)?.IsDead ?? false)
						 || (_attachEntity is Player { IsDead: true })
						 || ((_attachEntity as Block)?.PhasingIn ?? false) // a Teleport block that vanished from above us
						 || (!chargingFromCeiling && (X <= cr.Left - Width * 0.5f || X >= cr.Right + Width * 0.5f)) )
						valid = false;                        // block gone, or we've slid out from under it
					else
					{
						glueY = _gravityReversed
							? cr.Top + Height * 0.5f              // reversed: grip its TOPSIDE from above
							: cr.Bottom - Height * 0.5f;          // follow its underside vertically
						rideDX = _attachEntity.X - _ceilingLastX; // carry its horizontal movement
					}
				}
				else if ( !IsPlayerColliding( X, Y + ceilProbeDY, ceilDir, out _ ) )
				{
					valid = false;                            // static arena wall we've drifted away from
				}
				else
				{
					// ARENA-BOUNDARY FLUSH SNAP: a null attach grips the arena boundary itself, and the
					// charge-ceiling grab can latch from the 1px proximity probe WITHOUT a genuine bonk
					// (an apex that dies just short of the surface; ordinary clings need
					// _ceilingHitThisTick, which ClampToBounds left flush). The entity path re-derives
					// glueY from the clung rect every tick, but this path kept our current Y — freezing
					// that sub-pixel shortfall for the whole hang/wind-up (VelY zeroed, early return:
					// gravity never closes it), a visible 1px gap under the ceiling. Glue flush to the
					// boundary plane, clearance-guarded like the pull-through rule above (a solid sliver
					// in the gap keeps the old spot rather than snapping into it).
					float flushY = _gravityReversed
						? Arena.WALL_SIZE + Height * 0.5f
						: Arena.HEIGHT - Arena.WALL_SIZE - Height * 0.5f;
					if ( glueY != flushY && IsSpotClearOfSolids( X, flushY ) )
						glueY = flushY;
				}

				if ( valid )
				{
					float targetX = chargingFromCeiling && _attachEntity != null
						? _attachEntity.X + _attachTangentOffset // charge owns the tangent: stay pinned to the grab point
						: X + rideDX;                            // ordinary cling rides plus its own horizontal shimmy

					// PULL-THROUGH GUARD (the WallHug blocked flush-follow, rotated 90°): the vertical
					// follow below is a raw snap, and the clung block's underside can move past solids WE
					// can't pass — park flush beside an obstacle, then rise: any body overhanging the
					// block's edge sits under the obstacle, and the glue dragged it straight up through
					// it. Follow only through CLEAR space. If the target spot is blocked, first scrape
					// sideways fully under the block — the same outcome the depenetration resolver already
					// produces for a MILD overhang, made deterministic for any overhang (skipped while
					// charging: that pose owns X via the tangent pin). If even that spot is blocked (no
					// overhang to remove, or the block is narrower than us), the block went somewhere we
					// can't follow: let go with a plain Detach, NOT EndCeilingCling — the glue never
					// actually moved us toward the blocked spot, so there is no carried momentum to
					// inherit (same reasoning as WallHug's blocked follow).
					if ( _attachEntity != null && glueY != Y && !IsSpotClearOfSolids( targetX, glueY ) )
					{
						RectF br = _attachEntity.GetRect();
						float underLeft = br.Left + Width * 0.5f;
						float underRight = br.Right - Width * 0.5f;
						float scrapedX = !chargingFromCeiling && underLeft <= underRight
							? Math.Clamp( targetX, underLeft, underRight )
							: targetX;
						if ( scrapedX != targetX && IsSpotClearOfSolids( scrapedX, glueY ) )
							targetX = scrapedX;
						else
							valid = false; // blocked follow — release below
					}

					if ( valid )
					{
						// SPIKED-SURFACE KILL: the movement-path spike checks all key on relative motion
						// or a resolve, and the glue produces neither (raw snaps, VelY zeroed, early
						// return) — so a cling could hang from live teeth indefinitely: shimmy across a
						// flush seam onto a spiked neighbour, straddle a parked block's edge under a
						// spiked obstacle face, or keep gripping a surface that grows spikes mid-cling.
						// But a grip pressing us against a face is a genuine press, not passive flush
						// adjacency — the same contract the Stuck glue's spiked-face kill encodes.
						// Checked at the FINAL glue spot EVERY tick (so dynamically grafted spikes kill
						// the moment they land), against every contacted surface kind: all blocks flush
						// at the grip plane (a sweep, not a first-hit probe — straddle survival must not
						// depend on block iteration order), all obstacle faces (ForRect is seam-airtight
						// and strict at segment ends, so hanging BESIDE the teeth still survives), and
						// the arena boundary itself for a null-attach cling (the wall a graft can spike
						// under a hanging player).
						Direction gripFaceNormal = _gravityReversed ? Direction.Up : Direction.Down;
						RectF glueRect = GetRect( targetX, glueY );
						if ( BlockFaceDeadlyForRect( gripFaceNormal, glueRect )
							|| Stage.ObstacleFaceDeadlyForRect( gripFaceNormal, glueRect )
							|| (_attachEntity is null && Stage.WallDeadlyAt( ceilDir, new Vector2( targetX, glueY ) )) )
						{
							KilledBySpikes( ceilDir );
							return;
						}

						X = targetX;
						Y = glueY;                                // ride vertically
						if ( _attachEntity != null ) _ceilingLastX = _attachEntity.X;
						VelY = 0.0f;
						return;
					}

					Detach(); // blocked follow: no momentum inherit (see the guard comment above)
				}
				else
					EndCeilingCling(); // slid off / block gone
			}
		}
		else
		{
			EndCeilingCling(); // no contact or Up released (a no-op unless actually clinging)
		}
		// The post-dash hang itself (timer advanced at the top of this function): skip gravity — and the
		// wall-cling above / wall-climb, hover and field paths below — so the dash stays flat. Deliberately
		// placed AFTER ceiling-cling: returning any earlier swallowed a held-Up ceiling impact anywhere in
		// the coast window, discarding its one-tick contact before the timer expired.
		if ( dashGravitySuppressed )
			return;

		// Wall-climb: hold INTO a wall + Up to climb it at a fixed speed (0 = off). A pressed jump still
		// wall-jumps (that fires later in the tick, overriding this); a held Up climbs. Climbs AWAY from the
		// floor, so in a reverse-gravity field it climbs down the wall (toward the reversed "up").
		if ( WALL_CLIMB_SPEED > 0f && huggingWall && UpPressed )
		{
			VelY = wallSurfaceVelocityY + gSign * WALL_CLIMB_SPEED;
			UpdateWallClimbEffects( dt, huggingWallLeft, gSign );
			return;
		}

		// Hover / air-stall: hold Up in the air away from walls to freeze the fall for a few ticks, once per
		// airtime (refilled on landing in HandleVerticalJumping). 0 = off. Gravity-sign aware so it also
		// stalls the upward "fall" in a reverse field.
		if ( HOVER_FRAMES > 0 && !OnFloor && !huggingWall && UpPressed && verticalMotion * gSign <= 0f && _hoverRemaining > 0 )
		{
			_hoverRemaining--;
			VelY = 0f;
			return;
		}

		float currentGravity = GRAVITY * dt;
		// Asymmetric base gravity: shape the arc (e.g. float up, fall fast). Defaults 1 = original.
		currentGravity *= (verticalMotion * gSign > 0f) ? RISE_GRAVITY_FACTOR : FALL_GRAVITY_FACTOR;

		if ( huggingWall && wallRelativeVelY * gSign <= 0.0f )
			currentGravity *= WALL_GRAVITY_FACTOR;
		else if ( UpPressed && !(ACTIVE_UP_GRAVITY_FALL_ONLY && verticalMotion * gSign > 0.0f) && !(ACTIVE_DOWN_OVERRIDES_UP && DownPressed) )
			currentGravity *= ACTIVE_UP_GRAVITY_FACTOR;
		else if ( DownPressed )
			currentGravity *= (verticalMotion * gSign > 0f) ? ACTIVE_DOWN_GRAVITY_RISE_FACTOR : ACTIVE_DOWN_GRAVITY_FACTOR;

		// Wall-rise: while hugging a wall and still RISING (VelY>0), additionally scale gravity — a
		// "slidy wall" that carries upward momentum far up the surface. This STACKS on top of the
		// input-gated factors above (rather than replacing them), so the default 1.0 is a true no-op and
		// the original behaviour — a floaty ACTIVE_UP rise while pressed into a wall — is preserved.
		if ( huggingWall && wallRelativeVelY * gSign > 0.0f )
			currentGravity *= WALL_RISE_GRAVITY_FACTOR;

		// Apex hang: near the top of the arc (small |VelY|, airborne) reduce gravity for extra hang time.
		// Defaults (factor 1 / threshold 0) make this a no-op.
		if ( !OnFloor && Math.Abs( huggingWall ? wallRelativeVelY : VelY ) < APEX_HANG_VEL_THRESHOLD )
			currentGravity *= APEX_HANG_GRAVITY_FACTOR;

		// SUSTAINED-FIELD ANTI-GRAVITY (see the channel declarations): while the wind/magnet/siren
		// channels' summed vertical component lifts us away from the floor and we're not rising under our
		// own VelY, cancel gravity (scaled by the lift speed) instead of letting it bank fall speed behind
		// the suspension, and bleed off any fall speed banked before we entered the field. The wind channel
		// coasts out over ~0.4s after leaving a lane, so gravity fades back in over that same ramp; the
		// magnet channel zeroes instantly, handing over a fresh fall from rest. Deterministic (pure
		// function of the channels; the blocks fed them before we tick). A ground pound is exempt the
		// same way a rising jump is — both are committed OWN motion, not gravity-banked fall speed; and
		// the pound ends itself the moment VelY reaches zero (HandleGroundPound), so letting the arrest
		// bleed the slam would quietly dissolve a pound mid-air into a hover. The lane still pushes back
		// through the channel sum in the position integration — it slows the slam, it can't disarm it.
		float fieldLift = FieldVelocity.y * gSign;
		if ( fieldLift > 0f && verticalMotion * gSign <= 0f && !_groundPounding )
		{
			float cancel = Math.Min( 1f, fieldLift / FIELD_LIFT_FULL_CANCEL_SPEED );
			currentGravity *= 1f - cancel;
			float fallSpeed = -verticalMotion * gSign; // >0 = moving toward the floor (wall-relative if hugging)
			if ( fallSpeed > 0f )
			{
				fallSpeed = Math.Max( 0f, fallSpeed - GRAVITY * FIELD_FALL_ARREST_FACTOR * cancel * dt );
				VelY = -fallSpeed * gSign + (huggingWall ? wallSurfaceVelocityY : 0f);
			}
		}

		VelY -= gSign * currentGravity * _blinkPrepareFactor; // pull toward the floor (down normally, up when reversed)
		// Cap vertical speed. Defaults are set very high so it's a no-op for normal characters; rise and
		// fall are capped independently. A ground pound lifts the fall cap to at least its slam speed so a
		// low MaxFallSpeed doesn't throttle the pound.
		float fallCap = _groundPounding ? Math.Max( MAX_FALL_SPEED, GROUND_POUND_SPEED ) : MAX_FALL_SPEED;
		// Wall-slide fall cap: a tighter downward clamp while sliding down a wall (independent of the
		// slide gravity factor). Negative default = off. Not applied during a ground pound (the pound
		// owns the fall cap above).
		if ( WALL_SLIDE_MAX_FALL_SPEED >= 0f && !_groundPounding && huggingWall && wallRelativeVelY * gSign <= 0.0f )
			fallCap = Math.Min( fallCap, WALL_SLIDE_MAX_FALL_SPEED );
		// Clamp the AWAY-from-floor speed to [-fallCap, MAX_RISE_SPEED] (fall vs rise capped independently),
		// then map back through the sign — so a reverse field caps its upward "fall" by MAX_FALL_SPEED, not rise.
		if ( huggingWall )
		{
			wallRelativeVelY = VelY - wallSurfaceVelocityY;
			VelY = Math.Clamp( wallRelativeVelY * gSign, -fallCap, MAX_RISE_SPEED ) * gSign + wallSurfaceVelocityY;
		}
		else
		{
			VelY = Math.Clamp( VelY * gSign, -fallCap, MAX_RISE_SPEED ) * gSign;
		}
	}

	private void UpdateWallClimbEffects( float dt, bool huggingWallLeft, float gravitySign )
	{
		_wallClimbSfxAmount += WALL_CLIMB_SPEED * dt;
		if ( _wallClimbSfxAmount > WALL_CLIMB_SFX_REQ )
		{
			Audio.PlaySfx( SfxType.PlayerWallClimb, Position, 0.4f, pitchScale: DashRechargeSfxPitchScale );
			if ( DrivesHaptics ) Haptics.Pulse( 0.1f, 0.03f, 0f, Haptics.TONE_CRISP, EasingType.Linear );
			_wallClimbSfxAmount = 0f;
		}

		_wallClimbCloudTimer -= dt;
		if ( _wallClimbCloudTimer > 0f ) return;

		float inward = huggingWallLeft ? 1f : -1f;
		var pos = new Vector2(
			X,
			Y - (Height * 0.5f + 3f) * gravitySign // at the trailing (gravity-side) end, matching the signed drift below
		);
		var velocity = new Vector2(
			inward * Rng.CosmeticFloat( 8f, 18f ),
			-gravitySign * Rng.CosmeticFloat( 2f, 8f )
		);
		var kind = Rng.CosmeticValue() < 0.5f ? ParticleKind.MimicCloud0 : ParticleKind.MimicCloud1;
		Stage.AddMimicCloud( pos, velocity, kind, Rng.CosmeticFloat( 0.2f, 0.35f ), Rng.CosmeticInt( 2, 5 ) );
		_wallClimbCloudTimer = Rng.CosmeticFloat( WALL_CLIMB_CLOUD_INTERVAL_MIN, WALL_CLIMB_CLOUD_INTERVAL_MAX );
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>End a ceiling-cling and clear its state. If we were riding a block, inherit the block's
	/// velocity so we peel off smoothly with its momentum (a rising block carries us up, a sideways one
	/// flings us along it) instead of dead-stopping (a stopped block's Velocity is zero, so it hands over
	/// nothing). Overflow past the walk cap rides ExtraVelX so a fast block still carries.</summary>
	void EndCeilingCling()
	{
		// Guarded on the attachment kind: this is called unconditionally from the non-cling gravity
		// paths every tick, and must never strip a Riding/WallHug/Stuck attachment.
		if ( _attach != Attachment.CeilingCling ) return;

		// No momentum inherit when gravity FLIPPED mid-cling (the attach face no longer matches the
		// current gravity-frame ceiling — a reverse field arriving under a normal cling, or expiring
		// under a reversed one): we aren't peeling off into the air, we're handing over to the ride/
		// floor adoption, whose glue carries the block's motion itself. Inheriting here too skidded
		// the player along the surface at the block's speed on the handover tick.
		// Nor from an instance the mimic swap replaced without the same-tick handoff re-pointing us
		// (a missed path — normally OnBlockReplaced already swapped in the disguise, whose live
		// Velocity is legitimate to inherit): its frozen Velocity would land here as an untelegraphed
		// fling. Peel off with our own motion instead (shimmy VelX + the glue's zeroed VelY) and let
		// gravity start a natural fall.
		if ( _attachEntity != null && _attachEntity is not Block { Replaced: true }
			&& _attachFace == (_gravityReversed ? Direction.Down : Direction.Up) )
		{
			float bvx = _attachEntity.VelX;
			float bvy = _attachEntity.VelY;
			VelY = bvy;
			VelX = Math.Clamp( bvx, -MAX_X_SPEED, MAX_X_SPEED );
			if ( Math.Abs( bvx ) > MAX_X_SPEED )
				ExtraVelX = bvx - VelX;
		}
		Detach();
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Add a horizontal launch impulse in <paramref name="dir"/> (±1) of <paramref name="speed"/>
	/// px/s. The resulting self-motion is consolidated first, then split back into capped VelX plus
	/// same-direction ExtraVelX overflow. This preserves additive launch speed without leaving an old,
	/// opposing VelX hidden underneath a temporary ExtraVelX that can resurface after it decays. Shared by
	/// the hop-forward jump, long jump, and air jump.</summary>
	void ApplyHorizontalLaunch( float dir, float speed )
	{
		if ( dir == 0f || speed <= 0f ) return;

		float total = Math.Clamp(
			VelX + ExtraVelX + dir * speed,
			-MAX_X_SPEED - MAX_EXTRA_X_SPEED,
			MAX_X_SPEED + MAX_EXTRA_X_SPEED
		);
		VelX = Math.Clamp( total, -MAX_X_SPEED, MAX_X_SPEED );
		ExtraVelX = total - VelX;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>FLIPPER's core move: it has NO jump — instead a fresh jump/up press while grounded flips
	/// its OWN gravity. Because the up/down input accessors are already orientation-swapped when gravity
	/// is reversed, the SAME `UpJustPressed + OnFloor` test covers both directions of the toggle: pressing
	/// UP while standing on the floor flips to the ceiling, and pressing DOWN while hanging upside-down on
	/// the ceiling (which reads as UpJustPressed in the flipped frame) flips back. The flip feeds the
	/// EFFECTIVE `_gravityReversed` via XOR with any reverse-field this tick, so entering a reverse field
	/// while self-flipped cancels back to normal gravity (see the _gravityReversed field comment). The
	/// sprite flip, the enter/leave sfx and the violet burst all come for free off the effective flag
	/// changing (UpdateReverseGravityAudio / UpdateAnimation). No-op for every other character.
	/// Deterministic: reads only OnFloor (fixed state), recorded UpJustPressed, the jump-buffer
	/// counter and (v42+) <see cref="LaunchFloorIsSpiked"/>; toggles a bool.</summary>
	void HandleGravityFlip()
	{
		if ( !CAN_FLIP_GRAVITY ) return;
		// Only from the surface gravity currently holds us against (floor normally, ceiling when already
		// flipped). Fires on a fresh press (edge-triggered, one flip per tap) OR a press buffered a few
		// airborne ticks before touchdown — HandleJumpBuffer latches those and runs LAST, so the counter
		// read here still holds last tick's value, exactly like the ground-jump consumer. The buffer is
		// orientation-safe for the return flip too: falling toward the ceiling while self-flipped, an
		// early physical-Down press reads as UpJustPressed in the flipped frame and latches the same way.
		if ( !OnFloor || (!UpJustPressed && _jumpBufferCounter <= 0) ) return;
		if ( LaunchFloorIsSpiked ) return; // sim v42+: the hug-slide could flip off teeth it hadn't touched yet

		SetBaselineGravityReversed( !_selfGravityReversed );
		RefreshDashCharge();
		DetachPlatform(); // let go of any ridden block so we fall cleanly toward the new "down"
		_jumpBufferCounter = 0; // consume the buffered press — one flip per press, same as the jumps
	}

	/// <summary>NO LAUNCH OFF SPIKES (sim v42+): OnFloor is the 1px proximity probe, but obstacle/arena
	/// spikes kill only on penetration, so a slow approach (a hug-slide, a hover running out) read as
	/// grounded a hair ABOVE live teeth and the ground jump / Flipper invert / Shifter surface jump
	/// launched away before touching them. All three refuse the press while the floor in that window is
	/// deadly — the body then meets the spikes exactly as it would have. No new kill path; the jump buffer
	/// ages out like any press that found no floor, and the wall/air jumps can't take the press (both are
	/// gated on !OnFloor). Follows the effective gravity frame, so it covers reverse gravity and the
	/// Shifter's side floors. Sim-side reads only (SpikedSurfaceAt), so replay-safe behind the gate.</summary>
	bool LaunchFloorIsSpiked => Stage.SimVersion >= Sim.NO_JUMP_OFF_SPIKES && SpikedSurfaceAt( EffectiveGravityDirection );

	/// <summary>The side-wall twin of <see cref="LaunchFloorIsSpiked"/>: the wall jump arms and Spring's wall
	/// charge begins off the hug predicate (the 1px side probe), so a hug a hair short of a live spiked face
	/// could launch away from teeth it never touched. Neither arms off, nor fires from, such a wall.</summary>
	bool WallLaunchIsSpiked( Direction wall ) => wall is Direction.Left or Direction.Right
		&& Stage.SimVersion >= Sim.NO_JUMP_OFF_SPIKES && SpikedSurfaceAt( wall );

	// ----------------------------------------------------------------------------------------
	/// <summary>CHARGE JUMP: holding the toward-floor key while grounded winds up a leap aimed with
	/// Left/Right. Holding into a side wall winds up a wall jump aimed with Up/Down; releasing the wall hug
	/// fires horizontally when there is no net vertical aim. Holding Up against a ceiling attaches to it
	/// and winds the floor charge in reverse, aimed with Left/Right. Charges normally fire when their hold
	/// input is released; <see cref="CHARGE_JUMP_AUTO_FIRE_AT_MAX"/> can instead launch them at max power.
	///
	/// While charging, locomotion is suppressed and the away-from-floor jump key is swallowed. Ground
	/// charges use net Left/Right hold time; wall charges use net Up/Down hold time. Opposing inputs cancel.
	/// The net hold fraction steers from perpendicular to the surface toward a shallow
	/// <see cref="CHARGE_JUMP_MIN_ANGLE_DEG"/> launch. Power ramps from <see cref="CHARGE_JUMP_MIN_SPEED"/> to
	/// <see cref="CHARGE_JUMP_MAX_SPEED"/> over <see cref="CHARGE_JUMP_MAX_TIME"/> and remains capped there
	/// until release unless auto-fire is enabled. Pressing the key AWAY from the surface while the hold is
	/// still down cancels the wind-up with a tiny repel pop (<see cref="CancelChargeJumpWithPop"/>) — a
	/// keyboard/d-pad bail-out; a stick can't hold both, so rolling it away releases the hold and fires
	/// as before. If the player leaves the surface mid-charge (pushed off,
	/// or detached any other way) the charge is CANCELLED: normal control resumes and releasing the key
	/// does nothing. A wall charge also cancels on landing, but NOT while a block is shoving us along the
	/// wall from the floor side — that floor contact is the block moving us, not a landing.
	/// Starting a wind-up presses the charged face (<see cref="PressChargeSurface"/>) —
	/// the wall/ceiling charges arm off proximity, so contact alone may never have pressed it.
	///
	/// Runs right after <see cref="HandleVerticalJumping"/> so it reads the same fresh <see cref="OnFloor"/>
	/// the ground jump does; the launch velocity it sets is carried by next tick's move (exactly like every
	/// other jump). Deterministic: reads only collision state, recorded directional input, and the fixed
	/// tick <c>dt</c>. No-op unless floor, wall, or ceiling charging is enabled.</summary>
	void HandleChargeJump( float dt )
	{
		if ( !HAS_CHARGE_JUMP && !HAS_CHARGE_WALL_JUMP && !HAS_CHARGE_CEILING_JUMP ) return;

		bool onFloor = OnFloor;
		bool canStartGroundCharge = _chargeGroundedFrames >= 1 || (AUTO_BOUNCE_GROUND && !_onFloorLastTick);
		// A fired wall's suppression exists only so the launch tick(s) — where contact can still be live —
		// can't instantly swallow the jump
		// into a fresh wind-up. It re-arms on ANY clear sign the launch is over or re-intended: contact
		// with that wall broke, the toward-wall hold was RELEASED (a re-press is a fresh grip), or we've
		// landed. Requiring contact to break alone left the fired side permanently un-chargeable when the
		// launch never separates (a flush opposite wall, a slide along a long wall) — confusing, since
		// nothing tells the player that side needs a step away to re-arm.
		if ( (_suppressedChargeSurfaceDirection == Direction.Left && (!CollidingLeft || !LeftPressed || onFloor))
			|| (_suppressedChargeSurfaceDirection == Direction.Right && (!CollidingRight || !RightPressed || onFloor)) )
			_suppressedChargeSurfaceDirection = Direction.None;

		if ( _chargingJump )
		{
			bool chargingFromWall = _chargeJumpSurfaceDirection == Direction.Left
				|| _chargeJumpSurfaceDirection == Direction.Right;
			bool chargingFromCeiling = _chargeJumpSurfaceDirection == Direction.Up;
			bool touchingChargedSurface = chargingFromWall
				? (_chargeJumpSurfaceDirection == Direction.Left ? CollidingLeft : CollidingRight)
				: chargingFromCeiling ? _attach == Attachment.CeilingCling : onFloor;
			bool holdingCharge = chargingFromWall
				? (_chargeJumpSurfaceDirection == Direction.Left ? LeftPressed : RightPressed)
				: chargingFromCeiling ? UpPressed : DownPressed;
			// The key opposite the hold (away from the surface; the floor's is the jump key). Edge-triggered
			// so a direction already down when the wind-up began can't cancel it by itself.
			bool awayJustPressed = chargingFromWall
				? (_chargeJumpSurfaceDirection == Direction.Left ? RightJustPressed : LeftJustPressed)
				: chargingFromCeiling ? DownJustPressed : UpJustPressed;
			// A wall wind-up is only cancelled by onFloor when we LANDED. A block driving into us from the
			// floor side (rising under us; descending onto us in a reverse field) also reads as onFloor
			// while it shoves us along the wall, but that's the block moving us, not a landing — the
			// charge rides the push and the wall contact/hold keep governing it. The moment the floor
			// stops moving toward us (block slammed to a halt, or it was a static floor all along) the
			// normal landing cancel applies. Sim-only read (block velocity), so replay-safe.
			bool floorPushingUs = chargingFromWall && onFloor
				&& CollidingEntity( EffectiveGravityDirection ) is { } floorEntity
				&& floorEntity.VelY * GravitySign > 0f;

			// charge_trace: log every change of the sim-visible picture while winding, with the input
			// layer's stick pipeline alongside, so a phantom release shows exactly which link dropped
			// (raw _input bit vs consumed flag vs contact) and what the stick read at that moment.
			if ( _chargeTrace )
			{
				string state = $"WINDING surf={_chargeJumpSurfaceDirection} t={_chargeJumpTime:0.00}"
					+ $" touching={touchingChargedSurface} holding={holdingCharge}"
					+ $" in(L={(_input.Left ? 1 : 0)} R={(_input.Right ? 1 : 0)} U={(_input.Up ? 1 : 0)} D={(_input.Down ? 1 : 0)})"
					+ $" consumed={_directionalInputConsumed} gravRev={_gravityReversed} onFloor={onFloor} floorPush={floorPushingUs}";
				// Time advances every tick — compare with it stripped so only real changes log.
				string stateKey = state.Replace( $" t={_chargeJumpTime:0.00}", "" );
				if ( stateKey != _chargeTraceLastState )
				{
					_chargeTraceLastState = stateKey;
					TraceCharge( $"{state}\n[charge]   stick: {InputState.DescribeStickForTrace()}" );
				}
			}

			// SPIKED-WALL KILL (sim v42+): a wall wind-up is planted (steering suppressed, tangent pinned),
			// so a face that grows spikes under it never takes the penetrating step the obstacle/arena
			// kills need — the charge sat on live teeth and could fire or pop away. A grip pressing us into
			// a face is a genuine press (the cling / sticky rule), so the teeth landing kills. Block sides
			// already die through CheckForSpikes; the floor charge through the gravity hair; the ceiling
			// charge through the cling's own check. Ahead of the release/cancel paths so neither launches.
			if ( chargingFromWall && touchingChargedSurface && Stage.SimVersion >= Sim.NO_JUMP_OFF_SPIKES
				&& SpikedSurfaceAt( _chargeJumpSurfaceDirection ) )
			{
				KilledBySpikes( _chargeJumpSurfaceDirection );
				return;
			}

			// Losing the charged surface cancels. Releasing its hold input while still attached fires.
			if ( !touchingChargedSurface || ((chargingFromWall || chargingFromCeiling) && onFloor && !floorPushingUs) )
			{
				TraceCharge( $"END lost-surface t={_chargeJumpTime:0.00} touching={touchingChargedSurface} onFloor={onFloor}"
					+ $"\n[charge]   stick: {InputState.DescribeStickForTrace()}" );
				PlayChargeLostCue(); // reads the pre-clear wind-up state, so cue BEFORE the reset below
				if ( chargingFromCeiling && _attach == Attachment.CeilingCling )
					Detach();
				_chargingJump = false;
				_chargeJumpSurfaceDirection = Direction.None;
			}
			else if ( !holdingCharge )
			{
				TraceCharge( $"END fired (hold released) surf={_chargeJumpSurfaceDirection} t={_chargeJumpTime:0.00}"
					+ $" in(L={(_input.Left ? 1 : 0)} R={(_input.Right ? 1 : 0)} U={(_input.Up ? 1 : 0)} D={(_input.Down ? 1 : 0)})"
					+ $" consumed={_directionalInputConsumed}"
					+ $"\n[charge]   stick: {InputState.DescribeStickForTrace()}" );
				FireChargeJump();
				_chargingJump = false;
			}
			// Checked AFTER the release-fire so a same-tick "release hold + press away" (a stick rolling
			// away from the surface) still launches; the cancel needs the hold genuinely still down.
			else if ( awayJustPressed )
			{
				TraceCharge( $"END cancelled (away press) surf={_chargeJumpSurfaceDirection} t={_chargeJumpTime:0.00}"
					+ $" in(L={(_input.Left ? 1 : 0)} R={(_input.Right ? 1 : 0)} U={(_input.Up ? 1 : 0)} D={(_input.Down ? 1 : 0)})"
					+ $"\n[charge]   stick: {InputState.DescribeStickForTrace()}" );
				CancelChargeJumpWithPop();
			}
			else
			{
				// Ground charge aims left/right; wall charge rotates the same net-time aiming to up/down.
				AdvanceChargeJump( dt, chargingFromWall );

				float maxChargeTime = chargingFromWall ? CHARGE_WALL_JUMP_MAX_TIME : CHARGE_JUMP_MAX_TIME;
				if ( CHARGE_JUMP_AUTO_FIRE_AT_MAX && _chargeJumpTime >= maxChargeTime )
				{
					TraceCharge( $"END fired (auto at max) surf={_chargeJumpSurfaceDirection} t={_chargeJumpTime:0.00}" );
					FireChargeJump();
					_chargingJump = false;
				}
			}
		}
		// Begin winding up once settled. Normally this requires one prior grounded tick so a manual bounce
		// gets first refusal; an auto-bouncer may charge on its first landing frame because holding the
		// toward-floor key suppresses its automatic bounce.
		else if ( HAS_CHARGE_JUMP && onFloor && canStartGroundCharge && DownPressed && VelY * GravitySign <= 0f )
		{
			BeginChargeJump( Direction.None, dt, fromWall: false );
			PressChargeSurface( Direction.None );
			// Left/Right becomes aim input during the wind-up. Plant self-driven momentum now so holding
			// the same direction used to run cannot preserve VelX indefinitely through the charge.
			// Moving-platform carry and external wind/magnet/shockwave/siren channels remain active.
			VelX = 0f;
			ExtraVelX = 0f;
		}
		// Direction.Up here means the GRAVITY-FRAME ceiling (see ApplyGravity's frame convention): in a
		// reverse-gravity field the grip — and this charge — is against the topside of a block below,
		// and the launch fires away from it (up) exactly as the normal-frame one fires down.
		if ( !_chargingJump && HAS_CHARGE_CEILING_JUMP && !onFloor && !_jumpedThisTick
			&& UpPressed && (_gravityReversed ? CollidingDown : CollidingUp) && _attach == Attachment.CeilingCling )
		{
			BeginChargeJump( Direction.Up, dt, fromWall: false );
			PressChargeSurface( Direction.Up );
		}

		// WallLaunchIsSpiked (sim v42+): no wind-up planted a hair short of a live spiked face.
		Direction chargeableWallDirection = HuggingLeftWall && _suppressedChargeSurfaceDirection != Direction.Left
				&& !WallLaunchIsSpiked( Direction.Left )
			? Direction.Left
			: HuggingRightWall && _suppressedChargeSurfaceDirection != Direction.Right
				&& !WallLaunchIsSpiked( Direction.Right )
				? Direction.Right
				: Direction.None;
		if ( !_chargingJump && HAS_CHARGE_WALL_JUMP && !onFloor && !_jumpedThisTick
			&& chargeableWallDirection != Direction.None )
		{
			BeginChargeJump( chargeableWallDirection, dt, fromWall: true );
			PressChargeSurface( chargeableWallDirection );
		}

		// A wall wind-up owns tangent locomotion just like a floor wind-up owns horizontal locomotion.
		// Stay planted in the hugged surface's frame indefinitely while external force channels remain free
		// to push us off and cancel the charge through the normal lost-contact path.
		if ( _chargingJump && (_chargeJumpSurfaceDirection == Direction.Left || _chargeJumpSurfaceDirection == Direction.Right) )
		{
			VelY = WallHugSurfaceVelocityY;
			ExtraVelY = 0f;
		}

		// Track consecutive grounded ticks (reset the moment we leave the floor) for the settle guard above.
		_chargeGroundedFrames = onFloor ? _chargeGroundedFrames + 1 : 0;
	}

	void BeginChargeJump( Direction surfaceDirection, float dt, bool fromWall )
	{
		_chargingJump = true;
		_chargeJumpTime = 0f;
		_chargeJumpAimTime = 0f;
		_chargeJumpSurfaceDirection = surfaceDirection;
		TraceCharge( $"START surf={surfaceDirection}{(fromWall ? " (wall)" : "")} gravRev={_gravityReversed}"
			+ $"\n[charge]   stick: {InputState.DescribeStickForTrace()}" );
		Audio.PlaySfx( SfxType.SpringChargeStart, Position, volume: 0.65f );
		AdvanceChargeJump( dt, fromWall );
	}

	void AdvanceChargeJump( float dt, bool fromWall )
	{
		float maxChargeTime = fromWall ? CHARGE_WALL_JUMP_MAX_TIME : CHARGE_JUMP_MAX_TIME;
		int previousLevel = ChargeLevel( _chargeJumpTime / Math.Max( 0.0001f, maxChargeTime ) );
		_chargeJumpTime = Math.Min( _chargeJumpTime + dt, Math.Max( 0f, maxChargeTime ) );
		AccumulateChargeJumpAim( dt, fromWall );
		_chargeJumpAimTime = Math.Clamp( _chargeJumpAimTime, -_chargeJumpTime, _chargeJumpTime );
		int currentLevel = ChargeLevel( _chargeJumpTime / Math.Max( 0.0001f, maxChargeTime ) );

		for ( int level = previousLevel + 1; level <= currentLevel; level++ )
			Audio.PlaySfx( SfxType.SpringChargeThreshold, Position, volume: 0.55f, pitch: 0.85f + level * 0.12f );

		// Rumble that rises with the wind-up. Asserted per charging tick, so letting go, losing the
		// charge or dying all end it on their own; FireChargeJump cuts it early into the launch pulse.
		if ( DrivesHaptics )
		{
			float chargeFraction = Math.Clamp( _chargeJumpTime / Math.Max( 0.0001f, maxChargeTime ), 0f, 1f );
			Haptics.Sustain( HapticChannel.ChargeJump,
				Utils.Map( chargeFraction, 0f, 1f, CHARGE_JUMP_HAPTIC_MIN, CHARGE_JUMP_HAPTIC_MAX, true, EasingType.Linear ),
				0f, Haptics.TONE_HEAVY );
		}
	}

	void AccumulateChargeJumpAim( float dt, bool fromWall )
	{
		if ( fromWall )
		{
			if ( UpPressed ) _chargeJumpAimTime += dt;
			if ( DownPressed ) _chargeJumpAimTime -= dt;
		}
		else
		{
			if ( RightPressed ) _chargeJumpAimTime += dt;
			if ( LeftPressed ) _chargeJumpAimTime -= dt;
		}
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Fire the wound-up charge jump (see <see cref="HandleChargeJump"/>). The launch SPEED lerps
	/// with the charge fraction; the ANGLE (measured off the surface) lerps from 90° straight up toward
	/// <see cref="CHARGE_JUMP_MIN_ANGLE_DEG"/> by the magnitude of the net horizontal aim, and its side is
	/// the aim's sign. Both components are set directly so a character with zero air friction preserves the
	/// charged trajectory rather than bleeding its horizontal overflow through <c>ExtraVelX</c>.
	/// <paramref name="powerScale"/> scales the final launch speed (a sticky tear-out fires below full
	/// strength — see <see cref="HandleStickyChargeJump"/>). <paramref name="launchWall"/> names the wall
	/// the launch leaves when the caller knows it better than the tick-start probe does (the sticky
	/// tear-out's released block: a moving face can recede past the 1px probe between the last glue
	/// re-pin and this tick's flag refresh, leaving the proximity entity empty).</summary>
	void FireChargeJump( float powerScale = 1f, Entity2D launchWall = null )
	{
		Direction chargeSurfaceDirection = _chargeJumpSurfaceDirection;
		bool fromWall = chargeSurfaceDirection == Direction.Left || chargeSurfaceDirection == Direction.Right;
		bool fromCeiling = chargeSurfaceDirection == Direction.Up;
		// Captured BEFORE the detach below (the dust runs after it): an Up surface is the gravity-frame
		// ceiling only when the cling grip owns the charge; a sticky Up-face fire arrives here already
		// ClearStuck→Free and means the literal overhead face (see ChargeJumpAimDirection).
		bool fromFrameCeiling = fromCeiling && _attach == Attachment.CeilingCling;
		float maxChargeTime = fromWall ? CHARGE_WALL_JUMP_MAX_TIME : CHARGE_JUMP_MAX_TIME;
		float minChargeSpeed = fromWall ? CHARGE_WALL_JUMP_MIN_SPEED : CHARGE_JUMP_MIN_SPEED;
		float maxChargeSpeed = fromWall ? CHARGE_WALL_JUMP_MAX_SPEED : CHARGE_JUMP_MAX_SPEED;

		float chargeFrac = Math.Clamp( _chargeJumpTime / Math.Max( 0.0001f, maxChargeTime ), 0f, 1f );
		float speed = Utils.Map( chargeFrac, 0f, 1f, minChargeSpeed, maxChargeSpeed, true, EasingType.Linear ) * powerScale;

		Vector2 launchDirection = ChargeJumpAimDirection( chargeSurfaceDirection );

		// Launch directly away from the charged surface. Floor rise is clamped in ApplyGravity next tick;
		// the character's MaxRiseSpeed is set >= CHARGE_JUMP_MAX_SPEED so a straight-up max floor jump
		// isn't throttled. Ceiling launches travel downward with normal gravity.
		VelX = 0f;
		VelY = 0f;
		ExtraVelX = 0f;
		ExtraVelY = 0f;
		VelX = launchDirection.x * speed;
		VelY = launchDirection.y * speed;

		// Leaving the surface: spend the coyote windows + let go of any ridden block so the launch is clean.
		_groundedLeniencyCounter = 0;
		_wallJumpLeniencyCounter = 0;
		_wallDirection = Direction.None;
		_wallJumpWallEntity = null;
		_jumpedThisTick = true;
		if ( fromCeiling && _attach == Attachment.CeilingCling )
			Detach();
		else
			DetachPlatform();
		if ( fromWall )
		{
			// A charge wall jump launches off the same proximity-probe contact as the normal wall
			// jump — award the launched-off face's press the same way (see PressWallLaunchSurface).
			// The free wall charge reads the live colliding entity (current: firing required
			// touchingChargedSurface on this tick's flags); the sticky tear-out passes its released
			// block explicitly, because a moving face can recede past the tick-start probe.
			PressWallLaunchSurface( chargeSurfaceDirection,
				launchWall ?? (chargeSurfaceDirection == Direction.Left ? _collidingEntityLeft : _collidingEntityRight) );
			_suppressedChargeSurfaceDirection = chargeSurfaceDirection;
			_chargeWallJumpGravityTimer = Math.Max( 0f, CHARGE_WALL_JUMP_GRAVITY_SUPPRESS_TIME );
		}
		else if ( fromCeiling )
		{
			_suppressedChargeSurfaceDirection = Direction.Up;
		}
		_chargeJumpSurfaceDirection = Direction.None;

		// A meatier launch cue the harder it was charged (pitch drops as power rises → a deeper "spring").
		float amt = Utils.Map( chargeFrac, 0f, 1f, 0f, 1f, true, EasingType.Linear );
		Audio.PlaySfx( Character.Audio.Jump, Position, Utils.Map( amt, 0f, 1f, 0.7f, 1.0f, true, EasingType.Linear ), Utils.Map( amt, 0f, 1f, 1.1f, 0.8f, true, EasingType.Linear ) );
		// End the wind-up hum on the same tick the launch fires, so the two never overlap.
		Haptics.ReleaseSustain( HapticChannel.ChargeJump );
		if ( DrivesHaptics )
			Haptics.Pulse( Utils.Map( amt, 0f, 1f, 0.4f, 1f, true, EasingType.Linear ), 0.14f, 0f, Haptics.TONE_HEAVY );

		// A burst of launch dust off the actual charged surface, scaled by charge. A named WALL or
		// sticky face is a fixed physical side; None = the gravity floor, and a cling-owned Up is the
		// gravity-frame ceiling (world up normally, the topside below in a reverse-gravity field).
		Vector2 towardSurface = fromFrameCeiling
			? new Vector2( 0f, GravitySign )
			: chargeSurfaceDirection != Direction.None
				? DirectionVector( chargeSurfaceDirection )
				: new Vector2( 0f, -GravitySign );
		Vector2 awayFromSurface = -towardSurface;
		Vector2 surfaceTangent = new Vector2( -towardSurface.y, towardSurface.x );
		Vector2 pos = Position + towardSurface * 4f + surfaceTangent * Rng.CosmeticInt( -4, 4 );
		int n = Rng.CosmeticInt( 4, 8 ) + (int)MathF.Round( amt * 8f );
		for ( int i = 0; i < n; i++ )
		{
			Stage.AddParticle(
				pos,
				(awayFromSurface * Rng.CosmeticFloat( 0f, 1f )
					+ surfaceTangent * Rng.CosmeticFloat( -1f, 1f ) * 1.5f)
					* Rng.CosmeticFloat( 25.0f, 55.0f + amt * 40f ),
				Rng.CosmeticFloat( 0.90f, 0.95f ),
				Globals.GRAVITY_STR_DUST,
				ParticleKind.Dust,
				Rng.CosmeticFloat( 0.25f, 0.5f ),
				Rng.CosmeticInt( 2, 5 )
			);
		}
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Cancel the wind-up on an away-from-surface press: the wound power is discarded and the
	/// player gets a tiny repel pop (<see cref="CHARGE_CANCEL_POP_SPEED"/>) straight off the surface —
	/// enough to read as "let go", nowhere near a launch. Same bookkeeping as a fire (coyote windows spent,
	/// grip released, the surface suppressed until contact breaks or the hold lifts) so the wind-up can't
	/// re-arm on the pop tick's stale contact and the release Up can't be buffered into a ground jump.
	/// The floor pop has no suppression to set: a rising body fails the ground-charge start gate, and a
	/// re-landing with Down still held legitimately winds up again. Cosmetics: a bubble-pop ring of tiny
	/// motes off the contact point plus its own pop cue. Sticky charges never reach here.</summary>
	void CancelChargeJumpWithPop()
	{
		Direction surface = _chargeJumpSurfaceDirection;
		bool fromWall = surface == Direction.Left || surface == Direction.Right;
		bool fromCeiling = surface == Direction.Up;
		// Same face convention as ChargeJumpAimDirection: a cling-owned Up is the gravity-frame ceiling and
		// None the gravity-frame floor, while Up/Down without the cling are the PHYSICAL faces a sticky
		// wind-up stored (its block can die mid-wind and hand the live charge to the free handler).
		Vector2 towardSurface = fromWall
			? DirectionVector( surface )
			: fromCeiling ? new Vector2( 0f, _attach == Attachment.CeilingCling ? GravitySign : 1f )
			: surface == Direction.Down ? new Vector2( 0f, -1f )
			: new Vector2( 0f, -GravitySign );
		Vector2 away = -towardSurface;

		// Only the axis normal to the surface is replaced; the tangent keeps whatever it had (nothing,
		// for a planted wind-up). VelY rather than ExtraVelY so gravity governs the floor/ceiling pop.
		if ( fromWall ) { VelX = away.x * CHARGE_CANCEL_POP_SPEED; ExtraVelX = 0f; }
		else { VelY = away.y * CHARGE_CANCEL_POP_SPEED; ExtraVelY = 0f; }

		_groundedLeniencyCounter = 0;
		_wallJumpLeniencyCounter = 0;
		_wallDirection = Direction.None;
		_wallJumpWallEntity = null;
		_jumpedThisTick = true;
		if ( fromCeiling && _attach == Attachment.CeilingCling )
			Detach();
		else
			DetachPlatform();
		if ( fromWall || fromCeiling )
			_suppressedChargeSurfaceDirection = surface;

		_chargingJump = false;
		_chargeJumpTime = 0f;
		_chargeJumpAimTime = 0f;
		_chargeJumpSurfaceDirection = Direction.None;

		Audio.PlaySfx( SfxType.SpringChargeCancel, Position, 0.55f );
		Haptics.ReleaseSustain( HapticChannel.ChargeJump );
		if ( DrivesHaptics )
			Haptics.Pulse( 0.2f, 0.05f, away.x * 0.5f, Haptics.TONE_CRISP, EasingType.ExpoEaseOut );

		// Bubble pop: a ring of 1px motes bursting out of the contact point across the away-facing half
		// circle — quick, short-lived, near-weightless, so it reads as a skin bursting, not a dust kick.
		Vector2 surfaceTangent = new Vector2( -towardSurface.y, towardSurface.x );
		Vector2 pos = Position + towardSurface * 4f;
		int n = Rng.CosmeticInt( 6, 9 );
		for ( int i = 0; i < n; i++ )
		{
			float angle = Rng.CosmeticFloat( -MathF.PI * 0.5f, MathF.PI * 0.5f );
			Vector2 dir = away * MathF.Cos( angle ) + surfaceTangent * MathF.Sin( angle );
			Stage.AddParticle(
				pos + dir * 2f,
				dir * Rng.CosmeticFloat( 30.0f, 55.0f ),
				Rng.CosmeticFloat( 0.82f, 0.88f ),
				Globals.GRAVITY_STR_DUST * 0.3f,
				ParticleKind.Dust,
				Rng.CosmeticFloat( 0.15f, 0.3f ),
				1
			);
		}
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Cosmetic cue for a charge wind-up CANCELLED by losing its surface — slid off the end of
	/// a wall past the cling window, the floor moved out from under us, the ceiling grip broke, or a
	/// wall/ceiling charge touched down. The wound power is simply gone (no launch, no release to react
	/// to), so mark the loss: a quiet fizzle blip plus a few slow dust motes drifting off the charged
	/// surface, both scaled by how far the wind-up got. A barely-started charge fizzles silently —
	/// nothing worth mourning was lost. Reads the wind-up state, so call BEFORE clearing it.</summary>
	void PlayChargeLostCue()
	{
		bool fromWall = _chargeJumpSurfaceDirection == Direction.Left
			|| _chargeJumpSurfaceDirection == Direction.Right;
		float maxChargeTime = fromWall ? CHARGE_WALL_JUMP_MAX_TIME : CHARGE_JUMP_MAX_TIME;
		float chargeFrac = Math.Clamp( _chargeJumpTime / Math.Max( 0.0001f, maxChargeTime ), 0f, 1f );
		if ( chargeFrac < 0.1f ) return;

		// Quiet, and the pitch sags with the lost power — the bigger the wind-up, the sadder the fizzle.
		Audio.PlaySfx( SfxType.SpringChargeLost, Position,
			volume: Utils.Map( chargeFrac, 0f, 1f, 0.25f, 0.45f, true, EasingType.Linear ),
			pitch: Utils.Map( chargeFrac, 0f, 1f, 1.1f, 0.9f, true, EasingType.Linear ) );

		// Very subtle: a few slow motes seeping off the surface the charge was wound against (a fraction
		// of the launch burst's count/speed, near-weightless so they hang and disperse). Up = the
		// gravity-frame ceiling, same convention as the launch dust.
		Vector2 towardSurface = _chargeJumpSurfaceDirection == Direction.Up
			? new Vector2( 0f, GravitySign )
			: _chargeJumpSurfaceDirection != Direction.None
				? DirectionVector( _chargeJumpSurfaceDirection )
				: new Vector2( 0f, -GravitySign );
		Vector2 awayFromSurface = -towardSurface;
		Vector2 surfaceTangent = new Vector2( -towardSurface.y, towardSurface.x );
		Vector2 pos = Position + towardSurface * 4f;
		int n = 2 + (int)MathF.Round( chargeFrac * 3f );
		for ( int i = 0; i < n; i++ )
		{
			Stage.AddParticle(
				pos + surfaceTangent * Rng.CosmeticFloat( -3f, 3f ),
				(awayFromSurface * Rng.CosmeticFloat( 0.2f, 1f )
					+ surfaceTangent * Rng.CosmeticFloat( -0.6f, 0.6f ))
					* Rng.CosmeticFloat( 8.0f, 18.0f ),
				Rng.CosmeticFloat( 0.90f, 0.95f ),
				Globals.GRAVITY_STR_DUST * 0.25f,
				ParticleKind.Dust,
				Rng.CosmeticFloat( 0.3f, 0.5f ),
				Rng.CosmeticInt( 1, 2 )
			);
		}
	}

	Vector2 ChargeJumpAimDirection( Direction surfaceDirection )
	{
		float aimFraction = _chargeJumpTime > 0.0001f
			? Math.Clamp( _chargeJumpAimTime / _chargeJumpTime, -1f, 1f )
			: 0f;
		float tangentDirection = MathF.Sign( aimFraction );
		float angle = Utils.Map( MathF.Abs( aimFraction ), 0f, 1f, 90f, CHARGE_JUMP_MIN_ANGLE_DEG, true, EasingType.Linear )
			* (MathF.PI / 180f);
		float normal = MathF.Sin( angle );
		float tangent = MathF.Cos( angle ) * tangentDirection;

		if ( surfaceDirection == Direction.Left || surfaceDirection == Direction.Right )
		{
			float awayFromWall = surfaceDirection == Direction.Left ? 1f : -1f;
			return new Vector2( awayFromWall * normal, GravitySign * tangent );
		}
		// Up is the ceiling charge's FRAME label only while the cling grip owns the charge: launch
		// toward the frame floor (down normally; UP in a reverse-gravity field, away from the gripped
		// topside below). A STICKY charge stores PHYSICAL faces in the same field — its Up is a literal
		// overhead face in ANY gravity, and it is never cling-attached (Stuck during the wind-up,
		// already ClearStuck→Free by fire time), so it keeps the fixed world-down launch.
		if ( surfaceDirection == Direction.Up )
			return new Vector2( tangent, (_attach == Attachment.CeilingCling ? -GravitySign : -1f) * normal );
		// A named Down face (a sticky charge wound while standing on the block): away from the face is
		// world up regardless of gravity — unlike None (the free ground charge), which launches away
		// from whatever the CURRENT gravity floor is.
		if ( surfaceDirection == Direction.Down )
			return new Vector2( tangent, normal );

		return new Vector2( tangent, GravitySign * normal );
	}

	/// <summary>Rolls the body sprite so its head points along <paramref name="head"/> (null = upright).</summary>
	void SetSpriteHead( Vector2? head )
	{
		_spriteHead = head;
		_sprite.GameObject.LocalRotation = head is Vector2 h ? SpriteLayer.FlatRotation( h ) : Rotation.Identity;
	}

	void CreateChargeAimIndicator()
	{
		if ( !HAS_CHARGE_JUMP && !HAS_CHARGE_WALL_JUMP && !HAS_CHARGE_CEILING_JUMP ) return;

		_chargeAimIndicator = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", Vector2.One, "idle", childOrder: 2 );
		_chargeAimIndicator.Opaque = false;   // drawn at 0.9 alpha
		_chargeAimIndicator.AlphaCutoff = 0f;
		_chargeAimIndicator.Enabled = false;
	}

	void DestroyChargeAimIndicator()
	{
		_chargeAimIndicator?.GameObject?.Destroy();
		_chargeAimIndicator = null;
	}

	/// <summary>If the charge-jump aim indicator should be visible. </summary>
	public static bool ShowChargeAimIndicator { get; set; } = false;

	void UpdateChargeAimIndicator()
	{
		bool visible = ShowChargeAimIndicator && _chargingJump && !IsDead && _chargeAimIndicator is not null;
		if ( _chargeAimIndicator is not null ) _chargeAimIndicator.Enabled = visible;
		if ( !visible ) return;

		bool fromWall = _chargeJumpSurfaceDirection == Direction.Left
			|| _chargeJumpSurfaceDirection == Direction.Right;
		Vector2 direction = ChargeJumpAimDirection( _chargeJumpSurfaceDirection );
		float maxChargeTime = fromWall ? CHARGE_WALL_JUMP_MAX_TIME : CHARGE_JUMP_MAX_TIME;
		float chargeFraction = Math.Clamp( _chargeJumpTime / Math.Max( 0.0001f, maxChargeTime ), 0f, 1f );
		float length = Utils.Map( chargeFraction, 0f, 1f, 4f, 5.25f, true, EasingType.Linear );
		Color lowCharge = new Color( 0.55f, 0.82f, 0.36f );
		Color fullCharge = new Color( 0.82f, 0.95f, 0.50f );

		_chargeAimIndicator.Size = new Vector2( length, 1f );
		_chargeAimIndicator.Color = Color.Lerp( lowCharge, fullCharge, chargeFraction ).WithAlpha( 0.9f );
		Vector2 position = direction * 9.5f;
		_chargeAimIndicator.GameObject.LocalPosition = new Vector3( position.x, position.y, 2f * SpriteLayer.LAYER_Z_STEP );
		_chargeAimIndicator.GameObject.LocalRotation = SpriteLayer.FlatRotation( new Vector2( -direction.y, direction.x ) );
	}

	// ----------------------------------------------------------------------------------------
	void HandleVerticalJumping()
	{
		// Reset the once-per-tick jump guard (set by whichever of ground/wall/air jump fires, so a
		// single Up press can't trigger more than one of them). A dash that fired earlier owns this
		// tick's directional input too, preventing a vertical dash from stacking a buffered jump.
		_jumpedThisTick = _dashedThisTick;

		// Reverse-gravity field flips "down": gravity pulls up, so the player rests on the CEILING and a
		// jump must push them DOWN (away from it). gSign flips every vertical sign in this ground-jump path
		// (+1 normal → jump up off the floor; −1 reversed → jump down off the ceiling), and onFloor is the
		// surface gravity currently holds us against (the floor normally, the ceiling when reversed).
		float gSign = GravitySign;
		bool onFloor = _gravityReversed ? CollidingUp : CollidingDown;

		// Only refresh the coyote window while we're actually settled on the surface (not launching off
		// it). On a jump/bounce frame the surface flag is still true but VelY is already directed away;
		// refreshing then would hand out a second free jump (double-jump or bounce-then-jump), which isn't
		// what leniency is for. `VelY * gSign <= 0` = "not yet moving away from the floor" for either sign.
		if ( onFloor && VelY * gSign <= 0 )
		{
			_groundedLeniencyCounter = NUM_GROUNDED_LENIENCY_FRAMES;
			if ( !_abilities.HasSolar )
				_airJumpsUsed = 0;  // Solar's air jump is recharged only by gathering energy
			if ( _abilities.HoverRefreshOnFloor )
				RefreshHoverFrames();
		}
		else if ( _groundedLeniencyCounter > 0 )
			_groundedLeniencyCounter--;

		// YIELD to the wall jump when the press is really a wall jump: airborne (coyote) AND actively
		// hugging a wall AND the wall-jump gate will accept this same press (HandleWallJumping runs later
		// this tick and re-arms its window off the live hug before its launch check, so yielding hands the
		// press straight to it). Keyed on a LIVE hug — not the wall coyote window — so a stale window from
		// a wall brushed frames ago can't steal an open-air ledge coyote jump. Without this, running off a
		// floor into a wall gave a plain vertical ground jump inside the coyote window but a proper
		// away-kick wall jump one frame after it expired — the same press meaning two different moves
		// across a frame boundary. The neutral/CanNeutralWallJump gate is mirrored from HandleWallJumping
		// so a press the wall jump would REJECT (bare Up, no neutral wall jump) still ground-jumps instead
		// of being swallowed. _suppressWallRearm (stale contact right after a wall-jump launch) blocks the
		// yield the same way it blocks the wall window re-arming.
		bool neutralPress = !LeftPressed && !RightPressed;
		// !WallLaunchIsSpiked mirrors the wall jump's spike refusal (sim v42+): a hug on live teeth can't
		// take the press, so the coyote ground jump keeps it instead of both handlers declining.
		bool yieldToWallJump = !onFloor && HuggingWall && !_suppressWallRearm && CAN_WALL_JUMP
			&& !( neutralPress && !_abilities.CanNeutralWallJump )
			&& !WallLaunchIsSpiked( HuggingLeftWall ? Direction.Left : Direction.Right );

		// The Flipper has no jump — a grounded up-press flips its gravity instead (HandleGravityFlip owns
		// that press, ran just before this), so never fire a normal ground jump for it. A charge-jumper
		// mid-wind-up (`_chargingJump`) also swallows the away-from-floor jump key — "Up does nothing while
		// charging" — so the charge is the only jump that fires until Down is released (HandleChargeJump).
		// LaunchFloorIsSpiked (sim v42+): no jump off live teeth the probe sees but the body hasn't touched.
		if ( _groundedLeniencyCounter > 0 && (UpJustPressed || _jumpBufferCounter > 0) && !_jumpedThisTick
			&& !CAN_FLIP_GRAVITY && !_chargingJump && !yieldToWallJump && !LaunchFloorIsSpiked )
		{
			float jumpPower = JUMP_POWER * RollFloorJumpStrengthFactor();
			Vector2 pos = new Vector2( X + Rng.CosmeticInt( -4, 4 ), Y - 4 * gSign );
			int n = Rng.CosmeticInt( 2, 7 );
			for ( int i = 0; i < n; i++ )
			{
				Stage.AddParticle(
					pos,
					new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( 0f, 1f ) * gSign ) * Rng.CosmeticFloat( 20.0f, 50.0f ),
					Rng.CosmeticFloat( 0.90f, 0.95f ),
					Globals.GRAVITY_STR_DUST,
					ParticleKind.Dust,
					Rng.CosmeticFloat( 0.25f, 0.5f ),
					Rng.CosmeticInt( 2, 5 )
				);
			}

			// Stack the jump on top of any existing upward velocity instead of overwriting it, so a
			// jump that lands on the same frame as (or within the grounded-leniency window after) an
			// inertia launch — the upward fling off a block that slammed to a stop, or jumping off a
			// still-rising platform — combines BOTH impulses. Math.Max keeps a plain grounded jump at
			// exactly JUMP_POWER (VelY≈0 there) and never penalises a jump taken while falling
			// (VelY<0 → JUMP_POWER wins). Same pattern as the wall-jump below. The few-frame window is
			// free: CollidingDown is still true on the launch frame (we haven't moved yet) so the
			// leniency counter refreshes, then coyote-decrements over the next several frames.
			// Down-held special ground jumps (Mario-style), opt-in: a LONG JUMP when running (low + far)
			// or a BACK-FLIP from a standstill (high + backward). Distinguished by takeoff speed. When
			// neither applies it falls through to the normal jump below.
			bool didSpecialJump = false;
			if ( DownPressed && (CAN_LONG_JUMP || CAN_BACK_FLIP) )
			{
				// "Running" is based on INPUT, not velocity: holding a horizontal direction counts as
				// running even if wind / a wall is stopping actual movement (VelX≈0). No horizontal input
				// held = a standstill (back-flip). Both held cancels to a standstill too.
				float inputDir = (RightPressed ? 1f : 0f) - (LeftPressed ? 1f : 0f);
				bool running = inputDir != 0f;
				if ( running && CAN_LONG_JUMP )
				{
					VelY = gSign * jumpPower * LONG_JUMP_HEIGHT_FACTOR;          // flatter arc (down when reversed)
					ApplyHorizontalLaunch( inputDir, LONG_JUMP_HORIZONTAL_BOOST ); // launch the way they're driving
					didSpecialJump = true;
				}
				else if ( !running && CAN_BACK_FLIP )
				{
					VelY = gSign * jumpPower * BACK_FLIP_HEIGHT_FACTOR;             // extra height (down when reversed)
					float facingDir = (_facing == Direction.Left) ? -1f : 1f;
					// Initial FORWARD pop in VelX (NOT ExtraVelX): the sustained BACKWARD push lives in the
					// ExtraVelX channel, so keeping the forward pop out of it means a wall cancelling the pop
					// (both VelX+ExtraVelX are zeroed on a wall hit) can't "refund" into a bigger backward
					// launch — the backward arc is the same whether or not you brush a wall in front. The
					// backward direction is latched now so later facing changes mid-air don't flip it.
					VelX = Math.Clamp( facingDir * BACK_FLIP_HORIZONTAL_BOOST, -MAX_X_SPEED, MAX_X_SPEED );
					_backFlipping = true;
					_backFlipBackDir = -facingDir;
					_backFlipTimer = 0f;
					didSpecialJump = true;
				}
			}

			if ( !didSpecialJump )
			{
				VelY = gSign * Math.Max( jumpPower, gSign * VelY + jumpPower );
				if ( JUMP_SPEED_BONUS > 0f && VelX != 0f )
					ApplyHorizontalLaunch( MathF.Sign( VelX ), JUMP_SPEED_BONUS * Math.Abs( VelX ) );
				// Hop-forward: a horizontal kick in the FACING direction (the way the player last turned) on a
				// ground jump — always fires, even from a standstill, and is independent of VelX so external
				// pushes (wind, platforms) don't redirect it. Additive momentum (see ApplyHorizontalLaunch):
				// fills VelX to the walk cap then spills into ExtraVelX so it carries and never brakes a
				// faster run. 0 = off.
				if ( JUMP_HORIZ_EXIT_BOOST > 0f )
					ApplyHorizontalLaunch( (_facing == Direction.Left) ? -1f : 1f, JUMP_HORIZ_EXIT_BOOST );
			}
			_groundedLeniencyCounter = 0;
			_jumpBufferCounter = 0; // consume any buffered press
			// Consume the wall-jump coyote window too. It arms off any brief side contact (a block edge, a
			// wall, another block's side) that can coincide with being grounded — e.g. standing on a moving
			// block. Without this the window survives this ground jump and keeps counting down; a second Up
			// press a few airborne frames later then fires a phantom wall jump off the STALE _wallDirection
			// (launching up + sideways and stacking VelY very high) with no wall anywhere near. This ground
			// jump owns the press, so the wall coyote is spent. A genuine fresh wall grab re-arms it normally.
			_wallJumpLeniencyCounter = 0;
			_wallDirection = Direction.None;
			_wallJumpWallEntity = null;
			_jumpedThisTick = true;
			_jumpCutArmed = true;
			// Tutorial-ghost "JUMP" callout, flying away from the floor (down off the ceiling when reversed).
			GhostRecorder.Emit( this, GhostFxKind.Jump, Position, new Vector2( 0f, gSign ) );

			// Inherit a platform still moving in the jump direction so jumping off it stacks the
			// boost (a stopped block's Velocity is zero, so it can't phantom-boost the jump).
			// Gravity-signed: in reverse gravity the ride is an underside ride and jumps go down,
			// so the boost comes from a DESCENDING block; a block rising into the player there
			// moves against the jump and must not be inherited (it would cancel the jump).
			if ( _attach == Attachment.Riding && _attachEntity.VelY * gSign > 0 )
				VelY += _attachEntity.VelY;
			DetachPlatform();

			Audio.PlaySfx( Character.Audio.Jump, Position, 0.70f );
			// Light, quick pop on a normal jump.
			if ( DrivesHaptics ) Haptics.Pulse( 0.3f, 0.06f, 0f, Haptics.TONE_NEUTRAL );
		}
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Jumping off a wall reads as pressing it — award the launched-off face's press for any
	/// wall launch (wall jump, wall kick, charge wall jump). These launches arm and fire off the 1px
	/// PROXIMITY probes (CollidingLeft/Right), not physical overlap, so one can legitimately fire with
	/// the player a fraction of a pixel short of the face — braked to a hover beside the wall, or
	/// flush-placed by a rising block's sweep-less corner resolve whose velocity gate refused the
	/// press — and Unpenetrate (the only other press source) never runs. Air acceleration from a
	/// standstill moves ~0.1px/tick, so a flick-toward-wall + jump fires well before the gap closes.
	/// Same impostor rule as Unpenetrate's press; PressSide's own refusals (already pressed, spiked,
	/// switching, max phase) still apply, so a face pressed by genuine contact is a no-op here.
	/// A null/dead/phasing wall (arena wall, obstacle, vanished block) presses nothing. Which wall is
	/// the caller's call: the wall jump passes the face its probe reports this tick (the banked wall
	/// only on the glue-release tick right after a hug) and withholds the press deeper into its coyote
	/// window; the charge wall jump reads the live probe entity; the wall kick presses its banked wall
	/// for its whole window.</summary>
	void PressWallLaunchSurface( Direction wallDirection, Entity2D wall )
	{
		if ( wallDirection is not (Direction.Left or Direction.Right) ) return;
		PressContactFace( wallDirection, wall );
	}

	/// <summary>Winding up a free charge against a surface reads as pressing it, for the same reason
	/// a wall launch does (see <see cref="PressWallLaunchSurface"/>): the wall and ceiling charges arm
	/// off the 1px proximity probes / the cling grab, not physical overlap, so a player who flew in and
	/// braked a hair short of a face can hang there winding up on a face Unpenetrate never pressed.
	/// Pressed ONCE, at wind-up start (PressSide refuses an already-pressed face, so a face pressed by
	/// genuine contact is a no-op); no press launch is scheduled — a wind-up is not a springboard.
	/// <paramref name="surfaceDirection"/> is the charge's stored surface: Left/Right a physical wall
	/// side (live probe entity), Up the GRAVITY-FRAME ceiling (the cling's latched entity + face), None the
	/// gravity-frame floor. The sticky charge never comes here — the glue pressed its face on the grab.</summary>
	void PressChargeSurface( Direction surfaceDirection )
	{
		Direction contactDirection = surfaceDirection switch
		{
			Direction.Left or Direction.Right => surfaceDirection,
			// The cling stores the PHYSICAL face it grabbed, so read it rather than re-deriving from the
			// gravity flag: no dependence on ApplyGravity having already released a frame-mismatched cling.
			Direction.Up => _attach == Attachment.CeilingCling ? _attachFace : (_gravityReversed ? Direction.Down : Direction.Up),
			_ => _gravityReversed ? Direction.Up : Direction.Down,
		};
		Entity2D surface = surfaceDirection == Direction.Up && _attach == Attachment.CeilingCling
			? _attachEntity
			: contactDirection switch
			{
				Direction.Left => _collidingEntityLeft,
				Direction.Right => _collidingEntityRight,
				Direction.Up => _collidingEntityUp,
				_ => _collidingEntityDown,
			};
		if ( PressContactFace( contactDirection, surface ) )
			TraceCharge( $"pressed {OppositeDirection( contactDirection )} face of block@({surface.X:0.0},{surface.Y:0.0}) at wind-up start" );
	}

	/// <summary>Press the face of <paramref name="surface"/> that points back at us across a contact in
	/// <paramref name="contactDirection"/> (surface on our right = its LEFT face). Same impostor rule as
	/// Unpenetrate's press; PressSide's own refusals (already pressed, spiked, switching, max phase) still
	/// apply. A null/dead/phasing/non-block surface (arena wall, obstacle, vanished block) presses nothing.</summary>
	bool PressContactFace( Direction contactDirection, Entity2D surface )
	{
		if ( IsImpostor && !IsSwarmClone ) return false;
		if ( surface is not Block block || block.Replaced || block.IsDead || block.PhasingIn ) return false;
		return PressBlockSide( block, OppositeDirection( contactDirection ) );
	}

	bool PressBlockSide( Block block, Direction direction )
	{
		bool pressed = block.PressSide( direction, this );
		PressedBlockSideThisTick |= pressed;
		return pressed;
	}

	// ----------------------------------------------------------------------------------------
	void HandleWallJumping( float dt )
	{
		// Re-allow leniency re-arm once we've left the wall (no horizontal contact) or passed the apex
		// of our own wall jump (moving back toward the surface gravity holds us against). This keeps the
		// suppression scoped to the brief stale-contact window right after launching, without affecting
		// fresh wall grabs mid-dive or at apex. `VelY * GravitySign <= 0` = "past apex" for either gravity.
		if ( (!CollidingLeft && !CollidingRight) || VelY * GravitySign <= 0.0f )
			_suppressWallRearm = false;

		// HuggingLeft/RightWall bake in the FLOOR-IS-NOT-A-WALL exclusion (see the predicate block's
		// declaration comment for the full story): a side contact whose entity is also our floor — a
		// hairline embed while riding/being caught by a block — must never arm the coyote window, or a
		// phantom wall jump fires several airborne ticks after jumping off the block.
		// WallLaunchIsSpiked (sim v42+): a live spiked face never arms the window.
		if ( HuggingLeftWall && !_suppressWallRearm && !WallLaunchIsSpiked( Direction.Left ) )
		{
			_wallDirection = Direction.Left;
			_wallJumpWallEntity = _collidingEntityLeft;
			_wallJumpLeniencyCounter = NUM_WALL_JUMP_LENIENCY_FRAMES;
		}

		if ( HuggingRightWall && !_suppressWallRearm && !WallLaunchIsSpiked( Direction.Right ) )
		{
			_wallDirection = Direction.Right;
			_wallJumpWallEntity = _collidingEntityRight;
			_wallJumpLeniencyCounter = NUM_WALL_JUMP_LENIENCY_FRAMES;
		}

		if ( _wallJumpDirection != Direction.None )
		{
			_wallJumpTimer -= dt;
			if ( _wallJumpTimer <= 0.0f )
			{
				_wallJumpDirection = Direction.None;
				_wallJumpTimer = 0.0f;
			}
		}

		// A NEUTRAL wall jump (no horizontal held → straight up) is gated per-character: a character
		// without CanNeutralWallJump must lean into/away from the wall to launch, so a bare Up press near
		// the wall does nothing (the leniency window below keeps ticking, so adding a direction and
		// pressing Up again within it still works).
		bool neutralWallJump = !LeftPressed && !RightPressed;

		// Gate on !OnFloor, NOT !CollidingDown: this is what stops a GROUND jump and a WALL jump both firing
		// off one press while standing next to a wall. The ground jump (which ran first) consumed the wall
		// leniency, but the re-arm at the top of THIS method refills it the same tick, so only "we're on the
		// surface" blocks the wall jump here. !OnFloor alone isn't enough though: a COYOTE ground jump (ran
		// off a ledge, airborne, hugging a wall) passes it, so the once-per-tick _jumpedThisTick guard below
		// is what stops that press from stacking a wall jump on top (double sfx + JUMP_POWER + wall power). In a reverse-gravity field the surface is the CEILING
		// (CollidingUp), so !CollidingDown would be true up there and let both jumps fire — OnFloor tracks the
		// flipped gravity. (OnFloor == CollidingDown in normal gravity, so this is a no-op there.)
		// !WallLaunchIsSpiked (sim v42+): a window armed on a safe stretch can't fire once the hug has
		// drifted onto that face's live teeth.
		if ( _wallJumpLeniencyCounter > 0 && !OnFloor && (UpJustPressed || _jumpBufferCounter > 0)
			&& !_jumpedThisTick
			&& CAN_WALL_JUMP
			&& !( neutralWallJump && !_abilities.CanNeutralWallJump )
			&& !WallLaunchIsSpiked( _wallDirection ) )
		{
			// Lean determines the launch: AWAY from the wall = flat dive (clears gaps), INTO the wall =
			// normal arc, and NEUTRAL (no horizontal held) = straight up. No new input keys.
			bool dive = _abilities.CanWallDive
					 && ((_wallDirection == Direction.Left && RightPressed)
					  || (_wallDirection == Direction.Right && LeftPressed));
			bool neutral = neutralWallJump;
			float strengthFactor = RollWallJumpStrengthFactor();
			float verticalPower = (dive ? WALL_JUMP_DIVE_VERTICAL_POWER * SolarWallDiveFactor : WALL_JUMP_VERTICAL_POWER)
				* strengthFactor;
			float horizontalPower = WALL_JUMP_HORIZONTAL_POWER * strengthFactor;

			// GravitySign flips the launch so a wall jump in a reverse-gravity field kicks DOWN off the
			// wall (away from the ceiling) instead of up, mirroring the ground jump.
			float verticalLaunchSpeed = _move.WallJumpStacksVerticalVelocity
				? Math.Max( verticalPower, GravitySign * VelY + verticalPower )
				: verticalPower;
			VelY = GravitySign * verticalLaunchSpeed;

			Vector2 pos = new Vector2( X, Y + Rng.CosmeticInt( -6, 6 ) );
			int n = Rng.CosmeticInt( 1, 5 );
			for ( int i = 0; i < n; i++ )
			{
				Stage.AddParticle(
					pos,
					new Vector2( Rng.CosmeticFloat( -1f, 1f ), GravitySign ) * Rng.CosmeticFloat( 20.0f, 50.0f ),
					Rng.CosmeticFloat( 0.90f, 0.95f ),
					Globals.GRAVITY_STR_DUST,
					ParticleKind.Dust,
					Rng.CosmeticFloat( 0.25f, 0.5f ),
					Rng.CosmeticInt( 2, 5 )
				);
			}

			if ( neutral )
			{
				// Straight up: no horizontal kick, no momentum window.
				VelX = 0.0f;
			}
			else if ( _wallDirection == Direction.Left )
			{
				VelX = horizontalPower;
				if ( dive ) ExtraVelX = WALL_JUMP_DIVE_EXTRA_HORIZONTAL * SolarWallDiveFactor;
				_wallJumpDirection = Direction.Right;
			}
			else if ( _wallDirection == Direction.Right )
			{
				VelX = -horizontalPower;
				if ( dive ) ExtraVelX = -WALL_JUMP_DIVE_EXTRA_HORIZONTAL * SolarWallDiveFactor;
				_wallJumpDirection = Direction.Left;
			}

			if ( dive )
			{
				// Brief hit-stop to punch up the flatter, faster dive kick.
				Stage.RequestHitStop( HIT_STOP_WALL_DIVE_FRAMES );

				// Extra burst shooting vertically (up and down) to sell the flatter, faster kick.
				int dn = Rng.CosmeticInt( 4, 8 );
				for ( int i = 0; i < dn; i++ )
				{
					Stage.AddParticle(
						pos,
						new Vector2( Rng.CosmeticFloat( -0.2f, 0.2f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 50.0f, 90.0f ),
						Rng.CosmeticFloat( 0.90f, 0.95f ),
						Globals.GRAVITY_STR_DUST,
						ParticleKind.Dust,
						Rng.CosmeticFloat( 0.25f, 0.5f ),
						Rng.CosmeticInt( 2, 5 )
					);
				}
			}

			_wallJumpTimer = WALL_JUMP_TIME;
			_jumpedThisTick = true;
			_jumpCutArmed = true;
			_jumpBufferCounter = 0; // consume any buffered press (same as the grounded jump)
			// Tutorial-ghost "JUMP" callout, flying up and away from the wall (before _wallDirection is spent).
			GhostRecorder.Emit( this, GhostFxKind.Jump, Position,
				new Vector2( _wallDirection == Direction.Left ? 1f : -1f, GravitySign ) );

			// Jumping off the wall awards its press. Contact may never have pressed via Unpenetrate —
			// this launch runs off the 1px proximity probe, so it can fire without the player ever
			// physically overlapping the face (see PressWallLaunchSurface). Only while the probe still
			// reports a wall on that side, though, or on the first tick after the hug ended: the coyote window
			// outlives contact by several frames (a swap into a block's side hugs it, then the repel carries
			// us 10px+ away) and pressing a face we're visibly nowhere near reads wrong. The one-tick grace
			// is the glue-release frame: a neutral jump / lean-away dive lets go of the direction, which
			// drops the WallHug glue (HandleMovingPlatforms, BEFORE this tick's probe refresh), so a
			// receding block we were flush against a moment ago can already be past the 1px probe. A full
			// counter == hugged last tick or this one: only the re-arm above fills it, only the non-hugging
			// decay below lowers it, and every path that skips this method (twin dash, squash, mantle,
			// harden, sticky grab, character change) spends it on entry. The launch itself is unchanged.
			bool probeLive = _wallDirection == Direction.Left ? CollidingLeft : CollidingRight;
			Entity2D probedWall = _wallDirection == Direction.Left ? _collidingEntityLeft : _collidingEntityRight;
			bool huggedLastTick = _wallJumpLeniencyCounter >= NUM_WALL_JUMP_LENIENCY_FRAMES;
			// Press the face the probe reports NOW rather than the banked one: on a stacked column the
			// first-hit probe can hand over to the neighbouring block on a non-arming tick (direction
			// released), and the block we launch off is the one we're touching. The banked wall stands in
			// only when nothing pressable is on the probe (face already past it, obstacle, arena wall).
			Entity2D launchWall = probeLive && probedWall is Block ? probedWall
				: huggedLastTick ? _wallJumpWallEntity
				: null;
			if ( launchWall is not null ) PressWallLaunchSurface( _wallDirection, launchWall );

			// Consume the leniency window the same way the grounded jump zeroes _groundedLeniencyCounter.
			// Without this the window stays open for the remaining frames and a second UpJustPressed mid-air
			// (no ground, no wall) would fire another jump — an unintended double jump. Letting go of the
			// wall and jumping still works: that's a single jump which also consumes the window here.
			_wallJumpLeniencyCounter = 0;
			_wallDirection = Direction.None;
			_wallJumpWallEntity = null;
			// Consume the GROUND coyote window too (the mirror of the ground jump spending the wall window):
			// since the coyote ground jump YIELDS to this launch while hugging a wall (see
			// HandleVerticalJumping), a wall jump can now fire with the ground window still open. Leaving it
			// ticking would let a second press moments later fire a phantom mid-air ground jump that STACKS
			// on this rise — the exact double-jump the yield exists to prevent.
			_groundedLeniencyCounter = 0;
			// Block the counter from immediately re-arming off the wall we just launched from (stale
			// CollidingLeft/Right while rising). Cleared at the top of this method once we separate or fall.
			_suppressWallRearm = true;

			DetachPlatform();

			Audio.PlaySfx( Character.Audio.Jump, Position, 0.75f, dive ? 1.35f : 1f );
			// Wall jump; the flatter dive kicks a touch harder.
			if ( DrivesHaptics ) Haptics.Pulse( dive ? 0.6f : 0.4f, 0.08f, 0f, Haptics.TONE_NEUTRAL );
		}

		// The countdown pauses while we're still hugging the wall — but a floor-as-wall contact (see
		// FLOOR-IS-NOT-A-WALL in the predicate block) must not keep an old window alive; the shared
		// predicate already counts it as NOT hugging.
		if ( !HuggingWall && _wallJumpLeniencyCounter > 0 )
		{
			_wallJumpLeniencyCounter--;
			if ( _wallJumpLeniencyCounter <= 0 )
			{
				_wallDirection = Direction.None;
				_wallJumpWallEntity = null;
			}
			// Only release a lingering SIDE-HUG attachment here — never a top-ride (Riding) one. This
			// countdown also ticks while grounded (land on a moving platform within the window), and
			// stripping the Riding attachment every tick killed the glue/carry and the inertia fling for
			// the whole window (HandleMovingPlatforms runs before AdjustYPosition re-parents us).
			if ( _attach == Attachment.WallHug ) Detach();
		}
	}

	// ----------------------------------------------------------------------------------------
	// Mid-air jump (double jump): a bare Up press in the air, with jumps left, gives a fresh jump.
	// Usually refilled on landing (see HandleVerticalJumping); Solar spends stored energy instead.
	// Ground/wall jumps take priority — they run first
	// and set _jumpedThisTick, and we bail while their leniency windows are open — so one Up press can
	// never fire both. Off by default (MaxAirJumps 0). Deterministic (recorded UpJustPressed + fixed state).
	void HandleAirJump()
	{
		if ( MAX_AIR_JUMPS <= 0 ) return;
		if ( _jumpedThisTick ) return;                                              // a ground/wall jump used this press
		if ( OnFloor ) return;                                                      // grounded (floor/ceiling) → the ground jump owns it
		if ( _groundedLeniencyCounter > 0 ) return;                                 // let the ground coyote window win
		// Defer to the wall-jump coyote window ONLY when a wall jump could actually fire for this press.
		// A no-neutral-wall-jump character (CanNeutralWallJump == false) pressing Up with no horizontal
		// held is gated OUT of the wall jump (see the neutral gate in HandleWallJumping). If the air jump
		// also bailed here the press would be swallowed entirely — the jump buffer only re-fires
		// ground/wall jumps — starving the air jump near any wall. In that gated case let the air jump
		// claim the press instead. When a direction IS held (or neutral jumps are allowed) the wall jump
		// still owns the window as before. Same reasoning for CAN_WALL_JUMP: a character with air jumps
		// but no wall jump at all must not defer to a wall jump that can never fire (the window still
		// arms off wall contact regardless of the ability), or its air jump starves near every wall.
		// !WallLaunchIsSpiked (sim v42+): same starvation shape — a window whose wall has drifted onto live
		// teeth refuses to fire, so it must not swallow the air jump either.
		bool wallJumpAvailable = CAN_WALL_JUMP && (LeftPressed || RightPressed || _abilities.CanNeutralWallJump)
			&& !WallLaunchIsSpiked( _wallDirection );
		if ( _wallJumpLeniencyCounter > 0 && wallJumpAvailable ) return;             // let the wall coyote window win
		if ( TowardFloorPressed && !_abilities.AirJumpWhileHoldingDown ) return;   // a held Down swallows the press
		if ( !UpJustPressed ) return;

		bool usesSolarEnergy = _abilities.HasSolar;
		if ( usesSolarEnergy )
		{
			if ( Energy < SolarAbility.AIR_JUMP_ENERGY_COST ) return;
		}
		else if ( _airJumpsUsed >= MAX_AIR_JUMPS )
		{
			return;
		}

		if ( usesSolarEnergy )
			Energy = Math.Max( 0f, Energy - SolarAbility.AIR_JUMP_ENERGY_COST );
		else
			_airJumpsUsed++;
		_jumpedThisTick = true;
		_jumpCutArmed = true;
		if ( _abilities.AirJumpStacksVerticalVelocity )
		{
			float stackedRiseSpeed = Math.Max( AIR_JUMP_POWER, GravitySign * VelY + AIR_JUMP_POWER );
			float baseRiseSpeed = Math.Min( stackedRiseSpeed, MAX_RISE_SPEED );
			VelY = GravitySign * baseRiseSpeed;
			ExtraVelY += GravitySign * (stackedRiseSpeed - baseRiseSpeed);
		}
		else
		{
			VelY = GravitySign * AIR_JUMP_POWER;
		}
		// Directional kick: add a horizontal launch in the held direction without braking faster flight
		// or discarding momentum already carried by ExtraVelX.
		if ( AIR_JUMP_HORIZONTAL_BOOST > 0f )
		{
			float dir = (RightPressed ? 1f : 0f) - (LeftPressed ? 1f : 0f);
			if ( dir != 0f )
				ApplyHorizontalLaunch( dir, AIR_JUMP_HORIZONTAL_BOOST );
		}
		DetachPlatform();

		if ( usesSolarEnergy )
		{
			SolarAbility.EmitAirJumpParticles( this );
		}
		else
		{
			Vector2 pos = new Vector2( X + Rng.CosmeticInt( -4, 4 ), Y - 4 * GravitySign );
			int particleCount = Rng.CosmeticInt( 3, 7 );
			for ( int particleIndex = 0; particleIndex < particleCount; particleIndex++ )
			{
				Stage.AddParticle(
					pos,
					new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 0.5f ) * GravitySign ) * Rng.CosmeticFloat( 30.0f, 70.0f ),
					Rng.CosmeticFloat( 0.90f, 0.95f ),
					Globals.GRAVITY_STR_DUST,
					ParticleKind.Dust,
					Rng.CosmeticFloat( 0.25f, 0.5f ),
					Rng.CosmeticInt( 2, 5 )
				);
			}
		}
		int airJumpsRemaining = usesSolarEnergy
			? (int)(Energy / SolarAbility.AIR_JUMP_ENERGY_COST)
			: MAX_AIR_JUMPS - _airJumpsUsed;
		float airJumpPitch = Character.Audio.ResolveAirJumpPitch( airJumpsRemaining, MAX_AIR_JUMPS );
		Audio.PlaySfx( Character.Audio.AirJump, Position, 0.65f, airJumpPitch );
		// Solar burns energy to fire a rocket rather than flapping: a heavier, longer kick than the
		// ordinary air jump's light pop.
		if ( DrivesHaptics )
		{
			if ( usesSolarEnergy ) Haptics.Pulse( 0.55f, 0.16f, 0f, Haptics.TONE_HEAVY );
			else Haptics.Pulse( 0.3f, 0.06f, 0f, Haptics.TONE_NEUTRAL );
		}
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>After a back-flip, apply the sustained BACKWARD push (opposite the take-off facing) that
	/// arcs the player back over where they jumped from, as a strength envelope over its duration. It runs
	/// for the FULL duration regardless of whether Up is still held; it ends early only if the player lands
	/// or hugs a wall. Once it ends the drift coasts to a stop via the normal ExtraVel decay (which is
	/// suppressed in <see cref="HandleExtraVelocity"/> while the push is active).</summary>
	void HandleBackFlip( float dt )
	{
		if ( !_backFlipping ) return;

		bool wallHug = HuggingWall;
		// Don't treat the launch frame as "landed": a grounded jump fires while CollidingDown is STILL true
		// (we haven't risen off the floor yet) with VelY>0, so ending on a bare CollidingDown would kill the
		// back-flip the same tick it started. Only end on a genuine touchdown (grounded AND settling). Up
		// being released does NOT end it — the backward push runs its full envelope regardless.
		if ( (OnFloor && VelY * GravitySign <= 0f) || wallHug )
		{
			_backFlipping = false; // force stops; the drift now coasts via normal ExtraVel decay
			return;
		}

		// Spend the backward push as a strength ENVELOPE over BACK_FLIP_BACKWARD_DURATION: the accel eases
		// 0%→100%→0% (a smooth sine, peaking at the midpoint) so the backward arc swells in then tapers off
		// instead of cutting out abruptly. Once the duration elapses the force ends and the drift coasts via
		// normal decay.
		float dur = BACK_FLIP_BACKWARD_DURATION;
		if ( dur <= 0f || _backFlipTimer >= dur )
		{
			_backFlipping = false;
			return;
		}
		float strength = MathF.Sin( MathF.PI * (_backFlipTimer / dur) ); // 0 → 1 → 0 across the duration
		_backFlipTimer += dt;
		ExtraVelX = Math.Clamp( ExtraVelX + _backFlipBackDir * BACK_FLIP_BACKWARD_FORCE * strength * dt, -MAX_EXTRA_X_SPEED, MAX_EXTRA_X_SPEED );
	}

	// ----------------------------------------------------------------------------------------
	// Variable jump height: releasing the jump button while still rising clips the remaining upward
	// speed, so a tap is a short hop and a hold is the full jump. Off by default. Armed by any jump
	// (ground/wall/air) and consumed once. Deterministic (reads held Up + fixed state).
	void HandleVariableJumpHeight()
	{
		if ( !VARIABLE_JUMP_HEIGHT ) return;
		float gSign = GravitySign;
		// Disarm only once genuinely settled on the ground (VelY <= 0). NOT on the launch frame: a
		// grounded jump fires while CollidingDown is still true (we haven't moved up yet), so a bare
		// `if (CollidingDown)` here would disarm the cut the same frame it was armed — which is why the
		// cut used to only work after a wall jump (airborne, CollidingDown false). VelY > 0 on the launch
		// frame keeps it armed.
		if ( OnFloor && VelY * gSign <= 0f ) { _jumpCutArmed = false; return; }
		if ( !_jumpCutArmed ) return;

		if ( VelY * gSign <= 0.0f )
			_jumpCutArmed = false;            // past the apex — nothing left to cut
		else if ( !UpPressed )
		{
			VelY *= VARIABLE_JUMP_CUT_FACTOR; // released early while rising → clip the jump short
			_jumpCutArmed = false;
		}
	}

	// ----------------------------------------------------------------------------------------
	// Ground pound: tap Down in the air to slam straight down at GROUND_POUND_SPEED. Off by default
	// (CanGroundPound). While pounding, horizontal input is locked (see HandleHorizontalInput) and the
	// fall-speed cap is lifted so the slam isn't throttled by a low MaxFallSpeed. The pound ends on
	// landing or the moment we're moving upward again (e.g. an air jump). Deterministic (recorded
	// DownJust edge).
	void HandleGroundPound()
	{
		if ( !CAN_GROUND_POUND ) { _groundPounding = false; return; }

		// End an in-progress pound once we land or start moving back away from the floor (an air jump /
		// launch). `VelY * GravitySign >= 0` = "no longer moving into the floor", for either gravity.
		if ( _groundPounding && (OnFloor || VelY * GravitySign >= 0f) )
			_groundPounding = false;

		if ( OnFloor || !DownJustPressed ) return;

		VelY = -GravitySign * GROUND_POUND_SPEED; // slam INTO the floor (down normally, up when reversed)
		ExtraVelY = 0f;
		VelX = 0f;        // commit to a straight-down slam
		ExtraVelX = 0f;
		_groundPounding = true;
	}

	// ----------------------------------------------------------------------------------------
	// Jump buffer: latch a jump press made in the air that DIDN'T already trigger a wall/air jump, so
	// it fires the instant we land (consumed by the buffered check in HandleVerticalJumping). Runs LAST
	// so _jumpedThisTick reflects every jump handler this tick. Off by default (0 frames).
	void HandleJumpBuffer()
	{
		if ( JUMP_BUFFER_FRAMES <= 0 ) return;
		// Age the buffer once per tick, AFTER this tick's ground + wall jump checks have both read it (so
		// they see the same value), and before latching a fresh press below. A jump firing already zeroed it.
		if ( _jumpBufferCounter > 0 ) _jumpBufferCounter--;
		if ( UpJustPressed && !_jumpedThisTick && !OnFloor )
			_jumpBufferCounter = JUMP_BUFFER_FRAMES;
	}

	// ----------------------------------------------------------------------------------------
	// Fling straight AWAY from a freshly-pressed block face at a flat BLOCK_PRESS_BOOST, plus the
	// pressed block's own speed along the launch direction when its leading face fired (see the
	// scheduling in Unpenetrate). Both axes split into base + ExtraVel overflow so the boost carries
	// past the character's caps: horizontal past the walk cap, vertical past the gravity-relative
	// rise/fall caps (ApplyGravity clamps VelY only, so without the split a low-cap flyer — OWL's
	// rise 75 vs boost 160 — kept one movement tick of launch speed before the clamp ate it, and a
	// rising block scooped the player right back up). Detaches from any ridden platform so a
	// top-face launch isn't re-pinned by the glue.
	void ApplyPressLaunch( Direction away )
	{
		float b = BLOCK_PRESS_BOOST + _pressLaunchBlockVel;
		switch ( away )
		{
			case Direction.Left:  VelX = -Math.Min( b, MAX_X_SPEED ); ExtraVelX = (b > MAX_X_SPEED) ? -(b - MAX_X_SPEED) : 0f; break;
			case Direction.Right: VelX =  Math.Min( b, MAX_X_SPEED ); ExtraVelX = (b > MAX_X_SPEED) ?  (b - MAX_X_SPEED) : 0f; break;
			case Direction.Down:
			case Direction.Up:
			{
				// Gravity-relative like ApplyGravity's clamp: an Up launch under reverse gravity is
				// motion TOWARD gravity, so it splits against the fall cap, not the rise cap.
				float y = away == Direction.Up ? b : -b;
				float g = GravitySign;
				VelY = Math.Clamp( y * g, -MAX_FALL_SPEED, MAX_RISE_SPEED ) * g;
				ExtraVelY = y - VelY;
				break;
			}
		}

		// Kick biased the way we're flung, and scaled by the launch that actually results rather than by
		// the flat tuning value — pressing a block's LEADING face stacks its speed on top. The boost test
		// keeps the map's input range non-degenerate; it always passes here, since a non-zero boost is
		// what schedules the launch in the first place.
		if ( DrivesHaptics && BLOCK_PRESS_BOOST > 0f )
		{
			Vector2 awayVector = DirectionVector( away );
			Haptics.Pulse( Utils.Map( b, BLOCK_PRESS_BOOST, BLOCK_PRESS_BOOST * 2f, 0.45f, 0.7f, true, EasingType.Linear ),
				0.09f, awayVector.x * 0.5f, Haptics.TONE_NEUTRAL );
		}

		DetachPlatform();
	}

	// ----------------------------------------------------------------------------------------
	void UpdateAnimation( Direction newXDirection )
	{
		UpdateChargeAimIndicator();
		Direction xDir = _xDirection;
		if ( newXDirection != Direction.None )
			xDir = newXDirection;

		if ( xDir == Direction.Left ) _sprite.FlipHorizontal = true;
		else if ( xDir == Direction.Right ) _sprite.FlipHorizontal = false;

		// Set while a feet-to-surface rotation owns the sprite's orientation: the rotation already puts the
		// feet on the charged surface, so the reverse-gravity vertical flip below must NOT also apply — in
		// the rotated frame "vertical" runs along the surface normal, so flipping stood the player off the
		// wall on their head. A charge pose against a wall reads the same whichever way gravity points.
		bool rotatedPose = false;
		if ( HAS_SURFACE_GRAVITY )
		{
			SetSpriteHead( -DirectionVector( EffectiveGravityDirection ) );
			_sprite.FlipVertical = false;
			rotatedPose = true; // surface gravity always rotates feet-to-floor, charge pose included
		}
		else if ( HAS_CHARGE_WALL_JUMP || HAS_CHARGE_CEILING_JUMP )
		{
			// The rotation wants the PHYSICAL surface the feet plant on. A CLING-owned charge surface of
			// Up means the gravity-frame ceiling (see HandleChargeJump), so translate it: world Up
			// normally, the topside BELOW in a reverse-gravity field — there the wound spring reads
			// feet-down, head toward the reversed floor (up), the exact mirror of the head-down normal
			// ceiling pose. (A sticky charge's stored faces are physical, but it never reaches here —
			// the stuck path owns its own rotation and returns before UpdateAnimation; the attach gate
			// documents the ownership rather than guarding a live path.) The bare cling grip reads
			// _attachFace, which is already the physical face in either frame. A live WALL charge owns the
			// pose even while OnFloor: that combination only exists while a block lifts us along the wall
			// (see HandleChargeJump's floorPushingUs), and the charge is still wound against the wall.
			bool liveWallCharge = _chargingJump
				&& (_chargeJumpSurfaceDirection == Direction.Left || _chargeJumpSurfaceDirection == Direction.Right);
			Direction visualFloor = OnFloor && !liveWallCharge
				? Direction.None
				: _chargeJumpSurfaceDirection == Direction.Up
				? (_attach == Attachment.CeilingCling && _gravityReversed ? Direction.Down : Direction.Up)
				: _chargeJumpSurfaceDirection != Direction.None
				? _chargeJumpSurfaceDirection
				: HAS_CHARGE_CEILING_JUMP && _attach == Attachment.CeilingCling ? _attachFace
				: HuggingLeftWall ? Direction.Left
				: HuggingRightWall ? Direction.Right
				: Direction.None;
			if ( visualFloor == Direction.None )
			{
				SetSpriteHead( null );
			}
			else
			{
				SetSpriteHead( -DirectionVector( visualFloor ) );
				rotatedPose = true;
			}
		}

		// In a reverse-gravity field the player stands on the ceiling, so flip the sprite upside-down. The
		// flag is a per-tick block report (reset at frame start), so this reverts automatically on exit.
		// Skipped for a rotated pose (see above) — there the rotation already says which way is down.
		if ( !HAS_SURFACE_GRAVITY )
			_sprite.FlipVertical = _gravityReversed && !rotatedPose;
		foreach ( var ability in _abilityModules )
			ability.OnVisualUpdated( this, _sprite.FlipHorizontal, _sprite.FlipVertical );

		// Choose locomotion poses relative to the CURRENT gravity: the surface we rest on (ceiling when
		// reversed) and the sign of "moving away from the floor" (velUp) both flip, so walk/idle vs
		// air-up/air-down read correctly whether upright or inverted.
		bool onFloor = OnFloor;
		Vector2 awayFromFloor = DirectionVector( OppositeDirection( EffectiveGravityDirection ) );
		float velUp = VelX * awayFromFloor.x + VelY * awayFromFloor.y;

		// The away/toward-floor keys in the CURRENT gravity frame. UpPressed/DownPressed only track the
		// reverse-field swap, not surface gravity, so for the Shifter read the gravity-frame keys directly —
		// the same predicates ApplySurfaceGravity uses to pick the active-up/active-down gravity factor.
		bool upHeld = HAS_SURFACE_GRAVITY
			? RawDirectionHeld( OppositeDirection( EffectiveGravityDirection ) ) : UpPressed;
		bool downHeld = HAS_SURFACE_GRAVITY
			? RawDirectionHeld( EffectiveGravityDirection ) : DownPressed;

		// Gunner reload / shoot pose overrides the locomotion animation (visual only). Side shots force
		// facing toward the aim; up/down/reload keep the current facing set above.
		if ( _gunnerPose.HasValue )
		{
			if ( _gunnerPoseFace == Direction.Left ) _sprite.FlipHorizontal = true;
			else if ( _gunnerPoseFace == Direction.Right ) _sprite.FlipHorizontal = false;
			PlayAnim( _gunnerPose.Value );
			return;
		}

		if ( _mantleHanging )
		{
			_sprite.FlipHorizontal = _mantleSide == Direction.Left;
			PlayAnim( PlayerAnimType.WallDown );
			return;
		}

		if ( _chargingJump )
		{
			bool chargingFromWall = _chargeJumpSurfaceDirection == Direction.Left
				|| _chargeJumpSurfaceDirection == Direction.Right;
			float maxChargeTime = chargingFromWall ? CHARGE_WALL_JUMP_MAX_TIME : CHARGE_JUMP_MAX_TIME;
			float chargeFraction = Math.Clamp( _chargeJumpTime / Math.Max( 0.0001f, maxChargeTime ), 0f, 1f );
			PlayAnim( ChargeAnim( chargeFraction, _chargeJumpSurfaceDirection, rotatedPose ) );
			return;
		}

		if ( _autoBounceCompressionFrames > 0 && AUTO_BOUNCE_GROUND_COMPRESSION_FRAMES > 0 )
		{
			PlayAnim( PlayerAnimType.Charge3 );
			return;
		}

		if ( CAN_CEILING_CLING && _attach == Attachment.CeilingCling )
		{
			bool moving = newXDirection != Direction.None && MathF.Abs( VelX ) > 0f;
			PlayAnim( moving ? PlayerAnimType.CeilingClingMove : PlayerAnimType.CeilingCling );
			return;
		}

		if ( !onFloor )
		{
			if ( HuggingWall )
			{
				if ( WALL_CLIMB_SPEED > 0f && UpPressed ) PlayAnim( PlayerAnimType.Climb );
				else if ( velUp <= 0.0f ) PlayAnim( PlayerAnimType.WallDown );
				else PlayAnim( PlayerAnimType.WallUp );
			}
			else
			{
				if ( downHeld )
				{
					PlayAnim( PlayerAnimType.AirFallFast );
				}
				else
				{
					if ( velUp > 0.0f )
					{
						if ( upHeld ) PlayAnim( PlayerAnimType.AirUpActive );
						else PlayAnim( PlayerAnimType.AirUp );
					}
					else
					{
						if ( upHeld ) PlayAnim( PlayerAnimType.AirDownActive );
						else PlayAnim( PlayerAnimType.AirDown );
					}
				}
			}
		}
		else
		{
			if ( newXDirection == Direction.Left ) PlayAnim( PlayerAnimType.Walk );
			else if ( newXDirection == Direction.Right ) PlayAnim( PlayerAnimType.Walk );
			else if ( newXDirection == Direction.None )
			{
				if ( downHeld ) PlayAnim( PlayerAnimType.Crouch );
				else PlayAnim( PlayerAnimType.Idle );
			}
		}
	}

	// ----------------------------------------------------------------------------------------
	// rotatedPose: true when the caller rotated the sprite feet-to-surface, false for the upright
	// rendering (identity rotation, upside-down in reverse gravity via FlipVertical). It decides which
	// way the drawn pose's "right" points, which is what the aim lean is chosen against.
	PlayerAnimType ChargeAnim( float chargeFraction, Direction chargeSurfaceDirection, bool rotatedPose )
	{
		int level = ChargeLevel( chargeFraction );
		Direction relativeAim = Direction.None;
		if ( _chargeJumpTime > 0.0001f )
		{
			// Same frame translation as the rotated pose in UpdateAnimation: a CLING-owned Up surface is
			// the gravity-frame ceiling, and the lean projection needs the PHYSICAL face the pose is
			// drawn on. Attach-gated because the STUCK anim path calls this too, passing the literal
			// _attachFace — a sticky Up face (glued beneath a block) must stay untranslated in reverse
			// gravity or its lean mirrors against the wrong axis.
			Direction surfaceDirection = chargeSurfaceDirection == Direction.None
				? EffectiveGravityDirection
				: chargeSurfaceDirection == Direction.Up && _attach == Attachment.CeilingCling
					? (_gravityReversed ? Direction.Down : Direction.Up)
					: chargeSurfaceDirection;
			float aimFraction = Math.Clamp( _chargeJumpAimTime / _chargeJumpTime, -1f, 1f );
			bool chargingFromWall = surfaceDirection == Direction.Left || surfaceDirection == Direction.Right;
			Vector2 worldAim = chargingFromWall
				? new Vector2( 0f, aimFraction )
				: new Vector2( aimFraction, 0f );
			Vector2 towardSurface = DirectionVector( surfaceDirection );
			// Project into the drawn pose's local X axis, then account for its horizontal mirror. A rotated
			// pose carries that axis around with the surface. An UPRIGHT pose does not: reverse gravity draws
			// it upside-down with FlipVertical, which is a mirror, not a half turn — the sprite's right still
			// points world +x. Deriving the axis from the (now overhead) surface there picked the opposite
			// lean, so a held Left aimed left but posed right.
			Vector2 localRight = rotatedPose
				? new Vector2( -towardSurface.y, towardSurface.x )
				: new Vector2( 1f, 0f );
			float facingSign = _facing == Direction.Left ? -1f : 1f;
			float aim = Vector2.Dot( worldAim, localRight ) * facingSign;
			if ( aim < -CHARGE_JUMP_DIRECTIONAL_POSE_THRESHOLD ) relativeAim = Direction.Left;
			else if ( aim > CHARGE_JUMP_DIRECTIONAL_POSE_THRESHOLD ) relativeAim = Direction.Right;
		}

		return (relativeAim, level) switch
		{
			(Direction.Left, 0) => PlayerAnimType.ChargeLeft0,
			(Direction.Left, 1) => PlayerAnimType.ChargeLeft1,
			(Direction.Left, 2) => PlayerAnimType.ChargeLeft2,
			(Direction.Left, 3) => PlayerAnimType.ChargeLeft3,
			(Direction.Left, _) => PlayerAnimType.ChargeLeft4,
			(Direction.Right, 0) => PlayerAnimType.ChargeRight0,
			(Direction.Right, 1) => PlayerAnimType.ChargeRight1,
			(Direction.Right, 2) => PlayerAnimType.ChargeRight2,
			(Direction.Right, 3) => PlayerAnimType.ChargeRight3,
			(Direction.Right, _) => PlayerAnimType.ChargeRight4,
			(_, 0) => PlayerAnimType.Charge0,
			(_, 1) => PlayerAnimType.Charge1,
			(_, 2) => PlayerAnimType.Charge2,
			(_, 3) => PlayerAnimType.Charge3,
			_ => PlayerAnimType.Charge4,
		};
	}

	static int ChargeLevel( float chargeFraction )
		=> Math.Clamp( (int)(Math.Clamp( chargeFraction, 0f, 1f ) * 5f), 0, 4 );

	// ----------------------------------------------------------------------------------------
	static string AnimName( PlayerAnimType t ) => t switch
	{
		PlayerAnimType.Idle => "idle",
		PlayerAnimType.Walk => "walk",
		PlayerAnimType.Crouch => "crouch",
		PlayerAnimType.AirUp => "air_up",
		PlayerAnimType.AirUpActive => "air_up_active",
		PlayerAnimType.AirDown => "air_down",
		PlayerAnimType.AirDownActive => "air_down_active",
		PlayerAnimType.AirFallFast => "air_fall_fast",
		PlayerAnimType.WallUp => "wall_up",
		PlayerAnimType.WallDown => "wall_down",
		PlayerAnimType.Climb => "climb",
		PlayerAnimType.CeilingCling => "ceiling_cling",
		PlayerAnimType.CeilingClingMove => "ceiling_cling_move",
		PlayerAnimType.Charge0 => "charge_0",
		PlayerAnimType.Charge1 => "charge_1",
		PlayerAnimType.Charge2 => "charge_2",
		PlayerAnimType.Charge3 => "charge_3",
		PlayerAnimType.Charge4 => "charge_4",
		PlayerAnimType.ChargeLeft0 => "charge_left_0",
		PlayerAnimType.ChargeLeft1 => "charge_left_1",
		PlayerAnimType.ChargeLeft2 => "charge_left_2",
		PlayerAnimType.ChargeLeft3 => "charge_left_3",
		PlayerAnimType.ChargeLeft4 => "charge_left_4",
		PlayerAnimType.ChargeRight0 => "charge_right_0",
		PlayerAnimType.ChargeRight1 => "charge_right_1",
		PlayerAnimType.ChargeRight2 => "charge_right_2",
		PlayerAnimType.ChargeRight3 => "charge_right_3",
		PlayerAnimType.ChargeRight4 => "charge_right_4",
		PlayerAnimType.GunShootSide => "gun_shoot_side",
		PlayerAnimType.GunShootUp => "gun_shoot_up",
		PlayerAnimType.GunShootDown => "gun_shoot_down",
		PlayerAnimType.GunShootSideEmpty => "gun_shoot_side_empty",
		PlayerAnimType.GunShootUpEmpty => "gun_shoot_up_empty",
		PlayerAnimType.GunShootDownEmpty => "gun_shoot_down_empty",
		PlayerAnimType.GunReload => "gun_reload",
		PlayerAnimType.Hardened => "hardened",
		_ => "idle",
	};

	// Solar wears its energy state on its face: outside sunlight every face-showing pose swaps to
	// its _sad variant, and the fast-fall bolt dims to the _not_full art below a full charge.
	// Death poses are already sad.
	string ResolveAnimName( PlayerAnimType t )
	{
		string name = AnimName( t );
		if ( !_abilities.HasSolar ) return name;
		if ( t == PlayerAnimType.AirFallFast )
			return Energy >= SolarAbility.FULL_ENERGY_THRESHOLD ? name : "air_fall_fast_not_full";
		if ( !SolarInSunlight && t is PlayerAnimType.Idle or PlayerAnimType.Crouch
			or PlayerAnimType.Walk or PlayerAnimType.AirUp or PlayerAnimType.AirUpActive
			or PlayerAnimType.AirDown or PlayerAnimType.AirDownActive
			or PlayerAnimType.WallUp or PlayerAnimType.WallDown )
			return name + "_sad";
		return name;
	}

	void PlayAnim( PlayerAnimType animType )
	{
		string animation = ResolveAnimName( animType );
		if ( _animInitialized && _currentAnimType == animType && _currentAnimName == animation )
			return;
		_currentAnimType = animType;
		_currentAnimName = animation;
		_animInitialized = true;
		_sprite?.PlayAnimation( animation );
		foreach ( var ability in _abilityModules )
			ability.OnAnimationChanged( this, animation );
	}

	// ----------------------------------------------------------------------------------------
	void ClearColliding()
	{
		_collidingEntityLeft = null;
		_collidingEntityRight = null;
		_collidingEntityDown = null;
		_collidingEntityUp = null;
		_crushingEntityLeft = null;
		_crushingEntityRight = null;
		_crushingEntityDown = null;
		_crushingEntityUp = null;
	}

	// ----------------------------------------------------------------------------------------
	public override bool Unpenetrate( float x, float y, Entity2D other )
	{
		_lastUnpenetrateWasSurfaceGravityLedgeDeflect = false;
		if ( !GetRect( x, y ).Intersects( other.GetRect() ) )
			return false;

		RectF a = GetRect( x, y );
		RectF b = other.GetRect();

		float leftAmt = a.Right - b.Left;
		float rightAmt = b.Right - a.Left;
		float downAmt = a.Top - b.Bottom;
		float upAmt = b.Top - a.Bottom;

		Direction direction = Direction.None;
		float currAmt = float.MaxValue;

		if ( leftAmt > 0.0f && leftAmt < currAmt ) { direction = Direction.Left; currAmt = leftAmt; }
		if ( rightAmt > 0.0f && rightAmt < currAmt ) { direction = Direction.Right; currAmt = rightAmt; }
		if ( downAmt > 0.0f && downAmt < currAmt ) { direction = Direction.Down; currAmt = downAmt; }
		if ( upAmt > 0.0f && upAmt < currAmt ) { direction = Direction.Up; currAmt = upAmt; }

		if ( direction == Direction.None )
			return false;

		// HORIZONTAL-GRAVITY LEDGE DEFLECT: once Shifter's centre has passed the tangent edge of
		// its old side-floor, the next Left/Right gravity substep can still overlap that corner by a
		// sub-pixel sliver. Minimum translation chooses the movement axis, which makes AdjustX stop
		// gravity every tick despite no live support. Push out past the near top/bottom edge instead;
		// AdjustX treats that as a cross-axis corner correction and continues the gravity step.
		if ( HAS_SURFACE_GRAVITY
			&& _horizontalMoveDir == EffectiveGravityDirection
			&& EffectiveGravityDirection is Direction.Left or Direction.Right
			&& direction == OppositeDirection( EffectiveGravityDirection )
			&& !HasSurfaceGravitySupport( EffectiveGravityDirection ) )
		{
			float ledgeHangMargin = (LEDGE_FALL_OFF_FRACTION - 0.5f) * Height;
			// Blocked-deflect fallback (same rule as the landing deflects below): only deflect past
			// the corner if the body actually fits there. Otherwise keep the plain horizontal
			// resolve — AdjustX treats that as a landing on this face (the Shifter adopts it as
			// floor and rests on the corner) instead of wedging us into a too-narrow gap where the
			// residual resolve's squeeze cap eventually turns accumulated gravity into a crush.
			if ( y > b.Top + ledgeHangMargin )
			{
				if ( IsSpotClearOfSolids( x, b.Top + Height / 2f ) )
				{
					direction = Direction.Up;
					_lastUnpenetrateWasSurfaceGravityLedgeDeflect = true;
				}
				else
					TraceCrush( $"surface-ledge-deflect Up BLOCKED at ({x:0.00},{y:0.00}) -> keep {direction}" );
			}
			else if ( y < b.Bottom - ledgeHangMargin )
			{
				if ( IsSpotClearOfSolids( x, b.Bottom - Height / 2f ) )
				{
					direction = Direction.Down;
					_lastUnpenetrateWasSurfaceGravityLedgeDeflect = true;
				}
				else
					TraceCrush( $"surface-ledge-deflect Down BLOCKED at ({x:0.00},{y:0.00}) -> keep {direction}" );
			}
		}

		// EDGE-HANG FIX (diverges from original): the original minimum-translation resolution picks
		// the smallest-overlap axis, so a player who had walked PAST a block's edge (centre beyond
		// the lip, only a sliver of foot still overlapping) got snapped back UP onto the block — the
		// tiny vertical overlap beat the horizontal one. Together with the platform glue that left
		// the player hovering at the corner in an air pose, unable to fall. So when we'd "land" on top
		// (Up) but our CENTRE has gone past the block's side edge, don't catch — let gravity carry us
		// off the ledge.
		//
		// Gate this on NOT currently riding this block as a top platform. While genuinely standing /
		// leaning on the edge we're parented to it (dir Down) and the glue (Bottom = parent.Top) is
		// what holds us out over the lip, so we keep the catch there. Once we've gone off the lip
		// (detached, or now hugging the side) snapping us back up is wrong.
		//
		// LANDING LEAN MARGIN: the catch stays allowed while our centre is within LedgeHangMargin past
		// the edge — the SAME lean the platform glue lets a walker hold — so anywhere you can STAND you
		// can also LAND; a corner toe-hold a walker could lean onto no longer shrugs a faller off. This
		// can't re-create the original hover-at-the-lip bug: the glue detach uses the same margin, so a
		// walker the glue releases is already past the catch and falls. Restricted to solids with NO
		// horizontal motion: a HORIZONTALLY MOVING block we've slid off the side of would creep its edge
		// back under our centre — re-satisfying the margined test from the far side — and re-grab us
		// every few frames, stuttering the slide down its side (the regression that removed the old
		// margin; the reverse-gravity fling-pump below is its underside cousin). Those keep the strict
		// live edge.
		// CORNER-LANDING DEFLECT: when the refused catch happens during a VERTICAL move (a falling
		// landing attempt on a corner sliver), refusing outright meant "no collision at all": the
		// faller never landed (VelY never zeroed), sank through the lip in 1px substeps, and the
		// residual-penetration rescue popped them back flush every tick start — a visible slow sink
		// into the corner that only broke once a single tick's fall distance exceeded the horizontal
		// overlap (up to ~0.7s of being stuck for a near-half-body overlap). Instead, resolve out the
		// NEAR SIDE face: the push is < half the hitbox by definition of this gate, and AdjustYPosition
		// treats a cross-axis resolve as a corner graze (keeps falling), so the faller is nudged off
		// the lip immediately and falls on, clean. Only a move INTO the caught face at a genuine fall
		// speed deflects (falling for the top catch, rising for the reverse-gravity underside catch,
		// past CORNER_DEFLECT_MIN_FALL_SPEED — see its declaration for the flat-seam hitch the speed
		// gate prevents); everything else keeps the plain refusal: AdjustXPosition sprint-grazing a
		// corner with its feet a hair below the lip must skim OVER it (the refusal lets the move
		// continue until the catch is allowed and the vertical speed-bump branch steps it up on top) —
		// a side deflect there is ON its move axis, i.e. a dead stop and a phantom wall thud — and a
		// RISING clip of a corner sliver phases through cleanly rather than taking a sideways nudge
		// on the way past.
		bool ridingThisBlock = (_attach == Attachment.Riding && other == _attachEntity);
		float catchMargin = other.VelX == 0f ? LedgeHangMargin : 0f;
		// Exempted under effective-UP gravity (the mirror of the underside gate's effective-Up key
		// below): there an Up resolve is not a landing but the gravity-frame CEILING BONK — the catch
		// reverse-gravity ceiling-cling engagement needs, exactly as normal-gravity head-bonks keep
		// their corner catch through the underside gate. Kept for sideways surface gravity: a shifter
		// walking on its vertical axis must still skim OVER corner slivers via this refusal.
		if ( direction == Direction.Up && EffectiveGravityDirection != Direction.Up
			&& !ridingThisBlock && (x < b.Left - catchMargin || x > b.Right + catchMargin) )
		{
			if ( _verticalMoveDir != Direction.Down || TotalVelocity.y > -CORNER_DEFLECT_MIN_FALL_SPEED )
				return false;
			direction = leftAmt <= rightAmt ? Direction.Left : Direction.Right;
			// BLOCKED-DEFLECT FALLBACK: the deflect target must actually be free. Deflecting into a
			// gap the body doesn't fit (a few-px slot between this block and the arena wall, or a
			// narrow seam to another solid) wedges us: the wall clamp shoves us back into the block,
			// the residual resolve pops us on top each tick without ever zeroing VelY, and once one
			// tick's accumulated fall exceeds the squeeze cap every rescue fails — a crush death in a
			// gap that was OPENING. If the near side is occupied, keep the honest Up catch instead:
			// landing on the corner zeroes VelY and lets the platform glue take over.
			float deflectX = direction == Direction.Left ? b.Left - Width / 2f : b.Right + Width / 2f;
			if ( !IsSpotClearOfSolids( deflectX, y ) )
			{
				TraceCrush( $"landing-deflect {direction} BLOCKED at ({x:0.00},{y:0.00}) -> Up catch" );
				direction = Direction.Up;
			}
			else
				TraceCrush( $"landing-deflect {direction} at ({x:0.00},{y:0.00}) vel=({VelX:0.0},{VelY:0.0})"
					+ $" amts L={leftAmt:0.00} R={rightAmt:0.00} solid ({b.Left:0.0},{b.Bottom:0.0})-({b.Right:0.0},{b.Top:0.0}) v=({other.Velocity.x:0.0},{other.Velocity.y:0.0})" );
		}

		// UNDERSIDE MIRROR: in reverse gravity a Down resolve is the "landing" (gravity presses us up
		// against the block's bottom), so the same edge-hang re-grab exists upside down — the underside
		// ride's edge fling detaches us past the lip, reverse gravity pins the leftover sliver straight
		// back onto the underside, we re-attach, and the fling fires AGAIN next tick, pumping ExtraVelX
		// by the block's speed every frame until we rocket off the far edge. Refuse the catch once our
		// centre is past the side edge (unless still attached — the glue owns the legitimate edge-lean).
		// Gated on effective-Up gravity so normal-gravity head-bonks (and ceiling-cling engagement, which
		// needs this catch to set _ceilingHitThisTick) keep the corner catch. Effective-Up rather than
		// _gravityReversed so an up-gravity SHIFTER (adopted ceiling floor — surface gravity never sets
		// the reversed flag) slips past underside corner slivers like every other gravity direction,
		// instead of being caught into the attach/release churn at the lip. The landing lean margin
		// above applies here the same way — the fling-pump can't recur through it because the fling only
		// happens off a horizontally-MOVING block, which keeps the strict live edge (margin 0).
		if ( direction == Direction.Down && EffectiveGravityDirection == Direction.Up && !ridingThisBlock && (x < b.Left - catchMargin || x > b.Right + catchMargin) )
		{
			// Same corner-landing deflect as the Up gate above: a reverse-gravity "landing" moving
			// up INTO the underside gets pushed out the near side face instead of sinking through the lip.
			if ( _verticalMoveDir != Direction.Up || TotalVelocity.y < CORNER_DEFLECT_MIN_FALL_SPEED )
				return false;
			direction = leftAmt <= rightAmt ? Direction.Left : Direction.Right;
			// Blocked-deflect fallback, mirrored (see the Up gate above): keep the underside catch
			// rather than wedge into a gap the body doesn't fit.
			float deflectX = direction == Direction.Left ? b.Left - Width / 2f : b.Right + Width / 2f;
			if ( !IsSpotClearOfSolids( deflectX, y ) )
			{
				TraceCrush( $"underside-deflect {direction} BLOCKED at ({x:0.00},{y:0.00}) -> Down catch" );
				direction = Direction.Down;
			}
			else
				TraceCrush( $"underside-deflect {direction} at ({x:0.00},{y:0.00}) vel=({VelX:0.0},{VelY:0.0})"
					+ $" amts L={leftAmt:0.00} R={rightAmt:0.00} solid ({b.Left:0.0},{b.Bottom:0.0})-({b.Right:0.0},{b.Top:0.0}) v=({other.Velocity.x:0.0},{other.Velocity.y:0.0})" );
		}

		// CLOSING-CRUSHER CORNER DEFLECT (the corner-landing deflect's mirror: there WE fall onto
		// a block's corner sliver; here a block's corner closes onto OURS while a floor holds us
		// up). Minimum translation picks Down whenever the block's one-tick intrusion is shallower
		// than the horizontal sliver, but pushing a supported player down just embeds them in their
		// own floor — a trap the residual resolve then can't clear: its escape candidates come from
		// the floor (whose faces are all far away), while the small sideways escape belonged to THIS
		// block, already resolved and gone from the penetrating set. So while the block reaches less
		// than halfway across us — the same "must cover the centre to crush" contract the probe
		// bands encode — resolve out the near side face instead. A true crusher covers the centre,
		// keeps its Down resolve, and the crush probes kill it honestly. Mirrored for a rising block
		// vs. a ceiling we're held against.
		// The crusher needn't be moving vertically: a HORIZONTAL mover whose underside slivers over
		// our head — or a static one we're CARRIED into (ride carry is positional glue, so it never
		// shows in TotalVelocity) — springs the same trap sideways. So the gate tests the RELATIVE
		// horizontal approach toward the deflect side alongside the block's own vertical motion.
		// Blocked-deflect fallback (same rule as the landing deflects above): only deflect into a
		// spot the body actually fits — wedging sideways into an occupied seam just hands the
		// residual resolve a different solid whose faces suit it no better. If blocked, keep the
		// vertical resolve: either the residual pass still finds a validated way out, or the pocket
		// is genuinely closed and the kill is honest.
		if ( !ridingThisBlock && direction is Direction.Down or Direction.Up
			&& Math.Min( leftAmt, rightAmt ) < Width / 2f )
		{
			Direction deflect = leftAmt <= rightAmt ? Direction.Left : Direction.Right;
			float relVelX = other.VelX - (TotalVelocity.x + (PlatformEntity?.VelX ?? 0f));
			bool closingHorizontally = deflect == Direction.Left ? relVelX < 0f : relVelX > 0f;
			bool intoFloor = direction == Direction.Down && (other.VelY < 0f || closingHorizontally)
				&& IsPlayerColliding( x, y - 1f, Direction.Down, out _ );
			bool intoCeiling = direction == Direction.Up && (other.VelY > 0f || closingHorizontally)
				&& IsPlayerColliding( x, y + 1f, Direction.Up, out _ );
			if ( intoFloor || intoCeiling )
			{
				// Validate at the spot the deflect actually PRODUCES: a Left/Right resolve moves only X,
				// so the outcome is (deflectX, our committed Y) — NOT (deflectX, probe y). A gravity
				// substep's probe y sits a fraction of a pixel inside the support below, which read the
				// deflect spot as occupied and refused it every tick: a rider carried under a static
				// ceiling sliver sank 1px onto its underside (an AdjustY "landing") instead of holding
				// flush at its near edge.
				float deflectX = deflect == Direction.Left ? b.Left - Width / 2f : b.Right + Width / 2f;
				if ( IsSpotClearOfSolids( deflectX, Y ) )
				{
					direction = deflect;
					TraceCrush( $"crusher-corner-deflect {direction} at ({x:0.00},{y:0.00})"
						+ $" amts L={leftAmt:0.00} R={rightAmt:0.00} relVelX={relVelX:0.0}" );
				}
				else
					TraceCrush( $"crusher-corner-deflect {deflect} BLOCKED at ({x:0.00},{y:0.00}) -> keep {direction}" );
			}
		}
		// The same trap ROTATED 90° — a SIDE support instead of a floor/ceiling: a wall-hug, or
		// sideways surface gravity where a vertical face IS the floor. A block whose corner slivers
		// past our left/right edge while closing VERTICALLY (or we're carried along the face into a
		// static one) gets a Left/Right min-translation resolve that embeds us in the side support,
		// with the identical residual-resolve trap waiting. Same sliver contract (must reach less
		// than halfway across our height to deflect — a taller overlap keeps its resolve and crushes
		// honestly), same relative-closing test with the axes swapped, same blocked fallback. The
		// exemption is the platform slot itself (Riding or WallHug): our own carrier's resolves are
		// the glue's business. `else if` keyed on the entry direction, so a resolve the block above
		// just deflected sideways can't be re-deflected vertically by this branch.
		else if ( PlatformEntity != other && direction is Direction.Left or Direction.Right
			&& Math.Min( downAmt, upAmt ) < Height / 2f )
		{
			Direction deflect = downAmt <= upAmt ? Direction.Down : Direction.Up;
			float relVelY = other.VelY - (TotalVelocity.y + (PlatformEntity?.VelY ?? 0f));
			bool closingVertically = deflect == Direction.Down ? relVelY < 0f : relVelY > 0f;
			bool intoLeftWall = direction == Direction.Left && (other.VelX < 0f || closingVertically)
				&& IsPlayerColliding( x - 1f, y, Direction.Left, out _ );
			bool intoRightWall = direction == Direction.Right && (other.VelX > 0f || closingVertically)
				&& IsPlayerColliding( x + 1f, y, Direction.Right, out _ );
			if ( intoLeftWall || intoRightWall )
			{
				// Committed X, not probe x, for the same reason as the branch above: an AdjustX substep's
				// probe dips into the side support, and a Down/Up resolve only moves Y.
				float deflectY = deflect == Direction.Down ? b.Bottom - Height / 2f : b.Top + Height / 2f;
				if ( IsSpotClearOfSolids( X, deflectY ) )
				{
					direction = deflect;
					TraceCrush( $"crusher-corner-deflect {direction} at ({x:0.00},{y:0.00})"
						+ $" amts D={downAmt:0.00} U={upAmt:0.00} relVelY={relVelY:0.0}" );
				}
				else
					TraceCrush( $"crusher-corner-deflect {deflect} BLOCKED at ({x:0.00},{y:0.00}) -> keep {direction}" );
			}
		}

		_lastUnpenetrateDir = direction;

		float prevX = X;
		float prevY = Y;

		switch ( direction )
		{
			case Direction.Left:
				X = b.Left - Width / 2;
				break;
			case Direction.Right:
				X = b.Right + Width / 2;
				break;
			case Direction.Down:
				Y = b.Bottom - Height / 2;
				break;
			case Direction.Up:
				Y = b.Top + Height / 2;
				break;
		}
		TrackRidingDisplacement( X - prevX, Y - prevY );
		if ( _shifterTrace && HAS_SURFACE_GRAVITY
			&& (MathF.Abs( X - prevX ) > 0.01f || MathF.Abs( Y - prevY ) > 0.01f) )
			TraceShifter( $"SHOVE ({X - prevX:0.00},{Y - prevY:0.00}) {direction} by {other?.GetType().Name ?? "?"}@({other?.X ?? 0f:0.0},{other?.Y ?? 0f:0.0})"
				+ $" blockVel=({other?.Velocity.x ?? 0f:0.0},{other?.Velocity.y ?? 0f:0.0})" );

		// Hostile impostors never press sides. Friendly Swarm clones deliberately can.
		if ( (!IsImpostor || IsSwarmClone) && other is Block block )
		{
			// Only motion CLOSING on the block is pressure. Our side is TotalVelocity (base + every
			// push channel), so a dash or a field push chasing down a same-direction block still
			// presses, while merely being carried alongside one (equal velocities, no closing) does not.
			Vector2 relativeVelocity = TotalVelocity - block.Velocity;
			// WALL-HUG CARRY: the hug glue re-pins us flush to the hugged face every tick, so on the
			// normal axis the block's motion is ours too — but TotalVelocity never carries it (the wall
			// stop zeroes VelX; the glue is an absolute snap). Hugging the trailing face of a block
			// receding faster than one tick of air accel read as "outrun" and never pressed, while the
			// same hug on a vertical mover or a parked block pressed on contact. Our commanded motion
			// into the face IS the closing motion here: add the carry back on that axis only.
			if ( _attach == Attachment.WallHug && ReferenceEquals( _attachEntity, block )
				&& direction == OppositeDirection( _attachFace ) )
				relativeVelocity = new Vector2( TotalVelocity.x, relativeVelocity.y );
			bool pressingFace = direction switch
			{
				Direction.Left => relativeVelocity.x > 0f || _grappleMoveIntent.x > 0f,
				Direction.Right => relativeVelocity.x < 0f || _grappleMoveIntent.x < 0f,
				Direction.Down => relativeVelocity.y > 0f || _grappleMoveIntent.y > 0f,
				Direction.Up => relativeVelocity.y < 0f || _grappleMoveIntent.y < 0f,
				_ => false,
			};
			// PINNED-RETREAT CLAMP: commanded velocity lies in exactly one case — motion commanded
			// INTO a support is absorbed and never happens. Pinned on the floor of a down-blowing
			// wind lane, TotalVelocity read a full-gust retreat the floor turned into zero actual
			// motion, so a descending wind block "outran" the player it was squeezing and its own
			// bottom face never pressed. If the retreat side of this face is backed by a solid, we
			// can retreat no faster than that support itself moves — re-test with our commanded
			// component clamped to the support's. This only ever WIDENS the gate (a clamped retreat
			// reads strictly more closing), so no press that fires today is lost, and every new
			// press requires a genuine squeeze: a flush solid behind us AND the block closing
			// relative to it. Riding the tested face stays a non-press: the pressed block never
			// counts as its own pin, and open air behind us means no clamp at all.
			if ( !pressingFace && TryGetRetreatSupport( direction, block, out float retreatSupportVel ) )
			{
				pressingFace = direction switch
				{
					Direction.Left => MathF.Max( TotalVelocity.x, retreatSupportVel ) - block.Velocity.x > 0f,
					Direction.Right => MathF.Min( TotalVelocity.x, retreatSupportVel ) - block.Velocity.x < 0f,
					Direction.Down => MathF.Max( TotalVelocity.y, retreatSupportVel ) - block.Velocity.y > 0f,
					Direction.Up => MathF.Min( TotalVelocity.y, retreatSupportVel ) - block.Velocity.y < 0f,
					_ => false,
				};
			}
			// RIDE CARRY (sim v41+, the wall-hug rule applied to the ride glue): riding a DESCENDING block,
			// the glue re-pins us flush each tick and the floor stop zeroes VelY, so our one tick of gravity
			// into the top read as "outrun" by the block — its freshly popped top (after a phase-up, or
			// spikes retracting) never re-pressed, while the same ride on a parked, rising or sideways
			// mover pressed on contact. On the glued axis our commanded motion into the face IS the closing
			// motion. Widen-only (OR, not replace) so every press the raw test awards still fires; the
			// pre-gate path keeps the old behaviour for replays recorded before the fix.
			if ( !pressingFace && Stage.SimVersion >= Sim.RIDE_CARRY_PRESS
				&& _attach == Attachment.Riding && ReferenceEquals( _attachEntity, block )
				&& direction == OppositeDirection( _attachFace ) )
			{
				pressingFace = direction switch
				{
					Direction.Left => TotalVelocity.x > 0f,
					Direction.Right => TotalVelocity.x < 0f,
					Direction.Down => TotalVelocity.y > 0f,
					Direction.Up => TotalVelocity.y < 0f,
					_ => false,
				};
			}
			// A resolve CROSS to the active movement sweep is a corner graze, not a push into that
			// face — the X sweep even steps over vertical resolves and keeps moving (the speed-bump
			// fix in AdjustXPosition). Wall-hugging the side of a rising block, the last sliver of
			// overlap before sliding off its bottom corner resolves DOWN out of the corner (the
			// sliver is thinner than the horizontal substep), and the instant the block stops or
			// reverses, the carried hug VelY reads as closing velocity — pressing a bottom face the
			// player never touched. Same for a corner-landing deflect pressing the side face it
			// nudges us past. Sweep-less resolves (the tick-start residual pass) still press: a
			// block driving itself into the player presses its own face, vetted by the gate above.
			if ( pressingFace && !IsCrossAxisGrazeResolve( direction ) )
			{
				bool newPress = PressBlockSide( block, direction );
				// Block-press launch: a NEW side-press (a face not already pressed) schedules a fling
				// AWAY from that face, applied at end of tick. Fires on ALL four faces incl. tops/bottoms;
				// because it only triggers on an un-pressed face, a fully-pressed block top can still be
				// stood on. It's as much a hazard as a tool. 0 = off. Pressing the block's LEADING face
				// (its velocity pointing out of the face) stacks that speed onto the flat boost, so a
				// fast mover flings harder than a parked one; trailing/cross components clamp to zero.
				if ( newPress && BLOCK_PRESS_BOOST > 0f )
				{
					float blockVel = direction switch
					{
						Direction.Left  => Math.Max( 0f, -block.VelX ),
						Direction.Right => Math.Max( 0f,  block.VelX ),
						Direction.Down  => Math.Max( 0f, -block.VelY ),
						Direction.Up    => Math.Max( 0f,  block.VelY ),
						_ => 0f,
					};
					// Same-direction presses this tick keep the LARGEST bonus, so block spawn order
					// can't decide whether a parked block's zero erases a mover's contribution.
					_pressLaunchBlockVel = direction == _pressLaunchDir
						? Math.Max( _pressLaunchBlockVel, blockVel )
						: blockVel;
					_pressLaunchDir = direction;
				}
				// Crushed INTO something: a block flush on the side this resolve shoved us toward
				// takes the same squeeze — press the face we're being ground into as well.
				PressSqueezeAnvils( direction, block );
			}
		}

		return true;
	}

	// ----------------------------------------------------------------------------------------
	protected override bool ClampToBounds( float x, float y )
	{
		bool inBounds = true;
		RectF rect = GetRect( x, y );
		float prevX = X;
		float prevY = Y;

		// Spiked walls are ALWAYS lethal on contact, even for a character that otherwise phases through
		// arena walls. The solid clamp itself is gated on CollidesWithArenaWalls: when false the wall is
		// passable (the wrap ability teleports the player once they're far enough through).
		if ( rect.Left < Arena.WALL_SIZE )
		{
			if ( Stage.WallDeadlyAt( Direction.Left, new Vector2( x, y ) ) )
			{
				KilledBySpikes( Direction.Left );
				return false;
			}

			if ( _abilities.CollidesWithArenaWalls )
			{
				Left = Arena.WALL_SIZE;
				inBounds = false;
			}
		}
		else if ( rect.Right > Arena.WIDTH - Arena.WALL_SIZE )
		{
			if ( Stage.WallDeadlyAt( Direction.Right, new Vector2( x, y ) ) )
			{
				KilledBySpikes( Direction.Right );
				return false;
			}

			if ( _abilities.CollidesWithArenaWalls )
			{
				Right = Arena.WIDTH - Arena.WALL_SIZE;
				inBounds = false;
			}
		}

		if ( rect.Bottom < Arena.WALL_SIZE )
		{
			if ( Stage.WallDeadlyAt( Direction.Down, new Vector2( x, y ) ) )
			{
				KilledBySpikes( Direction.Down );
				return false;
			}

			if ( _abilities.CollidesWithArenaWalls )
			{
				Bottom = Arena.WALL_SIZE;
				inBounds = false;
			}
		}
		else if ( rect.Top > Arena.HEIGHT - Arena.WALL_SIZE )
		{
			if ( Stage.WallDeadlyAt( Direction.Up, new Vector2( x, y ) ) )
			{
				KilledBySpikes( Direction.Up );
				return false;
			}

			if ( _abilities.CollidesWithArenaWalls )
			{
				Top = Arena.HEIGHT - Arena.WALL_SIZE;
				inBounds = false;
			}
		}

		TrackRidingDisplacement( X - prevX, Y - prevY );
		return inBounds;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Fold the carrying platform's velocity into our own, just before a wrap detaches us.
	/// While riding a moving block we track it via the glue (<c>Bottom = platform.Top</c>), so our own
	/// <c>VelY</c> stays ≈0 — the block's motion never enters our velocity. If a rising block
	/// shoves us through a wall, wrapping us with that ≈0 velocity leaves us momentum-less on the far
	/// side; gravity then drags us straight back through the edge we just emerged from, pinning us
	/// against the still-closed wall for the whole reverse-wrap cooldown — a jarring stall. Inheriting
	/// the platform's velocity here makes us emerge as a projectile carrying its momentum, so we sail
	/// clear of the edge exactly like a normal wrap. Uses <c>VelY</c> (which gravity then arcs
	/// back down), matching the moving-platform inertia fling. No-op if not riding a moving platform.</summary>
	public void InheritPlatformVelocityForWrap()
	{
		Entity2D platform = PlatformEntity;
		if ( platform == null ) return;
		// A stopped platform's Velocity is zero (its momentum already reached us via the inertia
		// fling in HandleMovingPlatforms), so this only ever adds genuine motion.
		VelX += platform.VelX;
		// WallHug keeps VelY in world space already; its frame carry is applied only to displacement.
		if ( _attach != Attachment.WallHug ) VelY += platform.VelY;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Directly set the player's position to (x,y), preserving velocity — a discontinuous teleport,
	/// NOT ordinary movement. Detaches from any moving platform, sticky grip, or ceiling cling (we've left it) and bumps
	/// <see cref="TeleportSequence"/> so consumers can distinguish it from a per-tick move. Used by any
	/// ability that sets position directly: <see cref="WrapArenaEdgesAbility"/> (arena wrap),
	/// <see cref="SwapPortalAbility"/> (swap), and future teleport-style abilities.</summary>
	public void TeleportTo( float x, float y )
	{
		CancelMantleHang( applyCooldown: true );
		if ( IsStuck )
			ClearStuck( playUnstuck: true );
		else if ( _attach == Attachment.CeilingCling )
			// Plain Detach, not EndCeilingCling: its momentum inherit models peeling off a block we're
			// touching, which doesn't apply across a discontinuous jump — the glue would otherwise pin
			// us back to the far-away block's underside next tick.
			Detach();
		else
			DetachPlatform();

		// A teleport is a discontinuous jump, so proximity earned at the departure point doesn't
		// travel: spend the wall-launch windows (wall-jump coyote + wall-kick rebound), or an Up
		// press within the leniency frames after a wrap/swap/blink would fire a phantom wall jump —
		// and now award a press to — a wall that's nowhere near the arrival point. (Restores that
		// re-seed these fields from a snapshot run AFTER this, so they're unaffected.)
		// The GROUND coyote window (_groundedLeniencyCounter) deliberately survives: only windows
		// that would award a press to a distant surface are spent — a coyote ground jump presses
		// nothing, it's pure motion. This is intended tech (a grounded swap to an aerial portal can
		// stack the banked launch with a coyote jump; an upward blink buys a mid-air jump) — don't
		// "fix" it.
		_wallJumpLeniencyCounter = 0;
		_wallDirection = Direction.None;
		_wallJumpWallEntity = null;
		_wallKickWall = Direction.None;
		_wallKickWallEntity = null;
		_wallKickTimer = 0f;

		X = x;
		Y = y;
		TeleportSequence++; // a discontinuous position change (see TeleportSequence)
	}

	/// <summary>Stop all self-motion, carried momentum, and active external-force channels.</summary>
	public void ClearMotion()
	{
		Velocity = Vector2.Zero;
		ExtraVelX = 0f;
		ExtraVelY = 0f;
		_grappleReleaseVelocity = Vector2.Zero;
		_windVel = Vector2.Zero;
		_magnetVel = Vector2.Zero;
		_shockwaveVel = Vector2.Zero;
		_swarmRepelVel = Vector2.Zero;
		_sirenVel = Vector2.Zero;
	}

	/// <summary>Replace all motion with a fixed-speed launch, routing each axis through its OVERSPEED
	/// channel so the same speed reads the same in every direction.
	/// <para>The two axes overspeed differently and the split has to respect that. VelX may legitimately
	/// sit ABOVE the current cap — <see cref="ApplyHorizontalDeceleration"/> bleeds the excess at
	/// OverspeedDecay and the steering clamps in <see cref="HandleHorizontalInput"/> deliberately leave it
	/// alone — so X takes the whole impulse. VelY has no such allowance: gravity and the fall-speed clamp
	/// re-pin it to the rise/fall caps every tick, so the part above the cap has to ride ExtraVelY, which
	/// IS the vertical overspeed channel (see <see cref="HandleExtraVelocity"/>).</para>
	/// <para>Splitting X at the cap the way Y must be split is what made a horizontal launch feel dead:
	/// the over-cap remainder landed in ExtraVelX, a channel no steering input can reach, so you kept
	/// drifting the launched way while VelX had already flipped against it. Left/right now bites the
	/// single channel it can actually see.</para>
	/// <para><paramref name="keepExistingMotion"/> adds whatever we're already carrying instead of
	/// discarding it. For a launch applied AFTER <see cref="ResolveTeleportPenetration"/> that's how the
	/// depth-scaled ejection shove out of a solid we materialized inside survives — the launch rides on
	/// top of it rather than deleting it. A launch applied BEFORE the resolve (the Swapper's order)
	/// doesn't need it: the eject lands on top of the launch by itself.</para>
	/// <para>Assumes the caller isn't a surface-gravity character: those skip ApplyHorizontalDeceleration
	/// entirely and their tangent handling clamps to CurrentMaxXSpeed outright, so X overspeed would be
	/// yanked to the cap on the next tick rather than bled. Neither current caller (Blinker, Swapper)
	/// has it.</para></summary>
	public void ApplyLaunchVelocity( Vector2 velocity, bool keepExistingMotion = false )
	{
		if ( keepExistingMotion )
			velocity += TotalVelocity;

		ClearMotion();

		VelX = velocity.x;

		float gravitySign = GravitySign;
		float gravityRelativeY = velocity.y * gravitySign;
		float baseGravityRelativeY = Math.Clamp( gravityRelativeY, -MAX_FALL_SPEED, MAX_RISE_SPEED );
		VelY = baseGravityRelativeY * gravitySign;
		ExtraVelY = velocity.y - VelY;
	}

	/// <summary>Capture the motion being replaced, then relaunch at a stored velocity — the Swapper's
	/// banked-momentum exchange. Applies through <see cref="ApplyLaunchVelocity"/>, so a banked run comes
	/// back steerable: the Swapper has the widest cap spread of anyone (ground 66 with no air override
	/// against an uncapped rise), so clamping X to the walk speed and shunting the rest into ExtraVelX
	/// buried over half of a sideways swap in a channel input can't reach.</summary>
	public Vector2 ExchangeStoredVelocity( Vector2 storedVelocity, float scale )
	{
		Vector2 currentVelocity = TotalVelocity;
		if ( OnFloor )
		{
			Vector2 floorDirection = DirectionVector( EffectiveGravityDirection );
			float speedIntoFloor = Vector2.Dot( currentVelocity, floorDirection );
			if ( speedIntoFloor > 0f )
				currentVelocity -= floorDirection * speedIntoFloor;
		}

		ApplyLaunchVelocity( storedVelocity * scale );
		return currentVelocity;
	}

	internal string DescribeMotionForTrace()
	{
		static string V( Vector2 value ) => $"({value.x:0.00},{value.y:0.00})";
		return $"total={V( TotalVelocity )} base={V( Velocity )} extra={V( new Vector2( ExtraVelX, ExtraVelY ) )} "
			+ $"grapple={V( _grappleReleaseVelocity )} wind={V( _windVel )} magnet={V( _magnetVel )} "
			+ $"shock={V( _shockwaveVel )} swarm={V( _swarmRepelVel )} siren={V( _sirenVel )}";
	}

	public void SetHardened( bool hardened )
	{
		if ( IsHardened == hardened ) return;

		IsHardened = hardened;
		if ( hardened )
		{
			CancelMantleHang( applyCooldown: true );
			// Hardened ticks skip the jump handlers' window decay (same discipline as the twin dash): spend
			// both coyote windows and any buffered press so a pre-harden window can't fire a stale wall
			// jump (or press its face from afar) the tick the statue releases, and a statue whose platform
			// moved away can't ground-jump in midair. A release while actually grounded refreshes the
			// ground window that same tick, before the jump check, so no legit jump is lost.
			_groundedLeniencyCounter = 0;
			_jumpBufferCounter = 0;
			_wallJumpLeniencyCounter = 0;
			_wallDirection = Direction.None;
			_wallJumpWallEntity = null;
		}
		ClearMotion();
		if ( IsStuck )
			ClearStuck( playUnstuck: false );
		else
			DetachPlatform();
	}

	/// <summary>Enter or leave Twin1's committed dash state.</summary>
	public void SetTwinDashing( bool dashing )
	{
		if ( IsTwinDashing == dashing ) return;

		IsTwinDashing = dashing;
		if ( dashing ) CancelMantleHang( applyCooldown: true );
		ClearMotion();
		// Spend the coyote windows and any buffered jump (like the charged-jump launch does): their
		// decay lives in the movement path the dash skips, so a stale pre-dash window would otherwise
		// grant a midair jump or wall-jump the moment the dash ends.
		_groundedLeniencyCounter = 0;
		_wallJumpLeniencyCounter = 0;
		_wallDirection = Direction.None;
		_wallJumpWallEntity = null;
		_jumpBufferCounter = 0;
		_stasisLevel = 0;
		_stasisStrength = 0f;
		_fieldReversed = false;
		_gravityReversed = _selfGravityReversed;
		_inTrailPrev = false;
		_inReverseFieldPrev = false;
		if ( IsStuck )
			ClearStuck( playUnstuck: true ); // tearing off a sticky face reads like any other ability release
		else
			DetachPlatform();

		if ( _sprite is null ) return;
		_sprite.Opaque = !dashing;
		_sprite.AlphaCutoff = dashing ? 0f : 0.5f;
		_sprite.Color = (TintColor ?? Color.White).WithAlpha( dashing ? 0.48f : 1f );
	}

	/// <summary>End Twin1's dash, resolve its landing like a teleport, and preserve that result until
	/// the next fixed step instead of immediately running ordinary movement over it.</summary>
	public void FinishTwinDash()
	{
		SetTwinDashing( false );
		ResolveTeleportPenetration();
		// Rebuild the contact flags at the arrival point (like Rewind does): the movement path that
		// normally maintains them was skipped for the whole dash, so the first post-dash tick would
		// otherwise run its wall-cling/landing edge-triggers against contacts captured at dash start.
		RefreshContactsAfterTeleport();
		_twinDashEndedThisTick = true;
	}

	/// <summary>Whether a Twin dash can begin without immediately entering an obstacle or arena wall.</summary>
	public bool CanStartTwinDash( Vector2 direction )
	{
		if ( direction == Vector2.Zero ) return false;
		return !TwinDashPositionBlocked( Pos + Utils.Normalized( direction ) );
	}

	/// <summary>Move through blocks and players, stopping only at obstacles or arena walls.</summary>
	public bool MoveByTwinDash( Vector2 amount )
	{
		float distance = amount.Length;
		if ( distance <= 0f ) return true;

		int steps = Math.Max( 1, (int)MathF.Ceiling( distance ) );
		Vector2 step = amount / steps;
		for ( int i = 0; i < steps; i++ )
		{
			Vector2 target = Pos + step;
			if ( TwinDashPositionBlocked( target ) ) return false;
			Pos = target;
		}
		return true;
	}

	private bool TwinDashPositionBlocked( Vector2 position )
	{
		RectF rect = GetRect( position.x, position.y );
		if ( rect.Left < Arena.WALL_SIZE || rect.Right > Arena.WIDTH - Arena.WALL_SIZE
			|| rect.Bottom < Arena.WALL_SIZE || rect.Top > Arena.HEIGHT - Arena.WALL_SIZE )
			return true;

		foreach ( Obstacle obstacle in Stage.GetObstacles() )
		{
			if ( rect.Intersects( obstacle.GetRect() ) ) return true;
		}
		return false;
	}

	/// <summary>A personal movement snapshot used by Rewind. World state and surface attachments are
	/// intentionally excluded because their owning entities continue moving while the player rewinds.</summary>
	public readonly record struct RewindMovementState(
		Vector2 LastVelocity,
		int GroundedLeniencyCounter,
		int AirJumpsUsed,
		bool JumpCutArmed,
		int WallClingCounter,
		bool ClungLeftWall,
		bool ClungRightWall,
		bool BackFlipping,
		float BackFlipBackDirection,
		float BackFlipTimer,
		int JumpBufferCounter,
		int HoverRemaining,
		bool GroundPounding,
		int AutoBounceCompressionFrames,
		bool ChargingJump,
		float ChargeJumpTime,
		float ChargeJumpAimTime,
		Direction ChargeJumpSurfaceDirection,
		Direction SuppressedChargeSurfaceDirection,
		float ChargeWallJumpGravityTimer,
		int ChargeGroundedFrames,
		Direction WallJumpDirection,
		float WallJumpTimer,
		bool SuppressWallRearm,
		Direction InputDirection,
		Direction Facing,
		bool SelfGravityReversed );

	public readonly record struct RewindState(
		Vector2 Position,
		Vector2 Velocity,
		float ExtraVelocityX,
		float ExtraVelocityY,
		Vector2 WindVelocity,
		Vector2 MagnetVelocity,
		Vector2 ShockwaveVelocity,
		Vector2 SwarmRepelVelocity,
		Vector2 SirenVelocity,
		string Animation,
		bool FlipHorizontal,
		bool FlipVertical,
		RewindMovementState Movement );

	public RewindState CaptureRewindState()
	{
		return new RewindState(
			Pos,
			Velocity,
			ExtraVelX,
			ExtraVelY,
			_windVel,
			_magnetVel,
			_shockwaveVel,
			_swarmRepelVel,
			_sirenVel,
			_currentAnimName,
			_sprite?.FlipHorizontal ?? false,
			_sprite?.FlipVertical ?? false,
			new RewindMovementState(
				LastVelocity,
				_groundedLeniencyCounter,
				_airJumpsUsed,
				_jumpCutArmed,
				_wallClingCounter,
				_clungLeftWall,
				_clungRightWall,
				_backFlipping,
				_backFlipBackDir,
				_backFlipTimer,
				_jumpBufferCounter,
				_hoverRemaining,
				_groundPounding,
				_autoBounceCompressionFrames,
				_chargingJump,
				_chargeJumpTime,
				_chargeJumpAimTime,
				_chargeJumpSurfaceDirection,
				_suppressedChargeSurfaceDirection,
				_chargeWallJumpGravityTimer,
				_chargeGroundedFrames,
				_wallJumpDirection,
				_wallJumpTimer,
				_suppressWallRearm,
				_xDirection,
				_facing,
				_selfGravityReversed ) );
	}

	public void RestoreRewindState( in RewindState state )
	{
		TeleportTo( state.Position.x, state.Position.y );
		Velocity = state.Velocity;
		ExtraVelX = state.ExtraVelocityX;
		ExtraVelY = state.ExtraVelocityY;
		_windVel = state.WindVelocity;
		_magnetVel = state.MagnetVelocity;
		_shockwaveVel = state.ShockwaveVelocity;
		_shockwaveStartMag = _shockwaveVel.Length;
		_swarmRepelVel = state.SwarmRepelVelocity;
		_sirenVel = state.SirenVelocity;

		RewindMovementState movement = state.Movement;
		LastVelocity = movement.LastVelocity;
		_groundedLeniencyCounter = movement.GroundedLeniencyCounter;
		_airJumpsUsed = movement.AirJumpsUsed;
		_jumpCutArmed = movement.JumpCutArmed;
		_wallClingCounter = movement.WallClingCounter;
		_wallClingHold = false; // like the carry below: re-derived by the next ApplyGravity, never restored
		_clungLeftWall = movement.ClungLeftWall;
		_clungRightWall = movement.ClungRightWall;
		_wallHugCarryY = 0f;
		_wallHugStepVelocityY = 0f;
		_backFlipping = movement.BackFlipping;
		_backFlipBackDir = movement.BackFlipBackDirection;
		_backFlipTimer = movement.BackFlipTimer;
		_jumpBufferCounter = movement.JumpBufferCounter;
		_hoverRemaining = movement.HoverRemaining;
		_groundPounding = movement.GroundPounding;
		_autoBounceCompressionFrames = movement.AutoBounceCompressionFrames;
		_chargingJump = movement.ChargingJump;
		_chargeJumpTime = movement.ChargeJumpTime;
		_chargeJumpAimTime = movement.ChargeJumpAimTime;
		_chargeJumpSurfaceDirection = movement.ChargeJumpSurfaceDirection;
		_suppressedChargeSurfaceDirection = movement.SuppressedChargeSurfaceDirection;
		_chargeWallJumpGravityTimer = movement.ChargeWallJumpGravityTimer;
		_chargeGroundedFrames = movement.ChargeGroundedFrames;
		// The wall COYOTE window (_wallDirection / _wallJumpLeniencyCounter / _wallJumpWallEntity) is
		// deliberately NOT in the snapshot: a restore rewinds the player, not the blocks, so a window
		// banked seconds ago points at a wall that may be nowhere near the restored position — an Up
		// press right after a death recovery fired a phantom wall jump off (and, since the press-award
		// change, pressed) a face across the arena. TeleportTo above already spent the windows — the
		// same discontinuity rule it applies to wraps/swaps/blinks. The post-jump control ramp
		// (_wallJumpDirection/_wallJumpTimer) still restores: it describes an already-fired jump's
		// feel, not wall proximity. Swarm clone spawns and adoptions route through here too and lose at
		// most the leniency frames of wall coyote — accepted.
		_wallJumpDirection = movement.WallJumpDirection;
		_wallJumpTimer = movement.WallJumpTimer;
		_suppressWallRearm = movement.SuppressWallRearm;
		_xDirection = movement.InputDirection;
		_facing = movement.Facing;
		_selfGravityReversed = movement.SelfGravityReversed;
		_gravityReversed = _fieldReversed ^ _selfGravityReversed;
		if ( _sprite is not null )
		{
			_sprite.FlipHorizontal = state.FlipHorizontal;
			_sprite.FlipVertical = state.FlipVertical;
			_sprite.PlayAnimation( state.Animation );
			_currentAnimName = state.Animation;
			_animInitialized = false;
		}
	}

	/// <summary>Capture the on-screen body pose for the tutorial-ghost recorder: the pixel-aligned
	/// sprite center plus the animation/flip/rotation state needed to redraw this exact frame as a
	/// translucent ghost (see <see cref="GhostRecorder"/>). Rotated poses (surface gravity, charged
	/// wall/ceiling jumps) ride along as the sprite's angles with billboarding off, mirroring what
	/// <see cref="UpdateAnimation"/> displayed this step.</summary>
	public GhostPose CaptureGhostPose()
	{
		RectF rect = GetPixelRect( X, Y );
		Vector2 center = new( (rect.Left + rect.Right) * 0.5f, (rect.Bottom + rect.Top) * 0.5f );
		if ( _sprite is null )
			return new GhostPose( center, "idle", false, false, false, Angles.Zero );

		center = SpriteLayer.PixelAlignedCenter( center, _sprite.Size );
		// Visual-only shake offsets (Blinker's prepare jitter, impact buzzes) live on the sprite child,
		// not the body rect — fold them in so the ghost shakes exactly as the player did.
		Vector3 shake = _sprite.GameObject.LocalPosition;
		center += new Vector2( shake.x, shake.y );
		// Recorded in the old non-billboard LookAt form so existing ghost clips keep playing back.
		bool rotated = _spriteHead.HasValue;
		Angles rot = _spriteHead is Vector2 head
			? Rotation.LookAt( Vector3.Up, new Vector3( head.x, head.y, 0f ) ).Angles()
			: Angles.Zero;
		return new GhostPose( center, _currentAnimName,
			_sprite.FlipHorizontal, _sprite.FlipVertical, rotated, rot );
	}

	/// <summary>Move authority onto a live Swarm copy without adding transition presentation.</summary>
	public void AdoptSwarmCloneState( Player source )
	{
		if ( source is null || source.IsDead || !source.IsSwarmClone ) return;
		if ( _swarmTrace )
		{
			TraceSwarm( $"ADOPT {DescribeSwarmBodyForTrace()}" );
			TraceSwarm( $"   onto {source.DescribeSwarmBodyForTrace()}" );
		}

		// The fatal body is about to remain behind as its natural death animation. Release its old
		// attachment silently before RestoreRewindState teleports this component onto the survivor.
		if ( IsStuck ) ClearStuck( playUnstuck: false );
		else Detach();

		RestoreRewindState( source.CaptureRewindState() );
		_fieldReversed = source._fieldReversed;
		_gravityReversed = source._gravityReversed;
		_stasisLevel = source._stasisLevel;
		_stasisStrength = source._stasisStrength;
		_inTrailPrev = source._inTrailPrev;
		_inReverseFieldPrev = source._inReverseFieldPrev;

		_attach = source._attach;
		_attachEntity = source._attachEntity;
		_attachFace = source._attachFace;
		_attachTangentOffset = source._attachTangentOffset;
		_attachWasStopped = source._attachWasStopped;
		_attachParentLastPosition = source._attachParentLastPosition;
		_ceilingLastX = source._ceilingLastX;
		_stickyStuckTime = source._stickyStuckTime;
		_slamBlock = source._slamBlock;
		_slamFace = source._slamFace;
		_slamTimer = source._slamTimer;
		// The edge-release record travels with the body too: a clone that walked off a sticky edge
		// within the last STICK_EDGE_RELEASE_COOLDOWN would otherwise re-glue to that same face the
		// instant we take over at its position (the ClearStuck above wiped this body's own record).
		_stickyEdgeReleaseBlock = source._stickyEdgeReleaseBlock;
		_stickyEdgeReleaseFace = source._stickyEdgeReleaseFace;
		_stickyEdgeReleaseCooldown = source._stickyEdgeReleaseCooldown;
		// The launch cooldown belongs to the body we now occupy, not the one left behind dying.
		_swarmLaunchCooldown = source._swarmLaunchCooldown;
		ResetShockwaveHitsAfterRecovery( inheritFrom: source );

		RefreshContactsAfterTeleport();
	}

	/// <summary>Re-probe destination contacts after teleport penetration has selected the final position.
	/// Entity references and contact edges come from the current world, never from historical snapshots.</summary>
	public void RefreshContactsAfterTeleport()
	{
		if ( IsDead ) return;

		ClearColliding();
		const float threshold = 1.0f;
		CollidingLeft = IsPlayerColliding( X - threshold, Y, Direction.Left, out _collidingEntityLeft );
		CollidingRight = IsPlayerColliding( X + threshold, Y, Direction.Right, out _collidingEntityRight );
		CollidingDown = IsPlayerColliding( X, Y - threshold, Direction.Down, out _collidingEntityDown );
		CollidingUp = IsPlayerColliding( X, Y + threshold, Direction.Up, out _collidingEntityUp );
		CrushingLeft = CrushingRight = CrushingDown = CrushingUp = false;
		_onFloorLastTick = OnFloor;
		_prevCollidingLeft = CollidingLeft;
		_prevCollidingRight = CollidingRight;
		_prevDashContact = OnFloor;
	}

	internal int GetHoverFramesRemaining() { return _hoverRemaining; }
	internal int GetHoverFramesMaximum() { return HOVER_FRAMES; }

	/// <summary>Refill the character's hover budget.</summary>
	internal void RefreshHoverFrames()
	{
		if ( IsDead ) return;
		_hoverRemaining = HOVER_FRAMES;
	}

	/// <summary>Stop self-motion and carried momentum but LEAVE the external-force channels (wind /
	/// magnet / shockwave / siren) live. The grapple pull uses this every tick: the taut line owns the
	/// player's own velocity and gravity, but the world's fields still push them around mid-pull (a
	/// strong headwind can even stall the pull — each line's pull lifetime ends that).</summary>
	public void ClearSelfMotion()
	{
		Velocity = Vector2.Zero;
		ExtraVelX = 0f;
		ExtraVelY = 0f;
		_grappleReleaseVelocity = Vector2.Zero;
	}

	/// <summary>Carry collision-resolved grapple momentum without decay until the next solid contact.</summary>
	public void ApplyGrappleReleaseVelocity( Vector2 velocity )
	{
		if ( IsDead ) return;
		DetachForGrapple();
		Velocity = Vector2.Zero;
		_grappleReleaseVelocity = new Vector2(
			Math.Clamp( velocity.x, -MAX_EXTRA_X_SPEED, MAX_EXTRA_X_SPEED ),
			Math.Clamp( velocity.y, -MAX_EXTRA_Y_SPEED, MAX_EXTRA_Y_SPEED ) );
	}

	void EndGrappleReleaseMomentum( bool horizontalContact, bool verticalContact )
	{
		if ( _grappleReleaseVelocity == Vector2.Zero ) return;
		if ( !horizontalContact )
			ExtraVelX = Math.Clamp( ExtraVelX + _grappleReleaseVelocity.x, -MAX_EXTRA_X_SPEED, MAX_EXTRA_X_SPEED );
		if ( !verticalContact )
			ExtraVelY = Math.Clamp( ExtraVelY + _grappleReleaseVelocity.y, -MAX_EXTRA_Y_SPEED, MAX_EXTRA_Y_SPEED );
		_grappleReleaseVelocity = Vector2.Zero;
	}

	/// <summary>Apply the Blinker prepare hold without moving the player body or collision box.</summary>
	public void SetBlinkPrepareEffect( float motionFactor, float damping, Vector2 spriteOffset )
	{
		_blinkPreparing = true;
		if ( _mantleHanging ) CancelMantleHang( applyCooldown: true );
		if ( _attach != Attachment.Free && _attach != Attachment.Stuck )
			Detach();
		_blinkPrepareFactor = Math.Clamp( motionFactor, 0f, 1f );
		Velocity *= damping;
		ExtraVelX *= damping;
		ExtraVelY *= damping;
		SetShakeOffset( spriteOffset );
	}

	/// <summary>Set a sustained visual-only shake offset, such as Blinker's prepare jitter.</summary>
	public void SetShakeOffset( Vector2 offset )
	{
		_sustainedShakeOffset = offset;
		ApplyShakeOffset();
	}

	/// <summary>Clear a sustained shake without interrupting a transient impact shake.</summary>
	public void ClearShakeOffset()
	{
		_sustainedShakeOffset = Vector2.Zero;
		ApplyShakeOffset();
	}

	/// <summary>Start a short visual-only shake: a buzz ALONG the amplitude axis, its sign re-rolled
	/// every frame, so the axis reads but the direction does not. Use <see cref="ShakeDirectional"/>
	/// when the first displacement has to point somewhere specific. Cosmetic RNG keeps its samples out
	/// of gameplay state.</summary>
	public void Shake( Vector2 amplitude, int frames )
	{
		if ( frames <= 0 ) return;
		_transientShakeAmplitude = amplitude;
		_transientShakeStyle = ShakeStyle.Axis;
		_transientShakeFrames = frames;
		SampleTransientShake();
	}

	/// <summary>Start a short visual-only JOLT: the first frame lands exactly on <paramref name="jolt"/>
	/// (so the hit reads as coming FROM somewhere), then it rings out — sign flipped and magnitude
	/// decayed each frame, the same shape as Block.AddShake. No RNG at all: the direction is the whole
	/// point, so nothing here is allowed to re-roll it.</summary>
	public void ShakeDirectional( Vector2 jolt, int frames )
	{
		if ( frames <= 0 ) return;
		_transientShakeAmplitude = jolt;
		_transientShakeStyle = ShakeStyle.Directional;
		_transientShakeFrames = frames;
		SampleTransientShake();
	}

	/// <summary>Start a short visual-only shake with a fresh cosmetic-random direction each frame.</summary>
	public void ShakeRandom( float magnitude, int frames )
	{
		if ( magnitude <= 0f || frames <= 0 ) return;
		_transientShakeMagnitude = magnitude;
		_transientShakeStyle = ShakeStyle.Random;
		_transientShakeFrames = frames;
		SampleTransientShake();
	}

	void TickTransientShake()
	{
		if ( _transientShakeFrames <= 0 ) return;
		_transientShakeFrames--;
		if ( _transientShakeFrames > 0 )
			SampleTransientShake();
		else
		{
			_transientShakeOffset = Vector2.Zero;
			ApplyShakeOffset();
		}
	}

	void SampleTransientShake()
	{
		if ( _transientShakeStyle == ShakeStyle.Random )
		{
			float angle = Rng.CosmeticFloat( 0f, MathF.Tau );
			float strength = _transientShakeMagnitude * Rng.CosmeticFloat( 0.65f, 1f );
			_transientShakeOffset = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * strength;
			ApplyShakeOffset();
			return;
		}

		if ( _transientShakeStyle == ShakeStyle.Directional )
		{
			// Show this frame's jolt, THEN wind the amplitude down for the next one: the first sample
			// (taken by ShakeDirectional itself) is the undiluted jolt vector, and the ring-out that
			// follows alternates around it. Rounded to whole pixels on the way out, so with the small
			// amplitudes we use the tail goes silent on its own after a few frames.
			_transientShakeOffset = _transientShakeAmplitude;
			_transientShakeAmplitude *= -SHAKE_JOLT_RECOVERY;
			ApplyShakeOffset();
			return;
		}

		float sign = Rng.CosmeticInt( 0, 2 ) == 0 ? -1f : 1f;
		_transientShakeOffset = _transientShakeAmplitude * sign * Rng.CosmeticFloat( 0.65f, 1f );
		ApplyShakeOffset();
	}

	void ApplyShakeOffset()
	{
		if ( _sprite is null ) return;
		Vector2 offset = _sustainedShakeOffset + _transientShakeOffset;
		_sprite.GameObject.LocalPosition = new Vector3( MathF.Round( offset.x ), MathF.Round( offset.y ), 0f );
	}

	/// <summary>Start the short visual scale punch used by Twin control and ability feedback.</summary>
	internal void StartScaleBounce()
	{
		_controlSwitchBounceTime = 0f;
		if ( _sprite is not null ) _sprite.GameObject.LocalScale = 1f;
	}

	void TickControlSwitchBounce( float dt )
	{
		if ( _controlSwitchBounceTime < 0f || _sprite is null ) return;

		_controlSwitchBounceTime += dt;
		float scale;
		if ( _controlSwitchBounceTime < CONTROL_SWITCH_BOUNCE_UP )
			scale = MathX.Lerp( 1f, CONTROL_SWITCH_BOUNCE_PEAK, _controlSwitchBounceTime / CONTROL_SWITCH_BOUNCE_UP );
		else if ( _controlSwitchBounceTime < CONTROL_SWITCH_BOUNCE_UP + CONTROL_SWITCH_BOUNCE_DOWN )
			scale = MathX.Lerp( CONTROL_SWITCH_BOUNCE_PEAK, 1f,
				(_controlSwitchBounceTime - CONTROL_SWITCH_BOUNCE_UP) / CONTROL_SWITCH_BOUNCE_DOWN );
		else
		{
			scale = 1f;
			_controlSwitchBounceTime = -1f;
		}

		_sprite.GameObject.LocalScale = scale;
	}

	void ResetControlSwitchBounce()
	{
		_controlSwitchBounceTime = -1f;
		if ( _sprite is not null ) _sprite.GameObject.LocalScale = 1f;
	}

	/// <summary>Resolve Blinker arrival. A destination entirely past a wall's inner face (the blink
	/// outranged the playfield) is fatal here; everything else — full containment in a block or
	/// obstacle, partial embeds with their eject boost — is the shared teleport-landing resolve.</summary>
	public void ResolveBlinkDestination()
	{
		RectF playerRect = GetRect();
		if ( playerRect.Right <= Arena.WALL_SIZE || playerRect.Left >= Arena.WIDTH - Arena.WALL_SIZE
			|| playerRect.Top <= Arena.WALL_SIZE || playerRect.Bottom >= Arena.HEIGHT - Arena.WALL_SIZE )
		{
			KilledByCrushing( false );
			return;
		}

		ResolveTeleportPenetration();
	}

	/// <summary>Release any ridden, hugged, clung, or sticky surface before a grapple starts pulling.
	/// Tearing off a sticky face opens the same one-side re-grab grace window a slam does (the
	/// <see cref="_slamBlock"/> trio), so a pull that travels ALONG that face isn't instantly
	/// re-grabbed — and thereby cancelled — on the very next tick's contact. Any OTHER sticky face
	/// can still grab mid-pull, which cancels the grapple (see GrapplerAbility.PostTick).</summary>
	public void DetachForGrapple()
	{
		CancelMantleHang( applyCooldown: true );
		if ( IsStuck )
		{
			_slamBlock = StuckBlock;
			_slamFace = _attachFace;
			_slamTimer = STICK_SLAM_RELEASE_TIME;
			Audio.PlaySfx( SfxType.StickyUnstuck, StuckBlock.Position );
			AddStickyGooBurst( engaging: false ); // reads _attachFace — must run before Detach clears it
		}
		Detach();
	}

	/// <summary>Move a grappling player through the normal per-pixel solid and spike collision path.</summary>
	public void MoveByGrapple( Vector2 amount )
	{
		_grappleMoveIntent = new Vector2( amount.x, 0f );
		AdjustXPosition( amount.x );
		if ( !ResolveEndedThisBody ) // not just IsDead: an intercepted death has already moved this body
		{
			_grappleMoveIntent = new Vector2( 0f, amount.y );
			AdjustYPosition( amount.y );
			ClampToBounds( X, Y );
		}
		_grappleMoveIntent = Vector2.Zero;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Kill the player with the crush (squish) death. Exposed for abilities that trap the
	/// player — e.g. a wrap character squeezed by a block against the still-closed (cooling-down)
	/// wall it just emerged from has nowhere to go and is crushed.</summary>
	public void KillByCrush( bool horizontal, [CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
		=> KilledByCrushing( horizontal, caller, callerLine );

	/// <summary>Kill the player with the spike death. Exposed for abilities that resolve spiked-face
	/// contact themselves — e.g. a wrap character phasing into a spiked interior-obstacle face.</summary>
	public void KillBySpikes( Direction dir, [CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
		=> KilledBySpikes( dir, caller, callerLine );

	// How hard a post-teleport ejection from a solid (block / interior obstacle / arena wall band)
	// flings the player, per pixel of embed depth (see ResolveTeleportPenetration). Applied to the
	// overflow ExtraVel channel (horizontal) so it can exceed MAX_X_SPEED and decays like a dash; a
	// deeper embed = a bigger boost.
	private const float TELEPORT_EJECT_FORCE = 30f;

	/// <summary>Resolve a teleport landing immediately after the player's position changes.
	/// If the hitbox is FULLY inside a single static solid — a block, or (for characters the arena walls are
	/// solid to) an interior obstacle — it's an instant crush death; otherwise the player is pushed
	/// flush out of the first solid it overlaps, including a living sibling, along the shallowest axis and given a boost
	/// proportional to how deep it was embedded — deeper = stronger — so teleporting cleanly out of a
	/// solid's edge is a rewarding launch while teleporting fully into one is fatal. The arena wall
	/// band gets the same depth-boosted ejection, per axis. Materializing in spikes is death: a deadly
	/// exit face (block side, obstacle face, or spiked wall stretch) kills instead of launching.
	/// No-op when the landing spot is clear.</summary>
	public void ResolveTeleportPenetration()
	{
		if ( IsDead ) return;

		// The wall band and normal obstacles are only solid to characters that collide with arena
		// walls (a wrap/phase character passes through both) — the same gate the movement path uses.
		// GLASS is solid to everyone, so the obstacle loops below gate per obstacle instead.
		bool solidWalls = _abilities.CollidesWithArenaWalls;

		// FULLY inside any single solid -> instant death (nowhere safe to emerge). Must run before the
		// ejection pass: a full embed has no legitimate flush push-out, and "ejecting" it would
		// relocate the player through the solid instead of resolving a surface contact.
		RectF playerRect = GetRect();
		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block.PhasingIn ) continue; // a phasing Teleport block is intangible to the player
			if ( RectFullyContains( block.GetRect(), playerRect ) )
			{
				KilledByCrushing( false );
				return;
			}
		}
		foreach ( Obstacle ob in Stage.GetObstacles() )
		{
			if ( !ObstacleSolidToUs( ob ) ) continue;
			if ( RectFullyContains( ob.GetRect(), playerRect ) )
			{
				KilledByCrushing( false );
				return;
			}
		}

		// Otherwise eject from the first solid we overlap, boosted by the embed depth on the shallow
		// axis — or die there if the exit face is spiked. One ejection clears the overlap; residual
		// overlap resolves next tick.
		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block.PhasingIn ) continue; // a phasing Teleport block is intangible to the player
			if ( TeleportEjectFrom( block ) ) return;
		}
		foreach ( Player player in Stage.Players )
		{
			if ( ReferenceEquals( player, this ) || player.IsDead ) continue;
			if ( TeleportEjectFrom( player ) ) return;
		}
		foreach ( Obstacle ob in Stage.GetObstacles() )
		{
			if ( !ObstacleSolidToUs( ob ) ) continue;
			if ( TeleportEjectFrom( ob ) ) return;
		}
		if ( solidWalls )
			TeleportEjectFromArenaWalls();
	}

	static bool RectFullyContains( RectF outer, RectF inner )
		=> inner.Left >= outer.Left && inner.Right <= outer.Right
			&& inner.Bottom >= outer.Bottom && inner.Top <= outer.Top;

	/// <summary>Push flush out of <paramref name="solid"/> along the shallowest axis with the teleport
	/// depth boost. Materializing in spikes is death: if the face we emerge through is deadly, the
	/// flush placement stands (the death reads at the spikes, like the move path's unpenetrate-then-
	/// kill) but the launch becomes a spike kill. False when the rects don't overlap.</summary>
	bool TeleportEjectFrom( Entity2D solid )
	{
		RectF a = GetRect();
		RectF b = solid.GetRect();
		if ( !a.Intersects( b ) ) return false;

		float leftAmt = a.Right - b.Left;   // push-out distance to the LEFT
		float rightAmt = b.Right - a.Left;  // ... RIGHT
		float downAmt = a.Top - b.Bottom;   // ... DOWN
		float upAmt = b.Top - a.Bottom;     // ... UP

		Direction dir = Direction.None;
		float depth = float.MaxValue;
		if ( leftAmt > 0f && leftAmt < depth ) { dir = Direction.Left; depth = leftAmt; }
		if ( rightAmt > 0f && rightAmt < depth ) { dir = Direction.Right; depth = rightAmt; }
		if ( downAmt > 0f && downAmt < depth ) { dir = Direction.Down; depth = downAmt; }
		if ( upAmt > 0f && upAmt < depth ) { dir = Direction.Up; depth = upAmt; }
		if ( dir == Direction.None ) return false;

		float boost = depth * TELEPORT_EJECT_FORCE;
		switch ( dir )
		{
			case Direction.Left: X = b.Left - Width / 2f; VelX = 0f; ExtraVelX = -boost; break;
			case Direction.Right: X = b.Right + Width / 2f; VelX = 0f; ExtraVelX = boost; break;
			case Direction.Down: Y = b.Bottom - Height / 2f; VelY = -boost; ExtraVelY = 0f; break;
			case Direction.Up: Y = b.Top + Height / 2f; VelY = boost; ExtraVelY = 0f; break;
		}
		DetachPlatform(); // launched off any platform we were riding

		// The eject direction IS the exit face's outward normal, so it keys the same deadly-now
		// predicates the move path uses (CheckForSpikes / UnpenetrateFromSolid).
		bool deadly = solid switch
		{
			Block bl => bl.SideDeadly( dir ),
			Obstacle => Stage.ObstacleFaceDeadlyForRect( dir, GetRect() ),
			_ => false,
		};
		if ( deadly ) KilledBySpikes( dir );
		return true;
	}

	/// <summary>The wall-band counterpart of <see cref="TeleportEjectFrom"/>: clamp back inside the
	/// playfield with the same depth boost. Per axis — the bands are axis-aligned, so a corner landing
	/// resolves both overlaps at once. Materializing in a spiked wall stretch is death, checked at the
	/// arrival position like <see cref="ClampToBounds"/> (kill, no clamp). A destination entirely past
	/// a wall's inner face never reaches this (the caller kills it first); only called when the walls
	/// are solid to this character.</summary>
	void TeleportEjectFromArenaWalls()
	{
		RectF a = GetRect();
		bool ejected = false;

		float leftPen = Arena.WALL_SIZE - a.Left;
		float rightPen = a.Right - (Arena.WIDTH - Arena.WALL_SIZE);
		if ( leftPen > 0f )
		{
			if ( Stage.WallDeadlyAt( Direction.Left, Position ) ) { KilledBySpikes( Direction.Left ); return; }
			X = Arena.WALL_SIZE + Width / 2f; VelX = 0f; ExtraVelX = leftPen * TELEPORT_EJECT_FORCE; ejected = true;
		}
		else if ( rightPen > 0f )
		{
			if ( Stage.WallDeadlyAt( Direction.Right, Position ) ) { KilledBySpikes( Direction.Right ); return; }
			X = Arena.WIDTH - Arena.WALL_SIZE - Width / 2f; VelX = 0f; ExtraVelX = -rightPen * TELEPORT_EJECT_FORCE; ejected = true;
		}

		float bottomPen = Arena.WALL_SIZE - a.Bottom;
		float topPen = a.Top - (Arena.HEIGHT - Arena.WALL_SIZE);
		if ( bottomPen > 0f )
		{
			if ( Stage.WallDeadlyAt( Direction.Down, Position ) ) { KilledBySpikes( Direction.Down ); return; }
			Y = Arena.WALL_SIZE + Height / 2f; VelY = bottomPen * TELEPORT_EJECT_FORCE; ExtraVelY = 0f; ejected = true;
		}
		else if ( topPen > 0f )
		{
			if ( Stage.WallDeadlyAt( Direction.Up, Position ) ) { KilledBySpikes( Direction.Up ); return; }
			Y = Arena.HEIGHT - Arena.WALL_SIZE - Height / 2f; VelY = -topPen * TELEPORT_EJECT_FORCE; ExtraVelY = 0f; ejected = true;
		}

		if ( ejected ) DetachPlatform();
	}

	// ----------------------------------------------------------------------------------------
	// A stopped block's Velocity is zero (Block.ImpactEffects zeroes it, banking the travel
	// velocity in PreImpactVelocity), so the raw reads in IsCrushAxisClosing are safe: a block at
	// rest reads as exactly what it is — a stationary wall — and can't phantom-crush a player
	// wedged beside it.
	bool CheckForCrushing()
	{
		if ( CrushingLeft && CrushingRight && IsCrushAxisClosing( _crushingEntityLeft, _crushingEntityRight, vertical: false ) )
			return true;

		return CrushingDown && CrushingUp && IsCrushAxisClosing( _crushingEntityDown, _crushingEntityUp, vertical: true );
	}

	// ----------------------------------------------------------------------------------------
	// One axis of the crush test: the pair is CLOSING when either plate drives inward, unless both
	// plates are real movers travelling the same way with the far one escaping faster than the near
	// one chases (an anvil outrunning its hammer squeezes nothing). near = Left/Down, far = Right/Up
	// (null = the arena wall / an obstacle / a hardened twin — static, so it only ever anvils).
	static bool IsCrushAxisClosing( Entity2D nearEntity, Entity2D farEntity, bool vertical )
	{
		float nearV = nearEntity == null ? 0f : (vertical ? nearEntity.VelY : nearEntity.VelX);
		float farV = farEntity == null ? 0f : (vertical ? farEntity.VelY : farEntity.VelX);

		bool closing = (nearEntity != null && nearV > 0) || (farEntity != null && farV < 0);

		if ( closing && nearEntity != null && farEntity != null )
		{
			if ( (nearV < 0 && farV > 0) ||
				 (nearV < 0 && farV < 0 && Math.Abs( farV ) < Math.Abs( nearV )) ||
				 (nearV > 0 && farV > 0 && Math.Abs( nearV ) < Math.Abs( farV )) )
			{
				closing = false;
			}
		}

		return closing;
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>CRUSH-KILL PRESSES: a body squeezed to death presses the buttons doing the squeezing.
	/// The press gate can never award these itself — the probe-pair kill fires at 0.1px proximity and
	/// returns BEFORE the tick-start resolve pass where squeeze presses live — so a wind block pinning
	/// the player and driving through them stole its own bottom-face press. Called ONLY from the
	/// probe-pair kill branch (fall damage and the residual no-fit rescue reuse KilledByCrushing but
	/// have no vetted crusher pair), and BEFORE the kill: a winning press starts the WIN beat first,
	/// so the death lands inside a victory (BeginGameOver is first-come-first-served and the score
	/// counts block phases, not survival). BOTH Block plates of each closing axis press — the anvil
	/// bears the same force as the hammer — while walls, obstacles and hardened twins have no buttons.
	/// Hostile impostors never press (mirrors the Unpenetrate gate); spiked and already-pressed faces
	/// are refused by PressSide itself.</summary>
	void AwardCrushKillPresses()
	{
		if ( IsImpostor && !IsSwarmClone ) return;

		if ( CrushingLeft && CrushingRight && IsCrushAxisClosing( _crushingEntityLeft, _crushingEntityRight, vertical: false ) )
		{
			if ( _crushingEntityLeft is Block bl && bl.PressSide( Direction.Right, this ) )
				TraceCrush( $"crush-press Right face of block@({bl.X:0.0},{bl.Y:0.0})" );
			if ( _crushingEntityRight is Block br && br.PressSide( Direction.Left, this ) )
				TraceCrush( $"crush-press Left face of block@({br.X:0.0},{br.Y:0.0})" );
		}

		if ( CrushingDown && CrushingUp && IsCrushAxisClosing( _crushingEntityDown, _crushingEntityUp, vertical: true ) )
		{
			if ( _crushingEntityDown is Block bd && bd.PressSide( Direction.Up, this ) )
				TraceCrush( $"crush-press Up face of block@({bd.X:0.0},{bd.Y:0.0})" );
			if ( _crushingEntityUp is Block bu && bu.PressSide( Direction.Down, this ) )
				TraceCrush( $"crush-press Down face of block@({bu.X:0.0},{bu.Y:0.0})" );
		}
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Flatten the Mimic form instead of a crush death (see the SquashState fields).
	/// The body freezes exactly where the kill would have drawn the squish, sheds every attachment
	/// and force, and spends its jump-leniency windows so the pop can't fire a stale (wall-)jump.</summary>
	void EnterSquash( bool horizontal )
	{
		if ( _squashState != SquashState.None ) return;

		_squashState = SquashState.Squashed;
		_squashHorizontal = horizontal;
		_squashFrame = 0;
		_squashTickCounter = 0;

		CancelMantleHang( applyCooldown: false );
		ClearShakeOffset();
		ClearStuck( playUnstuck: false );
		Detach();
		ClearMotion();
		LastVelocity = Vector2.Zero;

		// The blind unpenetrate pass can shove the body THROUGH an arena wall before the crush is
		// detected (it never validates destinations; ClampToBounds normally repairs that later in
		// the tick — which a squash entry skips). Freeze INSIDE the arena or the pancake is judged
		// wall-embedded forever and can never pop back. Pure geometry: the spiked-wall kill stays
		// with the real ClampToBounds.
		if ( _abilities.CollidesWithArenaWalls )
		{
			RectF r = GetRect();
			if ( r.Left < Arena.WALL_SIZE ) X += Arena.WALL_SIZE - r.Left;
			else if ( r.Right > Arena.WIDTH - Arena.WALL_SIZE ) X -= r.Right - (Arena.WIDTH - Arena.WALL_SIZE);
			if ( r.Bottom < Arena.WALL_SIZE ) Y += Arena.WALL_SIZE - r.Bottom;
			else if ( r.Top > Arena.HEIGHT - Arena.WALL_SIZE ) Y -= r.Top - (Arena.HEIGHT - Arena.WALL_SIZE);
		}
		TraceCrush( $"SQUASH h={horizontal} pos=({X:0.00},{Y:0.00})" );
		_wallClingHold = false;
		_wallClingCounter = 0;
		// Same discipline as the twin dash: the windows' decay lives in the movement path the squash
		// skips, so a pre-squash window would otherwise grant a phantom jump the moment control returns.
		_groundedLeniencyCounter = 0;
		_wallJumpLeniencyCounter = 0;
		_wallDirection = Direction.None;
		_wallJumpWallEntity = null;
		_jumpBufferCounter = 0;

		Audio.PlaySfx( SfxType.StickyStick, Position, 0.9f, 0.8f );
		UpdateSquashAnimation();
		foreach ( var ability in _abilityModules )
			ability.OnSquashed( this );
	}

	/// <summary>The squashed Mimic's whole fixed step (called instead of the normal tick body).
	/// Hazards keep their checks; the body advances/holds/reverses the squish frames and pops back
	/// the moment its collision box fits again.</summary>
	void TickSquash()
	{
		// Hazards kill a flattened Mimic only while it is VISIBLE (flattening / hold beat / popping);
		// the fully-hidden pancake is untouchable — the external kill paths gate on IsSquashHidden,
		// and the checks here skip for the same stretch.
		if ( !IsSquashHidden )
		{
			foreach ( Fireball fireball in Stage.GetFireballs() )
			{
				if ( !fireball.IsSpawning && !fireball.IsDissipating && GetRect().Intersects( fireball.Hitbox ) )
				{
					KilledByFireball( fireball.Position );
					return;
				}
			}
			foreach ( Teardrop teardrop in Stage.GetTeardrops() )
			{
				if ( !teardrop.HasSplashed && GetRect().Intersects( teardrop.Hitbox ) )
				{
					KilledByTeardrop( teardrop.Position );
					return;
				}
			}
			// Spiked faces can still grind into the visible flattened body. We're motionless with no
			// platform, so only CheckForSpikes' block-velocity terms can fire — resting spike contact
			// stays survivable, exactly like standing flush beside spikes.
			const float THRESHOLD = 1.0f;
			CollidingLeft = IsPlayerColliding( X - THRESHOLD, Y, Direction.Left, out _collidingEntityLeft );
			CollidingRight = IsPlayerColliding( X + THRESHOLD, Y, Direction.Right, out _collidingEntityRight );
			CollidingDown = IsPlayerColliding( X, Y - THRESHOLD, Direction.Down, out _collidingEntityDown );
			CollidingUp = IsPlayerColliding( X, Y + THRESHOLD, Direction.Up, out _collidingEntityUp );
			if ( CheckForSpikes() ) return;
		}

		int lastFrame = (_squashHorizontal ? SQUISH_H_FRAMES : SQUISH_V_FRAMES) - 1;
		if ( _squashState == SquashState.Squashed )
		{
			// Flatten at roughly the anim's native rate, show the final frame for a beat, then hide
			// (see SQUASH_HIDE_DELAY_TICKS) and wait for room. The tick counter paces the frame steps
			// while flattening and, once flat, doubles as the hide-delay clock.
			if ( _squashFrame < lastFrame )
			{
				if ( ++_squashTickCounter >= SQUASH_IN_TICKS_PER_FRAME )
				{
					_squashTickCounter = 0;
					_squashFrame++;
				}
			}
			else if ( _squashTickCounter < SQUASH_HIDE_DELAY_TICKS )
			{
				// The tick this reaches the delay is the vanish moment: dust squirts out of the pinch.
				if ( ++_squashTickCounter >= SQUASH_HIDE_DELAY_TICKS )
				{
					AddSquashHideParticles();
					_squashCueTicks = 0;
				}
			}
			else
			{
				// Hidden: a quiet goo pulse repeats so the pancake never reads as despawned.
				if ( ++_squashCueTicks >= SQUASH_CUE_INTERVAL_TICKS )
				{
					_squashCueTicks = 0;
					Audio.PlaySfx( SfxType.StickyStick, Position, 0.3f, Rng.CosmeticFloat( 1.3f, 1.5f ) );
				}
				if ( HasRoomToUnsquash() )
					_squashState = SquashState.Unsquashing;
			}
		}
		else
		{
			// Unsquashing: the same squish anim in reverse, one frame per tick ("quickly"). The
			// re-check must be the SAME predicate that started the pop: a strict clear-only test here
			// would ping-pong Squashed<->Unsquashing forever on the sub-epsilon resting overlap the
			// lenient side waves through (it never gets a tick to actually pop and resolve it).
			if ( !HasRoomToUnsquash() )
			{
				// Room closed again mid-pop: flatten back down (with the show-then-hide beat re-armed).
				_squashState = SquashState.Squashed;
				_squashTickCounter = 0;
			}
			else if ( --_squashFrame <= 0 )
			{
				ExitSquash();
				return;
			}
		}

		UpdateSquashAnimation();
	}

	/// <summary>Whether the flattened body can pop back (gates starting AND continuing the reverse
	/// anim): its full box fits where it lies, or — once nothing overlapping it is still moving — a
	/// small validated residual push-out (the same rescue the no-fit kill ran) frees it from static
	/// geometry it was squashed into. The rescue is refused while any overlapping block is in motion
	/// so the pancake is never bulldozed along ahead of a crusher; a mover passes over the frozen
	/// body instead.</summary>
	bool HasRoomToUnsquash()
	{
		if ( IsSpotClearOfSolids( X, Y ) ) return true;

		RectF rect = GetRect();
		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block.PhasingIn ) continue; // a phasing Teleport block is intangible to the player
			if ( block.Velocity != Vector2.Zero && rect.Intersects( block.GetRect() ) )
				return false;
		}
		// True either because only float-noise overlap remains (the normal machinery absorbs it next
		// tick) or because the rescue moved us to a spot it validated clear of every solid.
		return TryResolveResidualPenetration( out _ );
	}

	/// <summary>Whether the pinch that just tried to crush the Mimic pressed it into a LIVE spiked
	/// surface on the crush axis — a spiked block face, spiked obstacle face, or spiked arena-wall
	/// stretch. Only the crush axis is tested: a vertical pinch beside a spiked side wall flattens
	/// against the floor, not into the teeth (flush side contact is survivable, as when standing).</summary>
	bool CrushedIntoSpikes( bool horizontal, out Direction spikesDirection )
	{
		Direction near = horizontal ? Direction.Left : Direction.Down;
		Direction far = horizontal ? Direction.Right : Direction.Up;
		if ( SpikedSurfaceAt( near ) ) { spikesDirection = near; return true; }
		if ( SpikedSurfaceAt( far ) ) { spikesDirection = far; return true; }
		spikesDirection = Direction.None;
		return false;
	}

	/// <summary>Whether a live spiked surface presses on our <paramref name="side"/>: its face plane
	/// anywhere from just past our edge (the collision probes' 1px contact window) in to our CENTRE —
	/// a fast crusher's final step can bury its teeth inside the body before any spike check has seen
	/// the contact, and the blind unpenetrate pass can park us overlapping a spiked slot. Spiked
	/// arena walls are lethal regardless of wall solidity (mirrors ClampToBounds).
	/// KNOWN LENIENCY (accepted): the wall/obstacle queries are centre-keyed, so a body pinched
	/// exactly astride a SPLIT face's seam with its centre over the harmless segment squashes
	/// instead of spike-dying. The miss is survival-as-squash (self-corrects on the pop — moving
	/// into the teeth kills normally); the rect-span variant can't serve here (strict-flush plane,
	/// no buried-teeth range), so covering it would need a new face query for a ~4px seam case.</summary>
	bool SpikedSurfaceAt( Direction side )
	{
		const float REACH = 1.0f;
		RectF r = GetRect();
		bool verticalSide = side is Direction.Down or Direction.Up;

		bool atWall = side switch
		{
			Direction.Left => r.Left <= Arena.WALL_SIZE + REACH,
			Direction.Right => r.Right >= Arena.WIDTH - Arena.WALL_SIZE - REACH,
			Direction.Down => r.Bottom <= Arena.WALL_SIZE + REACH,
			_ => r.Top >= Arena.HEIGHT - Arena.WALL_SIZE - REACH,
		};
		if ( atWall && Stage.WallDeadlyAt( side, Position ) )
			return true;

		Direction normal = Globals.GetOppositeDirection( side ); // the touching face points back at us
		bool PlaneInRange( float plane ) => side switch
		{
			Direction.Left => plane >= r.Left - REACH && plane <= X,
			Direction.Right => plane <= r.Right + REACH && plane >= X,
			Direction.Down => plane >= r.Bottom - REACH && plane <= Y,
			_ => plane <= r.Top + REACH && plane >= Y,
		};
		// Strict ends, like the flush-face queries: zero tangent overlap is BESIDE the face.
		bool TangentOverlaps( RectF b ) => verticalSide
			? r.Right > b.Left && r.Left < b.Right
			: r.Top > b.Bottom && r.Bottom < b.Top;
		float FacePlane( RectF b ) => side switch
		{
			Direction.Left => b.Right,
			Direction.Right => b.Left,
			Direction.Down => b.Top,
			_ => b.Bottom,
		};

		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block.IsDead || block.PhasingIn || !block.SideDeadly( normal ) ) continue;
			RectF b = block.GetRect();
			if ( PlaneInRange( FacePlane( b ) ) && TangentOverlaps( b ) )
				return true;
		}

		foreach ( Obstacle ob in Stage.GetObstacles() )
		{
			if ( !ObstacleSolidToUs( ob ) ) continue;
			RectF b = ob.GetRect();
			if ( PlaneInRange( FacePlane( b ) ) && TangentOverlaps( b )
				&& Stage.ObstacleFaceDeadlyAt( ob, normal, Position ) )
				return true;
		}

		return false;
	}

	/// <summary>The flattened body just vanished under the crusher (see SQUASH_HIDE_DELAY_TICKS): a
	/// dust puff squirts out perpendicular to the crush axis — matter squeezed out of the pinch — so
	/// the disappearance reads as a squeeze, not a despawn. Cosmetic RNG only (no sim effect).</summary>
	void AddSquashHideParticles()
	{
		int count = Rng.CosmeticInt( 6, 11 );
		for ( int i = 0; i < count; i++ )
		{
			// Crushed vertically -> squirt sideways along the seam; crushed horizontally -> up/down.
			Vector2 spread = _squashHorizontal
				? new Vector2( Rng.CosmeticFloat( -1f, 1f ) * 0.15f, Rng.CosmeticFloat( -1f, 1f ) * 2f )
				: new Vector2( Rng.CosmeticFloat( -1f, 1f ) * 2f, Rng.CosmeticFloat( -1f, 1f ) * 0.15f );
			Stage.AddParticle(
				Position,
				spread * Rng.CosmeticFloat( 25f, 55f ),
				Rng.CosmeticFloat( 0.90f, 0.95f ),
				Globals.GRAVITY_STR_DUST,
				ParticleKind.Dust,
				Rng.CosmeticFloat( 0.30f, 0.6f ),
				Rng.CosmeticInt( 2, 5 )
			);
		}
	}

	/// <summary>The reverse squish finished: return control. Everything banked during the flatten is
	/// discarded, and the live force fields start re-feeding their channels next tick.</summary>
	void ExitSquash()
	{
		TraceCrush( $"UNSQUASH pos=({X:0.00},{Y:0.00})" );
		_squashState = SquashState.None;
		_squashFrame = 0;
		ClearMotion();
		LastVelocity = Vector2.Zero;
		if ( _sprite is not null )
		{
			_sprite.PlaybackSpeed = 1f;
			_sprite.Enabled = true; // always visible through the pop, but assert it anyway
		}
		Audio.PlaySfx( SfxType.StickyUnstuck, Position, 0.9f, 1.2f );
		foreach ( var ability in _abilityModules )
			ability.OnUnsquashed( this );
	}

	/// <summary>Assert the squish pose from sim state (every squashed tick + after a replay seek
	/// rebuilds presentation): clock paused, frame scrubbed by hand — PlayAnimation resets the
	/// engine playback clock, so the sprite can never be allowed to advance itself here. The held
	/// stretch hides the body outright (the pancake art doesn't sit on the true crush seam); both
	/// the frame and the visibility derive purely from sim state so a rebuild lands correct.</summary>
	void UpdateSquashAnimation()
	{
		if ( _sprite is null ) return;
		string name = _squashHorizontal ? "squish_h" : "squish_v";
		_sprite.Enabled = !IsSquashHidden;
		_sprite.PlaybackSpeed = 0f;
		if ( _currentAnimName != name )
		{
			_currentAnimName = name;
			_sprite.PlayAnimation( name );
			foreach ( var ability in _abilityModules )
				ability.OnAnimationChanged( this, name );
		}
		_sprite.CurrentFrameIndex = _squashFrame;
	}

	// ----------------------------------------------------------------------------------------
	// Spikes only kill on RELATIVE MOTION into them — flush contact alone is survivable. Each
	// direction checks: us moving into the spikes (TotalVelocity, so a dash, fling, or any field
	// push — wind/magnet/siren/shockwave — into spikes counts as moving), the spiked block moving
	// into us, or our platform carrying us into them. A stopped block's Velocity is zero (see Block.ImpactEffects), so a block at
	// rest reads as stationary and spikes landing on it can't kill a player standing flush beside it.
	// The VERTICAL branches also accept LAST tick's velocity into the spikes: a fast vertical impact
	// can cross the 1px contact window inside one AdjustYPosition, so the first tick that reports the
	// contact starts with VelY already zeroed by the resolve and TotalVelocity alone misses the slam.
	// Up needs it for the normal-gravity head-bonk; Down for the reverse-gravity slam onto a spiked
	// top, where gravity lifts us off instead of re-banking the toward-floor hair that rescues the
	// normal-gravity case. (LastVelocity is captured right after this check, pre-resolve.)
	bool CheckForSpikes()
	{
		bool spiked = false;
		Direction spikesDirection = Direction.None;
		Entity2D platform = PlatformEntity; // the block carrying us can shove us into spikes

		if ( _collidingEntityLeft is Block bl && bl.SideDeadly( Direction.Right ) )
		{
			float xDiff = Math.Abs( X - _collidingEntityLeft.X );
			float yDiff = Math.Abs( Y - _collidingEntityLeft.Y );
			if ( xDiff > yDiff )
			{
				if ( TotalVelocity.x < 0 || bl.VelX > 0 || (platform != null && platform.VelX < 0) )
				{
					spiked = true;
					spikesDirection = Direction.Left;
				}
			}
		}

		if ( _collidingEntityRight is Block br && br.SideDeadly( Direction.Left ) )
		{
			float xDiff = Math.Abs( X - _collidingEntityRight.X );
			float yDiff = Math.Abs( Y - _collidingEntityRight.Y );
			if ( xDiff > yDiff )
			{
				if ( TotalVelocity.x > 0 || br.VelX < 0 || (platform != null && platform.VelX > 0) )
				{
					spiked = true;
					spikesDirection = Direction.Right;
				}
			}
		}

		if ( _collidingEntityDown is Block bd && bd.SideDeadly( Direction.Up ) )
		{
			float xDiff = Math.Abs( X - _collidingEntityDown.X );
			float yDiff = Math.Abs( Y - _collidingEntityDown.Y );
			if ( yDiff > xDiff )
			{
				if ( (TotalVelocity.y < 0 || LastVelocity.y < 0) || bd.VelY > 0 || (platform != null && platform.VelY < 0) )
				{
					spiked = true;
					spikesDirection = Direction.Down;
				}
			}
		}

		if ( _collidingEntityUp is Block bu && bu.SideDeadly( Direction.Down ) )
		{
			float xDiff = Math.Abs( X - _collidingEntityUp.X );
			float yDiff = Math.Abs( Y - _collidingEntityUp.Y );
			if ( yDiff > xDiff )
			{
				if ( (TotalVelocity.y > 0 || LastVelocity.y > 0) || bu.VelY < 0 || (platform != null && platform.VelY > 0) )
				{
					spiked = true;
					spikesDirection = Direction.Up;
				}
			}
		}

		if ( spiked )
		{
			KilledBySpikes( spikesDirection );
			return true;
		}

		return false;
	}

	// Impostor deaths are pitched UP + a bit QUIETER than the player's, so a clone dying nearby doesn't
	// read as YOUR death (it's far less consequential).
	const float IMPOSTOR_DEATH_VOLUME = 0.5f;
	const float IMPOSTOR_DEATH_PITCH = 1.6f;

	/// <summary>Play a death sound at this body's position, automatically pitched up + quieter when this
	/// body is an impostor. For the real player it is the same call as before.</summary>
	void PlayDeathSfx( SfxType type, float volume = 1f, float pitch = 1f )
	{
		if ( IsImpostor ) { volume *= IMPOSTOR_DEATH_VOLUME; pitch *= IMPOSTOR_DEATH_PITCH; }
		Audio.PlaySfx( type, Position, volume, pitch );
	}

	void PlayCharacterDeathSfx( float volume = 1f, float pitch = 1f )
		=> PlayDeathSfx( Character.Audio.Death, volume, pitch );

	const float BLOOD_COUNT_MAX_SCALE = 1.7f;
	const float SWARM_BLACK_BLOOD_AMOUNT = 0.65f;

	void SpawnDeathParticles( Vector2 impactDirection, int countMin, int countMax, Func<Vector2> createVelocity,
		float decelMin, float decelMax, float lifetimeMin, float lifetimeMax, int sizeMin, int sizeMax )
	{
		float bloodAmount = 1f;
		switch ( Character.DeathFx )
		{
			case DeathFxStyle.Solar: SpawnMachineDeathParticles( impactDirection ); return;
			case DeathFxStyle.Spring: SpawnToyDeathParticles( impactDirection ); return;
			case DeathFxStyle.Bird: SpawnBirdDeathParticles( impactDirection ); bloodAmount = 0.66f; break;
			case DeathFxStyle.Wrapper: SpawnWrapperDeathParticles( impactDirection ); return;
			case DeathFxStyle.Gunner: SpawnGunnerDeathParticles( impactDirection ); break;
		}

		// Blood sprays (every character's BloodColor, whatever its hue) roll up to 1.5× the caller's
		// max — min untouched, so light sprays still happen. The special material bursts are exempt:
		// solar/spring/wrapper return before this loop, and bird/gunner spawn their feathers/gun/armor
		// in their own methods above, so only their blood share scales.
		int count = (int)MathF.Round( Rng.CosmeticInt( countMin, (int)(countMax * BLOOD_COUNT_MAX_SCALE) ) * bloodAmount );
		for ( int i = 0; i < count; i++ )
		{
			Stage.AddPhysicsParticle( Position, createVelocity(), Rng.CosmeticFloat( decelMin, decelMax ),
				Globals.GRAVITY_STR_BLOOD, Character.BloodColor, Rng.CosmeticFloat( lifetimeMin, lifetimeMax ),
				Rng.CosmeticInt( sizeMin, sizeMax ) );
		}

		if ( !Character.Abilities.HasSwarm && !IsSwarmClone ) return;

		int blackCount = Math.Max( 1, (int)MathF.Round( count * SWARM_BLACK_BLOOD_AMOUNT ) );
		for ( int i = 0; i < blackCount; i++ )
		{
			Stage.AddPhysicsParticle( Position, createVelocity(), Rng.CosmeticFloat( decelMin, decelMax ),
				Globals.GRAVITY_STR_BLOOD, Color.Black, Rng.CosmeticFloat( lifetimeMin, lifetimeMax ),
				Rng.CosmeticInt( sizeMin, sizeMax ) );
		}
	}

	void SpawnMachineDeathParticles( Vector2 direction )
	{
		Color darkMetal = new( 0.16f, 0.18f, 0.20f );
		Color lightMetal = new( 0.55f, 0.59f, 0.61f );

		SpawnMaterialParticles( direction, 7, darkMetal, 55f, 145f, 0.97f, -120f, 0.7f, 1.4f, 2, 6 );
		for ( int i = 0; i < 6; i++ )
		{
			Stage.AddPhysicsParticle( Position, MaterialVelocity( direction, 80f, 180f ),
				0.985f, -320f, lightMetal, Rng.CosmeticFloat( 0.8f, 1.6f ), Rng.CosmeticInt( 2, 5 ) );
		}

		BeginSolarDeathSparks();
	}

	void BeginSolarDeathSparks()
	{
		_solarDeathBurstOrigin = Position;
		_solarDeathBurstsRemaining = Rng.CosmeticInt( 2, 5 );
		SpawnSolarDeathSparkBurst();
		_solarDeathBurstsRemaining--;
		if ( _solarDeathBurstsRemaining > 0 )
			_solarDeathBurstTimer = Rng.CosmeticFloat( SOLAR_DEATH_BURST_DELAY_MIN, SOLAR_DEATH_BURST_DELAY_MAX );
	}

	void TickSolarDeathSparks( float dt )
	{
		if ( _solarDeathBurstsRemaining <= 0 ) return;

		_solarDeathBurstTimer -= dt;
		while ( _solarDeathBurstsRemaining > 0 && _solarDeathBurstTimer <= 0f )
		{
			SpawnSolarDeathSparkBurst();
			_solarDeathBurstsRemaining--;
			if ( _solarDeathBurstsRemaining > 0 )
				_solarDeathBurstTimer += Rng.CosmeticFloat( SOLAR_DEATH_BURST_DELAY_MIN, SOLAR_DEATH_BURST_DELAY_MAX );
		}
	}

	void SpawnSolarDeathSparkBurst()
	{
		Vector2 center = _solarDeathBurstOrigin + new Vector2(
			Rng.CosmeticFloat( -SOLAR_DEATH_BURST_CENTER_RADIUS, SOLAR_DEATH_BURST_CENTER_RADIUS ),
			Rng.CosmeticFloat( -SOLAR_DEATH_BURST_CENTER_RADIUS, SOLAR_DEATH_BURST_CENTER_RADIUS ) );
		for ( int i = 0; i < 4; i++ )
			SpawnSolarDeathBolt( center );
	}

	void SpawnSolarDeathBolt( Vector2 burstCenter )
	{
		const float SPARK_RADIUS = 10f;
		Color sparkColor = new( 0.45f, 0.92f, 1f );
		Color sparkHighlight = new( 1f, 0.9f, 0.35f );
		Vector2 origin = burstCenter + new Vector2(
			Rng.CosmeticFloat( -SPARK_RADIUS, SPARK_RADIUS ),
			Rng.CosmeticFloat( -SPARK_RADIUS, SPARK_RADIUS ) );
		float angle = Rng.CosmeticFloat( 0f, MathF.Tau );
		Vector2 forward = new( MathF.Cos( angle ), MathF.Sin( angle ) );
		Vector2 side = new( -forward.y, forward.x );
		int segmentCount = Rng.CosmeticInt( 5, 10 );
		int branchAt = Rng.CosmeticValue() < 0.45f ? Rng.CosmeticInt( 1, segmentCount - 1 ) : -1;
		float stepLength = Rng.CosmeticFloat( 1.9f, 2.8f );
		float bend = Rng.CosmeticFloat( 1f, 2.2f );
		float bendSign = Rng.CosmeticValue() < 0.5f ? -1f : 1f;
		float center = (segmentCount - 1) * 0.5f;

		for ( int i = 0; i < segmentCount; i++ )
		{
			float zig = (i % 2 == 0 ? -bend : bend) * bendSign;
			Vector2 pos = origin + forward * ((i - center) * stepLength) + side * zig;
			Color color = Rng.CosmeticValue() < 0.3f ? sparkHighlight : sparkColor;
			Stage.AddColoredParticle( pos, Vector2.Zero, 1f, color,
				Rng.CosmeticFloat( 0.1f, 0.2f ), 2, Globals.DEPTH_PARTICLE_1 );

			if ( i != branchAt ) continue;
			Vector2 branchDirection = Utils.Normalized( side * (Rng.CosmeticValue() < 0.5f ? -1f : 1f) + forward * 0.35f );
			int branchLength = Rng.CosmeticInt( 2, 5 );
			for ( int branchStep = 1; branchStep <= branchLength; branchStep++ )
			{
				Vector2 branchPos = pos + branchDirection * (stepLength * branchStep);
				Stage.AddColoredParticle( branchPos, Vector2.Zero, 1f, sparkHighlight,
					Rng.CosmeticFloat( 0.09f, 0.17f ), 2, Globals.DEPTH_PARTICLE_1 );
			}
		}
	}

	void SpawnToyDeathParticles( Vector2 direction )
	{
		Color spring = new( 0.15f, 0.17f, 0.18f );
		for ( int i = 0; i < 8; i++ )
		{
			Stage.AddPhysicsParticle( Position, MaterialVelocity( direction, 110f, 230f ),
				0.99f, -260f, spring, Rng.CosmeticFloat( 0.9f, 1.7f ), Rng.CosmeticInt( 2, 5 ) );
		}

		SpawnMaterialParticles( direction, 5, Color.White, 65f, 145f, 0.96f, -160f, 0.65f, 1.25f, 2, 6 );
		Color[] hairColors = { new( 0.9f, 0.15f, 0.18f ), new( 0.18f, 0.72f, 0.32f ), new( 0.16f, 0.38f, 0.9f ) };
		for ( int i = 0; i < 9; i++ )
			Stage.AddPhysicsParticle( Position, MaterialVelocity( direction, 170f, 275f ), 0.84f, -35f,
				hairColors[i % hairColors.Length], Rng.CosmeticFloat( 0.7f, 1.3f ), Rng.CosmeticInt( 2, 5 ) );
	}

	void SpawnBirdDeathParticles( Vector2 direction )
	{
		Color orange = new( 0.92f, 0.48f, 0.12f );
		SpawnMaterialParticles( direction, Rng.CosmeticInt( 2, 5 ), orange, 85f, 175f, 0.985f, -280f, 0.85f, 1.5f, 2, 5 );

		Color brown = new( 0.36f, 0.24f, 0.16f );
		for ( int i = 0; i < 13; i++ )
		{
			Color color = i % 3 == 0 ? Color.White : brown;
			Particle particle = Stage.AddPhysicsParticle( Position, MaterialVelocity( direction, 135f, 235f ),
				0.86f, Rng.CosmeticFloat( -55f, -12f ), color, Rng.CosmeticFloat( 1.1f, 2f ), Rng.CosmeticInt( 2, 5 ) );
			particle.SetMotion( ParticleMotion.Feather, Rng.CosmeticFloat( 0f, MathF.Tau ) );
			particle.SetCollision( collidesWithBlocks: true, ParticleSolidResponse.Stop );
		}
	}

	void SpawnWrapperDeathParticles( Vector2 direction )
	{
		Color cape = new( 0.72f, 0.08f, 0.12f );
		int capeCount = Rng.CosmeticInt( 1, 4 );
		for ( int i = 0; i < capeCount; i++ )
			Stage.AddPhysicsParticle( Position, MaterialVelocity( direction, 130f, 220f ), 0.84f, -35f,
				cape, Rng.CosmeticFloat( 0.8f, 1.4f ), Rng.CosmeticInt( 2, 5 ) );

		int boneCount = Rng.CosmeticInt( 11, 16 );
		for ( int i = 0; i < boneCount; i++ )
		{
			Particle bone = Stage.AddPhysicsParticle( Position, MaterialVelocity( direction, 70f, 170f ),
				0.985f, -210f, Color.White, Rng.CosmeticFloat( 0.9f, 1.7f ), Rng.CosmeticInt( 2, 5 ) );
			bone.SetCollision( collidesWithBlocks: true, ParticleSolidResponse.Bounce, restitution: 0.68f );
		}
	}

	void SpawnGunnerDeathParticles( Vector2 direction )
	{
		Color gunMetal = Color.Black;
		int gunCount = Rng.CosmeticInt( 2, 4 );
		for ( int i = 0; i < gunCount; i++ )
		{
			Particle gunPart = Stage.AddPhysicsParticle( Position, MaterialVelocity( direction, 85f, 165f ),
				0.992f, -620f, gunMetal, Rng.CosmeticFloat( 0.85f, 1.5f ), Rng.CosmeticInt( 3, 6 ) );
			gunPart.SetCollision( collidesWithBlocks: true, ParticleSolidResponse.Bounce, restitution: 0.48f );
		}

		Color armorRed = new( 140f / 255f, 83f / 255f, 83f / 255f );
		Color armorYellow = new( 240f / 255f, 255f / 255f, 137f / 255f );
		int armorCount = Rng.CosmeticInt( 4, 7 );
		for ( int i = 0; i < armorCount; i++ )
		{
			Particle armor = Stage.AddPhysicsParticle( Position, MaterialVelocity( direction, 70f, 150f ),
				0.99f, -500f, i % 2 == 0 ? armorRed : armorYellow,
				Rng.CosmeticFloat( 0.75f, 1.35f ), Rng.CosmeticInt( 2, 4 ) );
			armor.SetCollision( collidesWithBlocks: true, ParticleSolidResponse.Bounce, restitution: 0.3f );
		}
	}

	void SpawnMaterialParticles( Vector2 direction, int count, Color color, float speedMin, float speedMax,
		float deceleration, float gravity, float lifetimeMin, float lifetimeMax, int sizeMin, int sizeMax )
	{
		for ( int i = 0; i < count; i++ )
			Stage.AddPhysicsParticle( Position, MaterialVelocity( direction, speedMin, speedMax ), deceleration,
				gravity, color, Rng.CosmeticFloat( lifetimeMin, lifetimeMax ), Rng.CosmeticInt( sizeMin, sizeMax ) );
	}

	static Vector2 MaterialVelocity( Vector2 direction, float speedMin, float speedMax )
	{
		Vector2 scatter = Utils.Normalized( new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) );
		return scatter * Rng.CosmeticFloat( speedMin, speedMax ) + direction * Rng.CosmeticFloat( 20f, 65f );
	}

	// ----------------------------------------------------------------------------------------
	void KilledByCrushing( bool horizontal, [CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
	{
		TraceDeath( $"crush {(horizontal ? "H" : "V")} from {caller}:{callerLine} {DeathTraceState}" );
		if ( IsTwinDashing ) return;
		// The Mimic's own form is uncrushable: it flattens in place instead and pops back once its
		// box fits again (see TickSquash). Its temporary forms die like the characters they copy.
		if ( _abilities.HasMimic && !IsImpostor )
		{
			// EXCEPT into spikes: a pinch that presses the body into a live spiked surface is a
			// spike death, not a squash. Checked here because the motion-into-spikes rules can't
			// see a bulldoze (position pushes carry no velocity, and the crush fires before the
			// tick's contact/spike pass runs). Entry only — an already-squashed body re-routes
			// through the EnterSquash no-op below and never re-tests.
			if ( _squashState == SquashState.None && CrushedIntoSpikes( horizontal, out Direction spikesDir ) )
			{
				KilledBySpikes( spikesDir );
				return;
			}
			EnterSquash( horizontal );
			return;
		}
		if ( Character.DeathFx == DeathFxStyle.Blood ) PlayDeathAnim( horizontal ? "squish_h" : "squish_v" );
		else Explode();

		SpawnDeathParticles( Vector2.Zero, 12, 18,
			() => new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 50f, 200f ),
			0.95f, 0.98f, 0.33f, 1.2f, 2, 7 );

		PlayCharacterDeathSfx();
		Die();
	}

	// ----------------------------------------------------------------------------------------
	void KilledBySpikes( Direction spikesDirection, [CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
	{
		TraceDeath( $"spikes from={spikesDirection} at {caller}:{callerLine} {DeathTraceState}" );
		if ( IsTwinDashing || IsSquashHidden ) return;
		if ( _swarmTrace && IsSwarmBody ) TraceSwarm( $"SPIKES {SwarmLabel} from={spikesDirection} pos=({X:0.00},{Y:0.00})" );
		Vector2 bloodDir = Globals.GetVectorForDirection( Globals.GetOppositeDirection( spikesDirection ) );

		SpawnDeathParticles( bloodDir, 10, 18, () =>
		{
			Vector2 vel = bloodDir * Rng.CosmeticFloat( 40f, 100f );
			vel += new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 20f, 80f );
			return vel;
		}, 0.96f, 0.99f, 0.33f, 1.5f, 3, 8 );

		PlayCharacterDeathSfx();
		Explode();
		Die();
	}

	// ----------------------------------------------------------------------------------------
	public void KilledByFireball( Vector2 fireballPos, [CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
	{
		TraceDeath( $"fireball at ({fireballPos.x:0.00},{fireballPos.y:0.00}) from {caller}:{callerLine} {DeathTraceState}" );
		if ( IsTwinDashing || IsSquashHidden ) return;
		Vector2 bloodDir = Utils.Normalized( Position - fireballPos );

		SpawnDeathParticles( bloodDir, 12, 20, () =>
		{
			Vector2 vel = bloodDir * Rng.CosmeticFloat( 50f, 100f );
			vel += new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 40f, 100f );
			return vel;
		}, 0.96f, 0.98f, 0.5f, 1.5f, 2, 7 );

		PlayDeathSfx( SfxType.FireballHitPlayer );
		if ( Character.Audio.Death != SfxType.PlayerSquish ) PlayCharacterDeathSfx();
		Explode();
		Die();
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Killed by a Gunner bullet. This is used for Summoner impostors; the shot itself passes
	/// through the body and continues travelling.</summary>
	public void KilledByBullet( Vector2 bulletPos, [CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
	{
		TraceDeath( $"bullet at ({bulletPos.x:0.00},{bulletPos.y:0.00}) from {caller}:{callerLine} {DeathTraceState}" );
		if ( IsDead || !IsImpostor ) return;
		Vector2 bloodDir = Utils.Normalized( Position - bulletPos );

		SpawnDeathParticles( bloodDir, 12, 20, () =>
		{
			Vector2 vel = bloodDir * Rng.CosmeticFloat( 50f, 100f );
			vel += new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 40f, 100f );
			return vel;
		}, 0.96f, 0.98f, 0.5f, 1.5f, 2, 7 );

		PlayDeathSfx( SfxType.FireballHitPlayer );
		if ( Character.Audio.Death != SfxType.PlayerSquish ) PlayCharacterDeathSfx();
		Explode();
		Die();
	}

	// ----------------------------------------------------------------------------------------
	public void KilledByLaser( Vector2 dir, [CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
	{
		TraceDeath( $"laser dir=({dir.x:0.00},{dir.y:0.00}) from {caller}:{callerLine} {DeathTraceState}" );
		if ( IsTwinDashing || IsSquashHidden ) return;
		SpawnDeathParticles( dir, 10, 16, () =>
		{
			Vector2 vel = dir * Rng.CosmeticFloat( 33f, 80f );
			vel += new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 20f, 70f );
			return vel;
		}, 0.96f, 0.98f, 0.5f, 1.5f, 2, 6 );

		// The laser_hit_player clip is inherently very faint; layer the punchy squish death
		// sound (used by spikes) so the kill reads clearly while keeping the laser sizzle.
		PlayDeathSfx( SfxType.LaserHitPlayer );
		PlayCharacterDeathSfx();
		Explode();
		Die();
	}

	// ----------------------------------------------------------------------------------------
	public void KilledByTeardrop( Vector2 teardropPos, [CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
	{
		TraceDeath( $"teardrop at ({teardropPos.x:0.00},{teardropPos.y:0.00}) from {caller}:{callerLine} {DeathTraceState}" );
		if ( IsTwinDashing || IsSquashHidden ) return;
		Vector2 bloodDir = Utils.Normalized( Position - teardropPos );

		SpawnDeathParticles( bloodDir, 10, 16, () =>
		{
			Vector2 vel = bloodDir * Rng.CosmeticFloat( 33f, 80f );
			vel += new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 40f, 70f );
			return vel;
		}, 0.96f, 0.98f, 0.5f, 1.5f, 2, 6 );

		// The tear-hit clip alone is too soft to sell a death; layer the punchy squish death
		// sound (same approach as KilledByLaser) so the kill reads clearly while keeping the splash.
		PlayDeathSfx( SfxType.TearHitPlayer );
		PlayCharacterDeathSfx( volume: 0.85f, pitch: 0.7f );
		Explode();
		Die();
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Killed by touching a Venom block's lethal acid trail. Bursts the player (explode + blood),
	/// spraying away from the trail along <paramref name="dir"/> (a unit direction).</summary>
	public void KilledByVenom( Vector2 dir, [CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
	{
		TraceDeath( $"venom dir=({dir.x:0.00},{dir.y:0.00}) from {caller}:{callerLine} {DeathTraceState}" );
		if ( IsTwinDashing || IsSquashHidden ) return;
		SpawnDeathParticles( dir, 12, 20, () =>
		{
			Vector2 vel = dir * Rng.CosmeticFloat( 40f, 100f );
			vel += new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 30f, 90f );
			return vel;
		}, 0.96f, 0.99f, 0.33f, 1.5f, 3, 8 );

		PlayCharacterDeathSfx();
		Explode();
		Die();
	}

	// ----------------------------------------------------------------------------------------
	void Explode()
	{
		PlayDeathAnim( "explode" );
	}

	void PlayDeathAnim( string name )
	{
		_pendingDeathAnimation = name;
		_currentAnimName = name;
		_animInitialized = false; // bypass the dedupe guard for one-shot death anims
		if ( _sprite is null ) return;
		// A Mimic squash pauses the sprite's clock to scrub the squish by hand (and hides the body
		// during the held stretch); a hazard death mid-squash must hand the clock back and re-show
		// the sprite, or the death anim would play invisible / frozen on its first frame.
		_sprite.PlaybackSpeed = 1f;
		_sprite.Enabled = true;

		// Hide the body once the death animation finishes, matching the original's
		// DeathAnimDone (which set IsVisible = false). Without this the sprite parks on
		// its final frame forever — harmless for "explode" (last frame is ~empty) but the
		// "squish" deaths leave the squashed body lingering on screen. Disabling the
		// renderer is idempotent, which matters because a non-looping sprite re-raises
		// OnAnimationEnd every frame interval while parked on its last frame.
		_sprite.OnAnimationEnd = OnDeathAnimationFinished;
		_sprite.PlayAnimation( name );
		foreach ( var ability in _abilityModules )
			ability.OnAnimationChanged( this, name );
	}

	void OnDeathAnimationFinished( string _ )
	{
		if ( _sprite is not null )
			_sprite.Enabled = false;
		foreach ( var ability in _abilityModules )
			ability.OnDeathAnimationFinished( this );
	}

	/// <summary>Leave the already-started death animation at the current fatal position while this
	/// Player body is recovered elsewhere by an ability. Blood and sound have already been emitted by
	/// the kill path before the death interception hook runs.</summary>
	public void PreserveDeathAnimationAtCurrentPosition()
	{
		if ( string.IsNullOrEmpty( _pendingDeathAnimation ) ) return;

		var character = Character ?? Characters.Original;
		GameObject root = Stage.CreateChild( "RewindDeathEcho" );
		RectF rect = GetPixelRect( X, Y );
		root.WorldPosition = new Vector3(
			(rect.Left + rect.Right) * 0.5f,
			(rect.Bottom + rect.Top) * 0.5f,
			Globals.DepthToZ( Depth ) );
		SpriteRenderer deathSprite = SpriteLayer.Add( root, character.SpritePath, character.ArtSize,
			_pendingDeathAnimation );
		deathSprite.FlipHorizontal = _sprite?.FlipHorizontal ?? false;
		deathSprite.FlipVertical = _sprite?.FlipVertical ?? false;
		if ( TintColor.HasValue ) deathSprite.Color = TintColor.Value;
		deathSprite.OnAnimationEnd = _ => root.Destroy();

		if ( _sprite is not null )
		{
			_sprite.OnAnimationEnd = null;
			_sprite.Enabled = true;
		}
		_pendingDeathAnimation = null;
	}

	/// <summary>A death recovery re-placed this body elsewhere: the shockwave rings' once-per-wave hit
	/// records describe the fatal body left behind, so each ring decides afresh at the new position (see
	/// <see cref="BlockShockwave.ResetHitRecord"/>). A Swarm recovery passes the adopted clone.</summary>
	public void ResetShockwaveHitsAfterRecovery( Player inheritFrom = null )
	{
		foreach ( Block block in Stage.GetBlocks() )
			if ( block is BlockShockwave shockwave ) shockwave.ResetHitRecord( this, inheritFrom );
	}

	/// <summary>Keep the restored rewind state intact until the next fixed step when recovery happened
	/// during this player's own movement path.</summary>
	public void FinishRewindDeathRecovery()
	{
		_rewindDeathRecoveredThisTick = true;
	}

	// ----------------------------------------------------------------------------------------
	void Die()
	{
		if ( _swarmTrace && IsSwarmBody ) TraceSwarm( $"DYING {DescribeSwarmBodyForTrace()}" );
		foreach ( var ability in _abilityModules )
			if ( ability.TryPreventDeath( this ) )
			{
				TraceDeath( $"prevented by {ability.GetType().Name}" );
				return;
			}
		TraceDeath( $"DEAD {DeathTraceState}" );

		IsDead = true;
		_squashState = SquashState.None; // a hazard can kill a squashed Mimic; don't die flattened
		CancelMantleHang( applyCooldown: false );
		ResetControlSwitchBounce();
		if ( _chargeAimIndicator is not null ) _chargeAimIndicator.Enabled = false;
		ClearStuck( playUnstuck: false ); // sticky-specific cleanup: silences the release fx + clears the slam window
		// Release the attachment slot ENTIRELY (any kind, not just Stuck — ClearStuck only handles its
		// own). A dead player never ticks, so nothing downstream would ever detach a Riding/WallHug/
		// CeilingCling grip; leaving _attachEntity pointing at a live block is a stale-state trap for
		// any future player reuse or post-death state read.
		Detach();

		// Let ability modules retire tick-driven stage visuals (see PlayerAbility.OnDeath) — a dead
		// player never ticks, so anything they only refresh from PreTick/PostTick would freeze on screen.
		foreach ( var ability in _abilityModules )
			ability.OnDeath( this );

		// A Swarm copy gets a brief death hit-stop, but remains a locally reaped impostor rather than
		// handing off to game over or triggering the human player's heavier death feedback.
		if ( IsSwarmClone )
		{
			Stage.RequestHitStop( SwarmAbility.DEATH_HITSTOP_FRAMES );
			// Losing a body is a real loss even though the run goes on — deliberately under the human
			// player's death jolt below so the two never read as the same event.
			Haptics.Pulse( 0.7f, 0.18f, 0f, Haptics.TONE_HEAVY );
		}

		// An impostor dying is NOT the end of the run — it just gets reaped. Skip the game-over hand-off
		// and the whole-game death hit-stop / haptics (those belong to the human player's death).
		if ( IsImpostor )
			return;

		// Hit-stop on death to land the kill before the game-over hold begins.
		Stage.RequestHitStop( HIT_STOP_DEATH_FRAMES );
		// Strong, heavy jolt on death (every kill path routes through here).
		Haptics.Pulse( 1f, 0.28f, 0f, Haptics.TONE_HEAVY );
		Stage.PlayerHasDied( this );
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>The player was caught by a Summoner impostor — instant death (same feel as a fireball hit).</summary>
	public void KilledByImpostor( Vector2 impostorPos, [CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
	{
		TraceDeath( $"impostor at ({impostorPos.x:0.00},{impostorPos.y:0.00}) from {caller}:{callerLine} {DeathTraceState}" );
		if ( IsTwinDashing || IsSquashHidden ) return;
		Vector2 bloodDir = Utils.Normalized( Position - impostorPos );

		SpawnDeathParticles( bloodDir, 12, 20, () =>
		{
			Vector2 vel = bloodDir * Rng.CosmeticFloat( 50f, 100f );
			vel += new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 40f, 100f );
			return vel;
		}, 0.96f, 0.98f, 0.5f, 1.5f, 2, 7 );

		PlayCharacterDeathSfx();
		Explode();
		Die();
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Called on an impostor when its lifetime runs out — it bursts into a puff of blood and is
	/// then reaped by the stage. Only valid on impostors.</summary>
	public void ExpireImpostor()
	{
		if ( IsDead ) return;

		SpawnDeathParticles( Vector2.Zero, 10, 16,
			() => new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -0.3f, 1f ) ) * Rng.CosmeticFloat( 30f, 90f ),
			0.94f, 0.98f, 0.4f, 1.1f, 2, 6 );

		PlayCharacterDeathSfx();
		Explode();
		Die();
	}

	/// <summary>Remove a Swarm copy without death presentation when its body becomes authoritative.</summary>
	public void RetireSwarmCloneSilently()
	{
		if ( IsDead || !IsSwarmClone ) return;
		if ( _swarmTrace ) TraceSwarm( $"RETIRE {DescribeSwarmBodyForTrace()}" );
		IsDead = true;
		// Same stale-state discipline as Die(): a retired body never ticks again, so release the sticky
		// glue and the attachment slot here rather than leave _attachEntity pointing at a live block.
		ClearStuck( playUnstuck: false );
		Detach();
		if ( _sprite is not null ) _sprite.Enabled = false;
	}
}