Game/TestLevels.cs
using System.Collections.Generic;
namespace BlockParty;
/// <summary>
/// DEBUG-ONLY regression-repro levels: the ConCmds that jump into them plus any per-tick scripting
/// they need live HERE. The level DEFINITIONS now live in <c>Assets/levels/test/*.json</c> (loaded
/// like any level); the only shipping hooks are <see cref="OnStageEnter"/> / <see cref="OnStageTick"/>
/// (both no-ops on non-test levels) and <see cref="IsTestLevel"/>.
///
/// The levels live in the normal id registry (so replay/desync machinery treats them like any level)
/// but are excluded from the level-select map, so they're reached only via their ConCmds.
/// </summary>
public static class TestLevels
{
/// <summary>Id of the crush-repro test level. Load with the <c>crush_repro</c>
/// console command.</summary>
public const string CrushReproId = "crush-repro";
/// <summary>Id of the wrap-repro test level. Load with the <c>wrap_repro</c>
/// console command.</summary>
public const string WrapReproId = "wrap-repro";
/// <summary>Id of the tile-overhang-repro test level. Load with the
/// <c>tile_overhang_repro</c> console command.</summary>
public const string TileOverhangReproId = "tile-overhang-repro";
/// <summary>Id of the reverse-detach-repro test level. Load with the
/// <c>reverse_detach_repro</c> console command.</summary>
public const string ReverseDetachReproId = "reverse-detach-repro";
/// <summary>Id of the edge-hang-repro test level. Load with the <c>edge_hang_repro</c>
/// console command.</summary>
public const string EdgeHangReproId = "edge-hang-repro";
/// <summary>Id of the Shifter underside corner-sliver repro level. Load with the
/// <c>shifter_underside_repro</c> console command.</summary>
public const string ShifterUndersideReproId = "shifter-underside-repro";
/// <summary>Id of the Shifter tight-gap adoption repro level. Load with the
/// <c>shifter_gap_repro</c> console command.</summary>
public const string ShifterGapReproId = "shifter-gap-repro";
/// <summary>Id of the Shifter reverse-field ping-pong repro level. Load with the
/// <c>shifter_reverse_repro</c> console command.</summary>
public const string ShifterReverseReproId = "shifter-reverse-repro";
/// <summary>Id of the riding ceiling-glue misfire repro level. Load with the
/// <c>ride_cling_repro</c> console command.</summary>
public const string RideClingReproId = "ride-cling-repro";
/// <summary>Id of the block-vs-glass-pillar pinch repro level (the glass-pillars witch report).
/// Load with the <c>pillar_pinch_repro</c> console command.</summary>
public const string PillarPinchReproId = "pillar-pinch-repro";
/// <summary>Is this level one of the repro test levels? Used by GameStage to exempt them from
/// the DEBUG_STARTING_BLOCKS spawn override — their whole point is their exact authored layout,
/// so the repro ConCmds must work even with that override on.</summary>
public static bool IsTestLevel( string levelId )
=> levelId == CrushReproId || levelId == WrapReproId || levelId == TileOverhangReproId
|| levelId == ReverseDetachReproId || levelId == EdgeHangReproId
|| levelId == ShifterUndersideReproId || levelId == ShifterGapReproId
|| levelId == ShifterReverseReproId || levelId == RideClingReproId
|| levelId == PillarPinchReproId;
// --- crush repro ---------------------------------------------------------------------
/// <summary>Console command: jump straight into the phantom-crush regression level (the
/// crush-repro level). Once it loads, don't
/// touch anything: the block slams the left-wall lip within ~2s. The wedged player surviving
/// every slam = healthy; an instant crush death with nothing moving = the stale-stopped-block
/// velocity bug is back.</summary>
[ConCmd( "crush_repro" )]
public static void LoadCrushReproCmd()
{
if ( !Game.IsEditor ) return;
var gm = GameManager.Instance;
if ( gm is null )
{
Log.Warning( "[BlockParty] crush_repro: no GameManager in the scene." );
return;
}
gm.StartLevel( CrushReproId );
Log.Info( "[BlockParty] Loading crush-repro level. Don't move: surviving the slam = healthy; phantom crush death within ~2s = stale-velocity regression." );
}
// --- wrap-escape repro ---------------------------------------------------------------
/// <summary>Console command: jump straight into the wrap-escape regression level (the wrap-repro
/// level) as WRAPPER, the wrap character — regardless of which character
/// is selected on the title screen. Once it loads, don't touch anything: the player free-falls
/// toward the floor, and spikes start growing there just before they cross it. The player
/// wrapping to the top of the arena = healthy; the player sailing out through the floor and
/// dying offscreen ~0.4s later = the inconsistent-spike-gate escape bug (wrap gate counts
/// growing/retracting spikes as spiked, kill check doesn't). The verdict is logged either way.</summary>
[ConCmd( "wrap_repro" )]
public static void LoadWrapReproCmd()
{
if ( !Game.IsEditor ) return;
var gm = GameManager.Instance;
if ( gm is null )
{
Log.Warning( "[BlockParty] wrap_repro: no GameManager in the scene." );
return;
}
gm.StartLevel( WrapReproId, Characters.Wrapper.Id );
Log.Info( "[BlockParty] Loading wrap-repro level as WRAPPER. Don't move: wrapping to the top = healthy; escaping through the floor into the void = wrap/kill spike-gate mismatch. Watch for the verdict log." );
}
// --- spike-tile overhang repro ---------------------------------------------------------
// The short-face bar: free-standing mid-arena, 40px wide but only 3px tall, so its LEFT and
// RIGHT faces are 3px — shorter than one 5px tooth tile. Shared between Obstacles and
// SpikedObstacleSides (matched by value), and read back by the OnStageEnter verdict check.
private static readonly RectF TileOverhangBar = new( 100f, 100f, 140f, 103f );
/// <summary>Console command: jump straight into the spike-tile overhang regression level (the
/// tile-overhang-repro level). The verdict is measured and logged immediately on
/// load — no interaction needed. Tiles centred on their sub-tile faces (overhang split evenly,
/// ≤ 1px per corner here) = healthy; the whole 2px excess poking past one corner (visible as
/// spikes jutting below the bar's bottom corners) = the one-sided flush-clamp bug.</summary>
[ConCmd( "tile_overhang_repro" )]
public static void LoadTileOverhangReproCmd()
{
if ( !Game.IsEditor ) return;
var gm = GameManager.Instance;
if ( gm is null )
{
Log.Warning( "[BlockParty] tile_overhang_repro: no GameManager in the scene." );
return;
}
gm.StartLevel( TileOverhangReproId );
Log.Info( "[BlockParty] Loading tile-overhang-repro level. Verdict logs immediately; also look at the bar's bottom corners for spikes poking past them." );
}
// --- reverse-gravity underside-ride detach repro ----------------------------------------
/// <summary>Console command: jump straight into the underside-ride detach regression level (the
/// reverse-detach-repro level) as the Original character — regardless of the
/// title-screen pick. Once it loads, don't touch anything: the reverse field lifts the player onto
/// the boxed block's underside, then the script kills the block to cut the field mid-hang. The
/// player detaching and falling back to the pedestal = healthy; the player snapping THROUGH the
/// block onto its top in one tick = the missing un-reverse detach in HandleMovingPlatforms. The
/// verdict is logged either way.</summary>
[ConCmd( "reverse_detach_repro" )]
public static void LoadReverseDetachReproCmd()
{
if ( !Game.IsEditor ) return;
var gm = GameManager.Instance;
if ( gm is null )
{
Log.Warning( "[BlockParty] reverse_detach_repro: no GameManager in the scene." );
return;
}
gm.StartLevel( ReverseDetachReproId, Characters.OriginalId );
Log.Info( "[BlockParty] Loading reverse-detach-repro level as Original. Don't move: the field lifts you under the boxed block, then cuts out mid-hang. Falling back down = healthy; teleporting through the block onto its top = underside-ride detach bug. Watch for the verdict log." );
}
// --- moving-platform edge-hang repro ----------------------------------------------------
// Seeded travel speeds: the floor block starts 25 px/s faster than the wall block (mirroring the
// replay this came from: ~97 vs ~120) and crosses the 120 px/s line — one lean-margin per tick,
// where the old stale-position ledge release false-fired — within ~0.2s of load. Both accelerate
// at the shared 50 px/s² from there, so the differential holds until the floor block slams the
// right arena wall (~165 px/s, ~1.1s in).
private const float EDGE_HANG_WALL_BLOCK_SPEED = 85f;
private const float EDGE_HANG_FLOOR_BLOCK_SPEED = 100f;
/// <summary>Console command: jump straight into the moving-platform edge-hang repro level with the
/// CURRENTLY SELECTED character (it exercises shared riding code, so test it with several). Layout:
/// the player starts in the corner of a wall block (standing against its right face, near its
/// bottom) and a flush floor block whose left edge sits at the player's feet — both heading right,
/// the floor block seeded faster so it pulls ahead, exactly the replay this reproduces. SHIFTER:
/// hold Down (walk toward the floor block); the corner squeeze flips gravity to Down and parks you
/// on the floor block's trailing edge as it outruns you — riding along near the trailing edge =
/// healthy; rocketing rightward across the block with no input = the stale-position ledge release
/// is back. OTHER characters: you spawn riding the floor block; hold Left (against its travel) to
/// walk to its trailing edge — one clean release when you genuinely cross the lip = healthy;
/// releasing early with a rightward velocity kick while visibly still on the block = regression.
/// Doing nothing rides the block into the right-wall slam (inertia fling right = intended).</summary>
[ConCmd( "edge_hang_repro" )]
public static void LoadEdgeHangReproCmd()
{
if ( !Game.IsEditor ) return;
var gm = GameManager.Instance;
if ( gm is null )
{
Log.Warning( "[BlockParty] edge_hang_repro: no GameManager in the scene." );
return;
}
gm.StartLevel( EdgeHangReproId );
Log.Info( "[BlockParty] Loading edge-hang-repro level with the selected character. SHIFTER: hold Down in the corner and watch the trailing-edge ride. Others: hold Left while riding the fast floor block to walk to its trailing edge. Clean single release at the lip = healthy; a no-input rightward rocket = stale-position ledge release regression." );
}
// --- shifter repros ----------------------------------------------------------------------
/// <summary>Console command: jump into the Shifter underside corner-sliver repro as GYRO (the
/// Shifter), regardless of the title-screen pick. Jump to stick to the left slab's underside,
/// hold Right to walk off its lip, and LET GO of Right once you're off — you up-fall into the
/// wedged block's bottom-left corner with only a ~1px sliver of overlap, centre past the lean
/// margin. Deflecting past the corner and rising to the top wall = healthy; freezing in mid-air
/// at the corner (attach/release churn in shifter_trace) = the missing up-gravity catch refusal.
/// The verdict is logged either way; overshooting onto the block underside logs a retry hint
/// (press Down to jump back to the pedestal).</summary>
[ConCmd( "shifter_underside_repro" )]
public static void LoadShifterUndersideReproCmd()
{
if ( !Game.IsEditor ) return;
var gm = GameManager.Instance;
if ( gm is null )
{
Log.Warning( "[BlockParty] shifter_underside_repro: no GameManager in the scene." );
return;
}
gm.StartLevel( ShifterUndersideReproId, Characters.Shifter.Id );
Log.Info( "[BlockParty] Loading shifter-underside-repro as GYRO. Jump to grab the slab's underside, hold Right off its lip, then let go. Rising to the top wall = healthy; freezing at the block's corner = underside catch bug. shifter_trace + crush_trace give the tick detail. Watch for the verdict log." );
}
/// <summary>Console command: jump into the Shifter tight-gap adoption repro as GYRO. The player
/// spawns standing in a 9px slot between two obstacle walls — 1px narrower than the sideways
/// 10px hitbox. Both walls are inside the 1px contact probe, so the side adoption fires by
/// itself on the first tick: don't touch anything. Surviving (the wall touch simply doesn't
/// take) = healthy; an instant crush death on load = the rotate-then-resolve-or-die adoption.
/// The verdict is logged either way.</summary>
[ConCmd( "shifter_gap_repro" )]
public static void LoadShifterGapReproCmd()
{
if ( !Game.IsEditor ) return;
var gm = GameManager.Instance;
if ( gm is null )
{
Log.Warning( "[BlockParty] shifter_gap_repro: no GameManager in the scene." );
return;
}
gm.StartLevel( ShifterGapReproId, Characters.Shifter.Id );
Log.Info( "[BlockParty] Loading shifter-gap-repro as GYRO. Don't touch anything: the 9px slot adopts a side wall on tick one. Still standing after a few seconds = healthy; instant squish on load = tight-gap adoption death. Watch for the verdict log." );
}
/// <summary>Console command: jump into the Shifter reverse-field repro as GYRO. Same boxed
/// Reverse block layout as reverse_detach_repro, but the player spawns AIRBORNE under the box's
/// left bottom-lip obstacle. Don't touch anything: the field (airborne-only for the Shifter)
/// lifts them into the lip's underside, the touch adopts it as the real floor, and the script
/// then kills the Reverse block to cut the field. Still hanging afterwards = healthy; falling on
/// the cut = the rest was only field-held; floor↔ceiling bouncing before any settle = the old
/// in-field adoption ping-pong. The verdict is logged either way.</summary>
[ConCmd( "shifter_reverse_repro" )]
public static void LoadShifterReverseReproCmd()
{
if ( !Game.IsEditor ) return;
var gm = GameManager.Instance;
if ( gm is null )
{
Log.Warning( "[BlockParty] shifter_reverse_repro: no GameManager in the scene." );
return;
}
gm.StartLevel( ShifterReverseReproId, Characters.Shifter.Id );
Log.Info( "[BlockParty] Loading shifter-reverse-repro as GYRO. Don't move: the field lifts you into the lip's underside, you adopt it, then the field is cut. Still hanging = healthy; dropping on the cut (or endless bouncing) = regression. Watch for the verdict log." );
}
// --- riding ceiling-glue misfire repro ---------------------------------------------------
/// <summary>Console command: jump into the riding ceiling-glue misfire repro as JACK (the Spring),
/// regardless of the title-screen pick. HOLD LEFT + UP from the moment the level loads (while
/// still falling — a grounded Up press would jump instead). You land on the block and walk left
/// while it accelerates rightward, carrying your head under the static overhang's left edge at
/// ~0.9s. That first brush tick samples CollidingUp BEFORE your leftward walk is applied, so the
/// fresh-grab probe misses at the walked position: the ceiling glue then runs against the RIDDEN
/// block. One-tick teleport to the block's underside (a ~50px down + rideDX sideways snap) = BUG
/// REPRODUCED; briefly clinging/surfing the overhang's edge at head height with no snap = healthy.
/// The verdict is logged either way.</summary>
[ConCmd( "ride_cling_repro" )]
public static void LoadRideClingReproCmd()
{
if ( !Game.IsEditor ) return;
var gm = GameManager.Instance;
if ( gm is null )
{
Log.Warning( "[BlockParty] ride_cling_repro: no GameManager in the scene." );
return;
}
gm.StartLevel( RideClingReproId, Characters.Spring.Id );
Log.Info( "[BlockParty] Loading ride-cling-repro as JACK. Hold LEFT + UP from load (press while falling!). The block carries you under the overhang's edge: a one-frame snap to the block's underside = ceiling glue fired while Riding; hanging/surfing at head height = healthy. Watch for the verdict log." );
}
// --- block-vs-glass-pillar pinch repro ---------------------------------------------------
/// <summary>Console command: jump into the glass-pillar pinch repro (the glass-pillars witch
/// report) as the WITCH. Geometry is glass-pillars' obstacle #3 verbatim (glass, x 158–173,
/// y 32–238) plus one Sad block spawned in the bottom-right corner and driven LEFT at a clamped
/// crawl. Hold RIGHT from load (one continuous hold — a double-tap would blink): you wall-hug
/// the block's left face while the script pins the reported pose (bottom edge 2px below the
/// block's top, slight upward velocity), lets the face grind your left edge a sub-residual-EPS
/// hair into the glass, then flips the block rightward — the Hunter-brake/block-bounce reversal
/// from the video. On a FIXED build the pinch resolves first: the embed is cleaned to flush
/// every tick and the corner deflect pops the player 2px onto the block's top (NO TELEPORT —
/// the flip is never even reached). On an unfixed build the embed survives, the flip fires,
/// and the sweep takes the pillar's along-move push-out as a wall hit, wall-hug-attaches to it
/// face-Right, and glue-snaps to its LEFT side = BUG REPRODUCED. The verdict is logged either
/// way — run <c>crush_trace</c> first for the per-tick resolve detail.</summary>
[ConCmd( "pillar_pinch_repro" )]
public static void LoadPillarPinchReproCmd()
{
if ( !Game.IsEditor ) return;
var gm = GameManager.Instance;
if ( gm is null )
{
Log.Warning( "[BlockParty] pillar_pinch_repro: no GameManager in the scene." );
return;
}
gm.StartLevel( PillarPinchReproId, Characters.Blinker.Id );
Log.Info( "[BlockParty] Loading pillar-pinch-repro as WITCH. Press and HOLD RIGHT from load (no tapping — that blinks). The script grinds you a hair into the glass pillar, then flips the block away. Staying right of the pillar = healthy; snapping through to its left = the reported bug. Watch for the verdict log." );
}
// Measure the built tile sprites of one short face against its authored segment and log any
// overhang past either corner. Returns true if every tile respects the centring contract,
// false if one overhangs, or null if the face wasn't found at all (repro broken, no verdict).
private static bool? CheckTileOverhang( GameStage stage, Obstacle ob, Direction normal )
{
float faceX = normal == Direction.Left ? TileOverhangBar.Left : TileOverhangBar.Right;
var face = stage.FindObstacleFace( ob, normal, new Vector2( faceX, (TileOverhangBar.Bottom + TileOverhangBar.Top) / 2f ) );
if ( face is null )
{
Log.Warning( $"[BlockParty] tile_overhang_repro: no {normal} face found on the bar — level geometry changed?" );
return null;
}
// Vertical faces tile along Y; each tile quad is centred on its sprite's world position,
// with an along-extent of the Size box's Y (an aspect-fit box the 4x5 side-tile art fills
// vertically). A face shorter than the tile CANNOT avoid overhanging (the tile stays
// full-size by design), so the healthy criterion is the fix's contract: the tile is
// centred, i.e. neither end overhangs by more than the unavoidable (tile - face) / 2.
// The bug dumped the whole excess past the START corner.
bool ok = true;
float faceLen = face.Segment.YMax - face.Segment.YMin;
foreach ( var s in face.TeethSprites )
{
float c = s.GameObject.WorldPosition.y;
float half = s.Size.y / 2f;
float allowed = MathF.Max( 0f, (half * 2f - faceLen) / 2f );
float startOverhang = face.Segment.YMin - (c - half);
float endOverhang = (c + half) - face.Segment.YMax;
if ( startOverhang > allowed + 0.01f || endOverhang > allowed + 0.01f )
{
ok = false;
Log.Info( $"[BlockParty] tile_overhang_repro: {normal} face tile at y={c} overhangs its segment (y {face.Segment.YMin}..{face.Segment.YMax}) by {startOverhang:0.##}px past the start corner, {endOverhang:0.##}px past the end (allowed: {allowed:0.##}px per end)." );
}
}
return ok;
}
// One-shot latches for the wrap repro's scripted spike graft and console verdict, reset by
// OnStageEnter. Static (not per-stage) is fine here: only one GameStage exists at a time.
private static bool _wrapSpikesAdded;
private static bool _wrapVerdictLogged;
// Scripted-driver state for the reverse-detach repro, reset by OnStageEnter (static is fine:
// one GameStage at a time). The hang counter must SETTLE (consecutive ticks at hanging height)
// before the kill, so the one-off bottom-face press bounce on the way up can't cut the field
// while the player is still un-attached mid-air.
private static int _reverseHangTicks;
private static bool _reverseFieldCut;
private static bool _reverseVerdictLogged;
private static int _reverseTicksSinceCut;
// Hanging under the boxed block (underside y=100) puts the player centre at y≈95; above this
// height they can only be pressed against / attached to the underside.
private const float REVERSE_HANG_MIN_Y = 92f;
// Consecutive ticks at hanging height before the scripted kill (~0.5s: attach is stable by then).
private const int REVERSE_HANG_SETTLE_TICKS = 30;
// Verdict bands after the cut: above the block's top region = teleported through it (the glue
// snap puts the centre at ≈145); back below the hang = detached and falling, as a fixed build
// should. The gap between the bands can only be crossed moving toward one of them.
private const float REVERSE_BUG_MIN_Y = 120f;
private const float REVERSE_HEALTHY_MAX_Y = 85f;
// Ticks after the cut before giving up on a verdict (~4s; the real outcome lands within ~1 tick
// for the bug, ~15 for the healthy fall).
private const int REVERSE_VERDICT_TIMEOUT_TICKS = 240;
/// <summary>The repro's Reverse block, or null if the level geometry changed out from under it.</summary>
private static BlockReverse FindReverseBlock( GameStage stage )
{
foreach ( var b in stage.GetBlocks() )
if ( b is BlockReverse br )
return br;
return null;
}
// Scripted-verdict state for the three Shifter repros, reset by OnStageEnter (static is fine:
// one GameStage at a time).
// Underside: armed once the player has walked off the slab's lip toward the wedged block; then
// either they rise free past the block (healthy), park motionless at its corner sliver (bug), or
// land solidly on the underside (overshoot — retry hint).
private static bool _shifterUndersideArmed;
private static bool _shifterUndersideVerdictLogged;
private static int _shifterUndersideStillTicks;
private static float _shifterUndersideLastY;
private static int _shifterUndersideTicksSinceArm;
// Gap: the slot adopts a side wall on tick one by itself; death = bug, surviving = healthy.
private static bool _shifterGapVerdictLogged;
private static int _shifterGapTicks;
// Reverse: the airborne spawn must rise (field affects airborne players), ADOPT the box's lip
// underside for real, and then survive the scripted field cut still attached. Oscillation before
// the cut = the in-field adoption regressed (old ping-pong); falling after the cut = field exit
// detached them (old field-held-only rest).
private static bool _shifterReverseVerdictLogged;
private static int _shifterReverseTicks;
private static int _shifterReverseCrossings;
private static bool _shifterReverseWasAbove;
private static int _shifterReverseSettleTicks;
private static bool _shifterReverseFieldCut;
private static float _shifterReverseLastY;
private static int _shifterReverseTicksSinceCut;
// Underside repro geometry (must match shifter-underside-repro.json): the wedged block spans
// (106,92)-(146,132), so its exposed bottom-left corner sliver is at (106, 92). The player walks
// off the slab (right edge x=88) and up-falls into it.
private const float UNDERSIDE_BLOCK_LEFT = 106f;
private const float UNDERSIDE_BLOCK_BOTTOM = 92f;
private const int UNDERSIDE_STILL_VERDICT_TICKS = 45;
private const int UNDERSIDE_TIMEOUT_TICKS = 600;
private const int GAP_VERDICT_TICKS = 300;
// Reverse repro bands: the player spawns airborne at y=85 under the box's left bottom-lip
// obstacle (underside y=92); the field lifts them 2px and the adoption hangs them at centre
// y≈87. Crossing y=50 repeatedly (arena floor ↔ lip) = ping-pong; y<70 after the field cut =
// they fell, i.e. the attachment was only field-held.
private const float REVERSE_SETTLE_MIN_Y = 84f;
private const int REVERSE_SETTLE_TICKS = 30;
private const float REVERSE_PINGPONG_MIDLINE_Y = 50f;
private const int REVERSE_PINGPONG_BUG_CROSSINGS = 4;
private const float REVERSE_DETACH_BUG_MAX_Y = 70f;
private const int REVERSE_HOLD_HEALTHY_TICKS = 120;
private const int REVERSE_PINGPONG_TIMEOUT_TICKS = 600;
// Scripted-verdict state for the ride-cling repro, reset by OnStageEnter (static is fine: one
// GameStage at a time). Armed once the player is riding the block at head height near the
// overhang while actually walking left (the leftward walk IS the stale-probe offset the bug
// needs — without it the first-contact grab succeeds in every build and there's no verdict).
private static bool _rideClingVerdictLogged;
private static bool _rideClingArmed;
private static int _rideClingTicks;
private static int _rideClingTicksSinceArm;
private static float _rideClingLastX;
private static float _rideClingLastY;
// Ride-cling repro geometry/tuning (must match ride-cling-repro.json): the block (top y=80)
// carries the player (standing centre y=85) under the overhang (bottom y=90, left edge x=80).
// The buggy glue snaps the centre to the block's underside (y=35) in ONE tick — a 50px drop; no
// legitimate motion in this layout moves 30px in a tick (max freefall here is ~7px/tick).
private const float RIDE_CLING_SNAP_DY = 30f;
// Armed: at standing/hanging height (y≈85), near the overhang's left edge, walking left.
private const float RIDE_CLING_ARM_MIN_Y = 83f;
private const float RIDE_CLING_ARM_MAX_Y = 87f;
private const float RIDE_CLING_ARM_MIN_X = 72f;
private const float RIDE_CLING_ARM_MAX_VELX = -15f;
// The brush happens ~11 ticks after arming; 2s of no snap after arming = the glue never fired.
private const int RIDE_CLING_HEALTHY_TICKS = 120;
// Ticks before giving up when the arm condition never comes (inputs not held).
private const int RIDE_CLING_TIMEOUT_TICKS = 600;
// Scripted-driver state for the pillar-pinch repro, reset by OnStageEnter (static is fine: one
// GameStage at a time). Pose phase: every tick until the block face reaches the arm line, the
// script re-pins the reported pose (bottom edge 2px below the block's top, slight rise) and
// re-clamps the block's speed (its own acceleration would blow past the half-pixel window the
// bug needs) while the human's held RIGHT does the hugging; X is never touched so the real
// attach/press machinery engages. Armed: watch the grind until the player's left edge is a
// SUB-RESIDUAL-EPS hair (0.15..0.5px) inside the pillar, then FLIP the block to the right —
// the Hunter brake / block-bounce reversal from the video. The receding face opens a gap, the
// held-right sweep gets a Right push-out from the pillar on the LEFT, and an unfixed build
// wall-hug-attaches face-Right to the pillar and glue-snaps through it next tick.
private static bool _pillarPinchArmed;
private static bool _pillarPinchFlipped;
private static bool _pillarPinchVerdictLogged;
private static int _pillarPinchTicksSinceFlip;
private static float _pillarPinchLastX;
// Pillar-pinch repro geometry (must match pillar-pinch-repro.json): the glass pillar is
// glass-pillars' obstacle #3 verbatim — x 158..173, y 32..238 — and the 40x40 Sad block rests on
// the floor (top y=42), driven LEFT from the bottom-right corner.
private const float PILLAR_PINCH_PILLAR_RIGHT = 173f;
// Reported pose, held until arm: player bottom = block top - 2 (centre = top + 3), rising gently.
private const float PILLAR_PINCH_POSE_RISE_VELY = 20f;
// Arm when the block's left face reaches here: the hugged player's left edge sits at ~173.5,
// half a pixel off the pillar — the exact "left side meets the pillar's right side" moment.
private const float PILLAR_PINCH_ARM_BLOCK_LEFT = 181.5f;
// Clamped block speed: 18 px/s = ~0.31px/tick, so the per-tick pillar embedding lands inside
// [FLIP_MIN_PEN, RESIDUAL_PEN_EPS) with room to spare (worst first crossing ≈ 0.46px). Deeper
// than the EPS at a tick start and the residual net crushes first — the (already-verified)
// designed outcome, not the bug.
private const float PILLAR_PINCH_BLOCK_SPEED = 18f;
private const float PILLAR_PINCH_FLIP_MIN_PEN = 0.15f;
// A hugged player's left edge can legally end up at most a whisker inside the pillar; the centre
// crossing the pillar's midline (165.5) is reachable only by going THROUGH it. One-tick sideways
// jumps past this also flag immediately (the buggy glue snap is a ~23px single-tick move).
private const float PILLAR_PINCH_BUG_MAX_X = 165f;
private const float PILLAR_PINCH_TELEPORT_DX = 8f;
// Healthy: two post-flip seconds still on the pillar's right side. Anything unresolved after 10s = stuck.
private const int PILLAR_PINCH_HEALTHY_TICKS = 120;
private const int PILLAR_PINCH_TIMEOUT_TICKS = 600;
// Graft the floor spikes when the falling player is this close above the floor: from here the
// fall to the wall (~57px, arriving ~0.18s later at ~300px/s) sits comfortably inside the 0.44s
// grow-in, so the face is still "switching" for the whole crossing.
private const float WRAP_SPIKE_TRIGGER_Y = 60f;
private const float WRAP_SPIKE_LIVE_TIME = 2f;
/// <summary>Test-level scripted setup, called at the end of <c>GameStage.OnEnter</c> (after
/// blocks and player are spawned). No-op on non-test levels.</summary>
public static void OnStageEnter( GameStage stage )
{
switch ( stage.Level?.Id )
{
case CrushReproId:
// The block must open by slamming LEFT into the wall lip (see BuildCrushRepro) —
// a random opening direction would delay the repro by several bounces.
stage.GetBlocks()[0].DebugSetMoveDirection( Direction.Left );
break;
case WrapReproId:
_wrapSpikesAdded = false;
_wrapVerdictLogged = false;
break;
case ReverseDetachReproId:
{
_reverseHangTicks = 0;
_reverseFieldCut = false;
_reverseVerdictLogged = false;
_reverseTicksSinceCut = 0;
// The field is off at phase 0 and there's no way to press the boxed block's sides
// without input, so spawn it straight at phase 1 (the mimic-swap helper — no phase-up
// transition or scoring). Its 120px field then covers the pedestal + underside.
var reverse = FindReverseBlock( stage );
if ( reverse is null )
Log.Warning( "[BlockParty] reverse_detach_repro: no Reverse block spawned — level def changed? No repro." );
else
reverse.SetInitialPhase( 1 );
break;
}
case EdgeHangReproId:
{
// Both blocks head RIGHT with a seeded speed differential (see the constants above) —
// random opening directions or a standing start would take several bounces to
// reassemble the corner + faster-floor-block situation the repro is about. The wall
// block is the HIGH one (y=217 vs the floor block's 177); picked by height rather than
// spawn index so a reordered level def can't silently swap the roles.
foreach ( var block in stage.GetBlocks() )
{
bool isWallBlock = block.Y > 200f;
block.DebugSetMoveDirection( Direction.Right );
block.DebugSetMoveSpeed( isWallBlock ? EDGE_HANG_WALL_BLOCK_SPEED : EDGE_HANG_FLOOR_BLOCK_SPEED );
}
break;
}
case ShifterUndersideReproId:
{
_shifterUndersideArmed = false;
_shifterUndersideVerdictLogged = false;
_shifterUndersideStillTicks = 0;
_shifterUndersideLastY = 0f;
_shifterUndersideTicksSinceArm = 0;
// Wedge the block vertically between the flush top/bottom obstacles (it spawns with
// zero clearance): a vertical travel axis means it never moves horizontally either,
// so its exposed bottom-left corner stays exactly where the repro needs it.
stage.GetBlocks()[0].DebugSetMoveDirection( Direction.Up );
break;
}
case ShifterGapReproId:
_shifterGapVerdictLogged = false;
_shifterGapTicks = 0;
break;
case ShifterReverseReproId:
{
_shifterReverseVerdictLogged = false;
_shifterReverseTicks = 0;
_shifterReverseCrossings = 0;
_shifterReverseWasAbove = false;
_shifterReverseSettleTicks = 0;
_shifterReverseFieldCut = false;
_shifterReverseLastY = 0f;
_shifterReverseTicksSinceCut = 0;
// Same as reverse_detach_repro: the boxed block's field is off at phase 0 and nothing
// can press its sides, so spawn it straight at phase 1.
var reverse = FindReverseBlock( stage );
if ( reverse is null )
Log.Warning( "[BlockParty] shifter_reverse_repro: no Reverse block spawned — level def changed? No repro." );
else
reverse.SetInitialPhase( 1 );
break;
}
case RideClingReproId:
{
_rideClingVerdictLogged = false;
_rideClingArmed = false;
_rideClingTicks = 0;
_rideClingTicksSinceArm = 0;
_rideClingLastX = 0f;
_rideClingLastY = 0f;
// The block must carry the player rightward INTO the overhang's left edge. No speed
// seed: the timing math relies on the natural accelerate-from-rest ramp (50 px/s²),
// which keeps the closing speed on the overhang below one tick of walk speed at the
// brush (~0.87s in) — the guarantee that the first contact tick is a stale one.
stage.GetBlocks()[0].DebugSetMoveDirection( Direction.Right );
break;
}
case PillarPinchReproId:
{
_pillarPinchArmed = false;
_pillarPinchFlipped = false;
_pillarPinchVerdictLogged = false;
_pillarPinchTicksSinceFlip = 0;
_pillarPinchLastX = 0f;
// Drive the block LEFT; the tick script re-clamps the speed every tick until the
// scripted reversal (see PILLAR_PINCH_BLOCK_SPEED for why it must stay this slow).
stage.GetBlocks()[0].DebugSetMoveDirection( Direction.Left );
stage.GetBlocks()[0].DebugSetMoveSpeed( PILLAR_PINCH_BLOCK_SPEED );
break;
}
case TileOverhangReproId:
{
// Faces are all built by now (arena + obstacles precede this hook); measure the
// bar's two sub-tile faces and log the verdict immediately.
var ob = stage.GetObstacles()[0];
bool? left = CheckTileOverhang( stage, ob, Direction.Left );
bool? right = CheckTileOverhang( stage, ob, Direction.Right );
if ( left is null || right is null )
Log.Warning( "[BlockParty] tile_overhang_repro: INCONCLUSIVE — a short face is missing (see warning above), so the overhang contract wasn't measured. Fix the repro before trusting any verdict." );
else
Log.Info( left.Value && right.Value
? "[BlockParty] tile_overhang_repro: HEALTHY — the sub-tile faces' tiles are centred; no corner overhangs more than the unavoidable (tile - face) / 2."
: "[BlockParty] tile_overhang_repro: BUG REPRODUCED — a tooth tile dumps its whole excess past one face corner (one-sided flush-clamp in BuildTiledFace); see the bar's bottom corners." );
break;
}
}
}
/// <summary>Test-level scripted driver, called once per fixed step from <c>GameStage.Tick</c>
/// (after entities have ticked). No-op on non-test levels.</summary>
public static void OnStageTick( GameStage stage )
{
switch ( stage.Level?.Id )
{
case WrapReproId: TickWrapRepro( stage ); break;
case ReverseDetachReproId: TickReverseDetachRepro( stage ); break;
case ShifterUndersideReproId: TickShifterUndersideRepro( stage ); break;
case ShifterGapReproId: TickShifterGapRepro( stage ); break;
case ShifterReverseReproId: TickShifterReverseRepro( stage ); break;
case RideClingReproId: TickRideClingRepro( stage ); break;
case PillarPinchReproId: TickPillarPinchRepro( stage ); break;
}
}
/// <summary>Ride-cling repro's per-tick verdict: any one-tick drop of 30px+ = BUG REPRODUCED
/// (the ceiling glue ran on the Riding attachment and snapped the player to the ridden block's
/// underside — nothing legitimate in this layout teleports). Armed once the player is riding at
/// head height near the overhang while walking left; two snap-free seconds after arming =
/// HEALTHY. Never arming (inputs not held) or dying logs INCONCLUSIVE.</summary>
private static void TickRideClingRepro( GameStage stage )
{
var player = stage.Player;
if ( player is null || player.IsDead )
{
if ( !_rideClingVerdictLogged && player is not null )
{
_rideClingVerdictLogged = true;
Log.Warning( "[BlockParty] ride_cling_repro: INCONCLUSIVE — the player died, which this layout should never allow. Fix the repro before trusting any verdict." );
}
return;
}
if ( _rideClingVerdictLogged ) return;
_rideClingTicks++;
// Teleport watch, live from tick 2: the buggy glue moves the centre from y≈85 to y≈35 (and
// yanks X by the block's displacement since ride-attach) inside a single tick.
float dY = player.Y - _rideClingLastY;
float dX = player.X - _rideClingLastX;
bool teleported = _rideClingTicks > 1 && dY <= -RIDE_CLING_SNAP_DY;
_rideClingLastX = player.X;
_rideClingLastY = player.Y;
if ( teleported )
{
_rideClingVerdictLogged = true;
Log.Info( $"[BlockParty] ride_cling_repro: BUG REPRODUCED — one-tick teleport to the ridden block's underside (dY={dY:0.0}, dX={dX:0.0}, now y={player.Y:0.0}): the ceiling-cling glue ran while _attach == Riding, treating the platform as the clung ceiling (stale CollidingUp + missed fresh-grab probe)." );
return;
}
if ( !_rideClingArmed )
{
if ( player.Y > RIDE_CLING_ARM_MIN_Y && player.Y < RIDE_CLING_ARM_MAX_Y
&& player.X > RIDE_CLING_ARM_MIN_X && player.VelX < RIDE_CLING_ARM_MAX_VELX )
{
_rideClingArmed = true;
Log.Info( "[BlockParty] ride_cling_repro: riding toward the overhang while walking left — the edge brush comes within a quarter second. Keep holding LEFT + UP..." );
}
else if ( _rideClingTicks > RIDE_CLING_TIMEOUT_TICKS )
{
_rideClingVerdictLogged = true;
Log.Warning( "[BlockParty] ride_cling_repro: INCONCLUSIVE — never saw the ride-and-walk-left setup (were LEFT + UP held from load, with UP pressed while still falling?). Reload to retry." );
}
return;
}
_rideClingTicksSinceArm++;
if ( _rideClingTicksSinceArm >= RIDE_CLING_HEALTHY_TICKS )
{
_rideClingVerdictLogged = true;
Log.Info( $"[BlockParty] ride_cling_repro: HEALTHY — the edge brush passed with no teleport (y={player.Y:0.0}); the glue stayed off the Riding attachment. (Valid only if LEFT + UP stayed held through the brush.)" );
}
}
/// <summary>Shifter underside repro's per-tick verdict: once the player has walked off the slab's
/// lip (armed), rising well past the wedged block = HEALTHY (the corner-sliver catch was refused
/// and deflected); parking motionless at the block's bottom-left corner with the centre still past
/// the lean margin = BUG REPRODUCED; parking on the underside with the centre over the block =
/// overshoot, retry. Timeout logs INCONCLUSIVE with the parked position.</summary>
private static void TickShifterUndersideRepro( GameStage stage )
{
var player = stage.Player;
if ( player is null || player.IsDead )
{
if ( !_shifterUndersideVerdictLogged && player is not null )
{
_shifterUndersideVerdictLogged = true;
Log.Warning( "[BlockParty] shifter_underside_repro: INCONCLUSIVE — the player died, which this layout should never allow. Fix the repro before trusting any verdict." );
}
return;
}
if ( _shifterUndersideVerdictLogged ) return;
// Armed once clearly past the slab's right edge (x=88) while at underside height — i.e. they
// walked off the lip and are up-falling toward the block's corner.
if ( !_shifterUndersideArmed )
{
if ( player.X > 96f && player.Y > 80f ) _shifterUndersideArmed = true;
else return;
}
_shifterUndersideTicksSinceArm++;
bool still = Math.Abs( player.Y - _shifterUndersideLastY ) < 0.05f;
_shifterUndersideLastY = player.Y;
_shifterUndersideStillTicks = still ? _shifterUndersideStillTicks + 1 : 0;
if ( player.Y > 150f )
{
_shifterUndersideVerdictLogged = true;
Log.Info( $"[BlockParty] shifter_underside_repro: HEALTHY — the corner-sliver catch was refused and deflected; the player rose free past the block (y={player.Y:0.0})." );
}
else if ( _shifterUndersideStillTicks >= UNDERSIDE_STILL_VERDICT_TICKS
&& player.Y > 82f && player.Y < UNDERSIDE_BLOCK_BOTTOM )
{
_shifterUndersideVerdictLogged = true;
// The lean margin is 2px: parked with the centre still short of Left-2 means the catch
// held a sliver it should have refused; centre at/over the margin is a legitimate rest.
if ( player.X < UNDERSIDE_BLOCK_LEFT - 2f )
Log.Info( $"[BlockParty] shifter_underside_repro: BUG REPRODUCED — pinned at the block's bottom-left corner sliver (x={player.X:0.0}, centre {UNDERSIDE_BLOCK_LEFT - player.X:0.0}px short of its edge): the up-gravity underside catch refusal is missing. shifter_trace shows the attach/release churn." );
else
Log.Info( $"[BlockParty] shifter_underside_repro: OVERSHOOT (no verdict) — you landed with your centre over the block (x={player.X:0.0}), a legitimate corner rest in both builds. Press Down to jump back to the pedestal and retry with an earlier let-go of Right." );
}
else if ( _shifterUndersideTicksSinceArm > UNDERSIDE_TIMEOUT_TICKS )
{
_shifterUndersideVerdictLogged = true;
Log.Warning( $"[BlockParty] shifter_underside_repro: INCONCLUSIVE — no verdict band hit after the walk-off (parked at x={player.X:0.0}, y={player.Y:0.0}). If you rose past the block on the LEFT of x=106 that's still healthy behaviour; reload to retry, and use shifter_trace/crush_trace for the tick detail." );
}
}
/// <summary>Shifter gap repro's per-tick verdict: the 9px slot triggers the side-wall adoption by
/// itself on the first tick (both walls sit inside the 1px contact probe). The rotated 10px hitbox
/// doesn't fit, so an instant squish = BUG REPRODUCED (rotate-then-resolve-or-die), and still
/// standing after a few seconds = HEALTHY (the adoption was refused and reverted).</summary>
private static void TickShifterGapRepro( GameStage stage )
{
if ( _shifterGapVerdictLogged ) return;
var player = stage.Player;
if ( player is null ) return;
_shifterGapTicks++;
if ( player.IsDead )
{
_shifterGapVerdictLogged = true;
Log.Info( $"[BlockParty] shifter_gap_repro: BUG REPRODUCED — the side-wall adoption rotated the hitbox into a slot it doesn't fit and resolve-or-die crushed the player (tick {_shifterGapTicks})." );
}
else if ( _shifterGapTicks >= GAP_VERDICT_TICKS )
{
_shifterGapVerdictLogged = true;
Log.Info( "[BlockParty] shifter_gap_repro: HEALTHY — the tight-gap side adoption was refused and reverted; the player is still standing in the slot." );
}
}
/// <summary>Shifter reverse repro's per-tick verdict, in two phases. PHASE 1: the airborne spawn
/// (y=85) must rise — the field affects airborne surface-gravity players — and ADOPT the box's
/// lip underside (settle at y≈87); repeated midline crossings instead = BUG (the in-field
/// adoption regressed to the floor↔ceiling ping-pong). PHASE 2: once settled the script kills
/// the Reverse block, cutting the field; still hanging afterwards = HEALTHY (the touch adopted
/// the surface for real, so field exit doesn't detach), falling = BUG (the rest was only
/// field-held).</summary>
private static void TickShifterReverseRepro( GameStage stage )
{
var player = stage.Player;
if ( player is null || player.IsDead )
{
if ( !_shifterReverseVerdictLogged && player is not null )
{
_shifterReverseVerdictLogged = true;
Log.Warning( "[BlockParty] shifter_reverse_repro: INCONCLUSIVE — the player died, which this layout should never allow. Fix the repro before trusting any verdict." );
}
return;
}
if ( _shifterReverseVerdictLogged ) return;
_shifterReverseTicks++;
// Ping-pong watch: oscillating between the arena floor and the lip crosses y=50 repeatedly.
bool above = player.Y > REVERSE_PINGPONG_MIDLINE_Y;
if ( _shifterReverseTicks > 1 && above != _shifterReverseWasAbove ) _shifterReverseCrossings++;
_shifterReverseWasAbove = above;
if ( !_shifterReverseFieldCut && _shifterReverseCrossings >= REVERSE_PINGPONG_BUG_CROSSINGS )
{
_shifterReverseVerdictLogged = true;
Log.Info( $"[BlockParty] shifter_reverse_repro: BUG REPRODUCED — {_shifterReverseCrossings} midline crossings before any settle: the in-field touch isn't adopting the surface (floor↔ceiling ping-pong)." );
return;
}
// PHASE 1: wait for a settled hang against the lip underside, then cut the field.
if ( !_shifterReverseFieldCut )
{
bool still = Math.Abs( player.Y - _shifterReverseLastY ) < 0.05f;
_shifterReverseLastY = player.Y;
_shifterReverseSettleTicks = still && player.Y > REVERSE_SETTLE_MIN_Y ? _shifterReverseSettleTicks + 1 : 0;
if ( _shifterReverseSettleTicks >= REVERSE_SETTLE_TICKS )
{
var reverse = FindReverseBlock( stage );
if ( reverse is null || reverse.IsDead )
{
_shifterReverseFieldCut = true; // fall through and let the post-cut watch report
Log.Warning( "[BlockParty] shifter_reverse_repro: Reverse block missing/already dead at the scripted kill — level def changed?" );
return;
}
reverse.Die();
_shifterReverseFieldCut = true;
Log.Info( $"[BlockParty] shifter_reverse_repro: adopted the lip underside (y={player.Y:0.0}); field cut — watching whether the attachment survives..." );
}
else if ( _shifterReverseTicks > REVERSE_PINGPONG_TIMEOUT_TICKS )
{
_shifterReverseVerdictLogged = true;
Log.Warning( $"[BlockParty] shifter_reverse_repro: INCONCLUSIVE — never settled against the lip underside (y={player.Y:0.0}, {_shifterReverseCrossings} crossings). Did the airborne lift not happen (check the field phase), or did the layout change?" );
}
return;
}
// PHASE 2: the field is gone; a real adoption keeps hanging, a field-held rest drops.
_shifterReverseTicksSinceCut++;
if ( player.Y < REVERSE_DETACH_BUG_MAX_Y )
{
_shifterReverseVerdictLogged = true;
Log.Info( $"[BlockParty] shifter_reverse_repro: BUG REPRODUCED — the player fell (y={player.Y:0.0}) when the field died: the underside rest was only field-held instead of a real base adoption." );
}
else if ( _shifterReverseTicksSinceCut >= REVERSE_HOLD_HEALTHY_TICKS )
{
_shifterReverseVerdictLogged = true;
Log.Info( $"[BlockParty] shifter_reverse_repro: HEALTHY — still hanging from the lip underside (y={player.Y:0.0}) two seconds after the field died: the in-field touch adopted the surface for real." );
}
}
/// <summary>Wrap repro's per-tick script: grafts spikes onto the bottom wall just before the
/// free-falling player crosses it (so the crossing happens during the grow-in transition), then
/// logs a verdict — BUG REPRODUCED if the player left the arena alive, HEALTHY if they wrapped
/// to the top mid grow-in.</summary>
private static void TickWrapRepro( GameStage stage )
{
var player = stage.Player;
if ( player is null || player.IsDead ) return;
if ( !_wrapSpikesAdded )
{
if ( player.Y < WRAP_SPIKE_TRIGGER_Y )
{
_wrapSpikesAdded = true;
stage.AddSpikesToFace( stage.FindArenaFace( Direction.Down, player.Pos ), WRAP_SPIKE_LIVE_TIME );
Log.Info( "[BlockParty] wrap_repro: floor spikes growing in while the player falls through the wall..." );
}
return;
}
if ( _wrapVerdictLogged ) return;
// Fully below the arena floor, still alive: refused the wrap, wasn't clamped, wasn't killed.
if ( player.GetRect().Top < 0f )
{
_wrapVerdictLogged = true;
Log.Info( "[BlockParty] wrap_repro: BUG REPRODUCED — player escaped the arena through the switching spikes and is falling in the void (delayed offscreen death when they land)." );
}
// Back in the top half of the arena: the wrap fired during the grow-in, as it should.
else if ( player.Y > Arena.HEIGHT / 2f )
{
_wrapVerdictLogged = true;
Log.Info( "[BlockParty] wrap_repro: HEALTHY — player wrapped to the top wall while the floor spikes were still growing in." );
}
}
/// <summary>Reverse-detach repro's per-tick script: wait for the field-lifted player to settle
/// hanging under the boxed Reverse block, kill the block to cut its field mid-hang, then log the
/// verdict — BUG REPRODUCED if the player snapped up THROUGH the block onto its top (the top-ride
/// glue ran on a face-Up attachment), HEALTHY if they detached and fell.</summary>
private static void TickReverseDetachRepro( GameStage stage )
{
var player = stage.Player;
if ( player is null || player.IsDead )
{
// Nothing in the level can kill the player, so a death means the repro geometry broke.
if ( !_reverseVerdictLogged && player is not null )
{
_reverseVerdictLogged = true;
Log.Warning( "[BlockParty] reverse_detach_repro: INCONCLUSIVE — the player died, which this layout should never allow. Fix the repro before trusting any verdict." );
}
return;
}
// PHASE 1: wait for a SETTLED hang under the block (the one-off bottom-face press on the way
// up can bounce them briefly, so require consecutive ticks at hanging height), then cut the
// field by killing its block — the same "flag drops while attached face-Up" edge as the field
// drifting off a hanging player mid-game.
if ( !_reverseFieldCut )
{
_reverseHangTicks = player.Y > REVERSE_HANG_MIN_Y ? _reverseHangTicks + 1 : 0;
if ( _reverseHangTicks < REVERSE_HANG_SETTLE_TICKS ) return;
var reverse = FindReverseBlock( stage );
if ( reverse is null || reverse.IsDead )
{
_reverseFieldCut = true; // no block to kill — fall through and let the timeout report
Log.Warning( "[BlockParty] reverse_detach_repro: Reverse block missing/already dead at the scripted kill — level def changed?" );
return;
}
reverse.Die();
_reverseFieldCut = true;
Log.Info( $"[BlockParty] reverse_detach_repro: field cut while the player hangs under the block (y={player.Y:0.0}). Watching where they end up..." );
return;
}
if ( _reverseVerdictLogged ) return;
_reverseTicksSinceCut++;
// Above the block's top region: only reachable by the glue snapping the player through the
// solid block (the bug puts their centre at ≈145 in a single tick).
if ( player.Y > REVERSE_BUG_MIN_Y )
{
_reverseVerdictLogged = true;
Log.Info( $"[BlockParty] reverse_detach_repro: BUG REPRODUCED — player teleported through the block onto its top (y={player.Y:0.0}) the tick the field dropped: HandleMovingPlatforms ran the top-ride glue (Bottom = parent.Top) on a face-Up attachment instead of detaching." );
}
// Back below the hang: they detached and fell, as a fixed build should.
else if ( player.Y < REVERSE_HEALTHY_MAX_Y )
{
_reverseVerdictLogged = true;
Log.Info( $"[BlockParty] reverse_detach_repro: HEALTHY — player detached and fell (y={player.Y:0.0}) when the field dropped mid-hang." );
}
else if ( _reverseTicksSinceCut > REVERSE_VERDICT_TIMEOUT_TICKS )
{
_reverseVerdictLogged = true;
Log.Warning( $"[BlockParty] reverse_detach_repro: INCONCLUSIVE — player parked at y={player.Y:0.0} after the cut, in neither verdict band. Did the attach never happen (check the hang height), or did the layout change?" );
}
}
/// <summary>Pillar-pinch repro's per-tick driver + verdict. POSE PHASE (block face right of the
/// arm line): re-pin the reported pose every tick — centre 3px above the block top (bottom edge
/// 2px below it) with a gentle rise — and re-clamp the block's speed, while the held RIGHT does
/// the actual hugging; X is left to the real press/attach machinery. ARMED: let the face grind
/// the player's left edge a sub-residual-EPS hair into the pillar, then FLIP the block rightward
/// (the video's Hunter-brake / block-bounce reversal). An unfixed build's rightward sweep now
/// takes a Right push-out FROM THE PILLAR ON THE LEFT, wall-hug-attaches face-Right to it, and
/// the glue snaps Right = pillar.Left next tick — the one-tick through-the-glass teleport.
/// Fixed builds treat the along-sweep push-out as progress and stay on the right side.</summary>
private static void TickPillarPinchRepro( GameStage stage )
{
if ( _pillarPinchVerdictLogged ) return;
var player = stage.Player;
var blocks = stage.GetBlocks();
if ( player is null || blocks.Count == 0 )
return;
Block block = blocks[0];
if ( !_pillarPinchArmed )
{
if ( player.IsDead )
{
_pillarPinchVerdictLogged = true;
Log.Warning( "[BlockParty] pillar_pinch_repro: INCONCLUSIVE — the player died before the pinch was even set up, which this layout should never allow. Fix the repro before trusting any verdict." );
return;
}
// Pin the FULL flush hug pose, X included: centre exactly Width/2 left of the face, so
// there is zero horizontal overlap with the block. Pinning Y alone let X drift a fraction
// INTO the block, which read as floor (OnFloor from the down-probe overlap) — the hug's
// grounded release kept detaching, the attach gate refused, and the first pillar contact
// hit the corner deflect UN-exempted, popping the player harmlessly on top instead of
// grinding (the first repro run's failure). Flush + airborne, the held-RIGHT sweep
// wall-stops on the face each tick and the real attach machinery keeps the hug live.
block.DebugSetMoveSpeed( PILLAR_PINCH_BLOCK_SPEED );
player.X = block.Left - player.Width / 2f;
player.Y = block.Top + 3f;
player.VelY = PILLAR_PINCH_POSE_RISE_VELY;
if ( block.Left > PILLAR_PINCH_ARM_BLOCK_LEFT )
return;
_pillarPinchArmed = true;
_pillarPinchLastX = player.X;
Log.Info( $"[BlockParty] pillar_pinch_repro: armed — hugging the face at (x={player.X:0.0}, y={player.Y:0.0}), left edge {player.X - 4f - PILLAR_PINCH_PILLAR_RIGHT:+0.0;-0.0}px off the pillar. Grinding until a sub-EPS embed, then flipping the block..." );
return;
}
float dX = player.X - _pillarPinchLastX;
_pillarPinchLastX = player.X;
// Grind phase: keep the approach speed clamped; flip the moment the player's left edge is
// meaningfully but sub-residual-EPS inside the pillar (pen = 177 - centre X).
if ( !_pillarPinchFlipped )
{
if ( player.IsDead )
{
_pillarPinchVerdictLogged = true;
Log.Warning( $"[BlockParty] pillar_pinch_repro: INCONCLUSIVE — crushed before the scripted reversal (the embed overshot the residual EPS window; the approach speed drifted?). Reload to retry." );
return;
}
// Popped onto the block top mid-grind: the corner deflect resolved the pinch UPWARD —
// the reported "depenetrated upwards" outcome, and the FIXED build's expected ending
// here (the along-sweep push-out rule cleans the embed to flush each tick, so the
// pinch never survives to the scripted reversal; the next blind pass's deflect target
// is then clear and fires). On an unfixed build the embed survives the tick instead,
// keeps the deflect target blocked, and the grind reaches the flip below.
if ( player.Y > block.Top + 4.5f )
{
_pillarPinchVerdictLogged = true;
Log.Info( $"[BlockParty] pillar_pinch_repro: NO TELEPORT — the pinch resolved with the designed upward deflect onto the block's top (x={player.X:0.0}, y={player.Y:0.0}) before any embed could survive a tick. This is the fixed build's expected outcome. (Valid only if RIGHT was held from load — with no input the pinch never forms and this pop is trivial.)" );
return;
}
float pen = 4f + PILLAR_PINCH_PILLAR_RIGHT - player.X;
if ( pen < PILLAR_PINCH_FLIP_MIN_PEN )
{
block.DebugSetMoveSpeed( PILLAR_PINCH_BLOCK_SPEED );
return;
}
block.DebugSetMoveDirection( Direction.Right );
block.DebugSetMoveSpeed( PILLAR_PINCH_BLOCK_SPEED );
_pillarPinchFlipped = true;
Log.Info( $"[BlockParty] pillar_pinch_repro: flipped the block rightward with the player {pen:0.00}px inside the pillar (x={player.X:0.00}). Watching the sweep..." );
return;
}
_pillarPinchTicksSinceFlip++;
if ( player.IsDead )
{
_pillarPinchVerdictLogged = true;
Log.Warning( $"[BlockParty] pillar_pinch_repro: INCONCLUSIVE — died after the reversal (tick {_pillarPinchTicksSinceFlip}), which the sub-EPS embed should never allow. Re-run with crush_trace." );
return;
}
// Through the glass: nothing legal moves the centre past the pillar's midline (legal motion
// parks flush at its right face), and nothing legal jumps 8px+ sideways in one tick.
if ( player.X <= PILLAR_PINCH_BUG_MAX_X || dX <= -PILLAR_PINCH_TELEPORT_DX )
{
_pillarPinchVerdictLogged = true;
Log.Info( $"[BlockParty] pillar_pinch_repro: BUG REPRODUCED — one-tick pass THROUGH the glass pillar (x={player.X:0.0}, dX={dX:0.0}, tick {_pillarPinchTicksSinceFlip} after the flip): the rightward sweep took the pillar's Right push-out as a wall hit, attached a face-Right WallHug to the pillar on the LEFT, and the glue snapped Right = pillar.Left." );
return;
}
if ( _pillarPinchTicksSinceFlip >= PILLAR_PINCH_HEALTHY_TICKS && player.X > PILLAR_PINCH_PILLAR_RIGHT - 3f )
{
_pillarPinchVerdictLogged = true;
Log.Info( $"[BlockParty] pillar_pinch_repro: HEALTHY — {PILLAR_PINCH_HEALTHY_TICKS} ticks after the reversal the player is still on the pillar's right side (x={player.X:0.0}, y={player.Y:0.0}); the along-sweep push-out was treated as progress, not a wall grab." );
return;
}
if ( _pillarPinchTicksSinceFlip > PILLAR_PINCH_TIMEOUT_TICKS )
{
_pillarPinchVerdictLogged = true;
Log.Warning( $"[BlockParty] pillar_pinch_repro: INCONCLUSIVE — no verdict band hit {PILLAR_PINCH_TIMEOUT_TICKS} ticks after the flip (parked at x={player.X:0.0}, y={player.Y:0.0}, block face at {block.Left:0.0}). crush_trace will show what's holding the state." );
}
}
}