Non-Reforged movement accuracy
The measured original movement model, and where this engine still differs from it.
Reference for the non-Reforged movement model — the mode that aims to reproduce the original Jazz Jackrabbit 2 rather than to play well by modern standards. Every figure attributed to the original here was measured, not derived and not remembered, with the trajectory probe described in How this was measured. The constants themselves live in Jazz2::Legacy, and every one of them is gated on !IsReforged().
Three fixes found along the way are the exception, because they are plain bugs in shared collision code rather than differences between the two models, and all three are therefore live in Reforged as well: the missing half-step of gravity in Jazz2::
The camera is measured here too (see The camera) but is gated on its own preference, Jazz2::
Four columns recur in most of the tables below (a few one-off comparisons name their own):
| Column | Meaning |
|---|---|
| 3.8.0 | The engine before this work (commit 35eb085c). Hand-tuned by feel. |
| Original | Jazz Jackrabbit 2 with JJ2+ Beta v6.6h, measured at 70.021 ticks/s. |
| Now | Current state, converted from the Original column. |
| How far … was off | Against the Now column, i.e. against the original converted to 60 Hz. |
Units and the frame-rate conversion
The original runs at a fixed 70 ticks/s; this engine has a variable tick rate with a 60 Hz baseline for timeMult. Converting a per-tick figure between them:
- velocity (px/tick) ×
70/60= 1.166667 - acceleration (px/tick²) ×
(70/60)²= 1.361111 - duration (ticks) ÷
70/60
Scaling both velocity and acceleration exactly preserves jump height and real-time duration simultaneously, so no further tuning factor belongs in these constants. 3.8.0 carried extra fudge factors (×1.05 on ground speed, ×0.94 on vertical velocity, ×0.94² on rise gravity, ×0.977 on fall gravity) which are all gone.
The original works in 1/65536 px internally, which is why its constants come out as clean fractions of 65536 — a strong signal that a measurement landed on the real value rather than near it.
The headline finding
The original applies one acceleration table to everything. Ground and air are identical, and the acceleration is applied in the direction of the input regardless of which way the player is moving. There is no skid factor, no separate airborne rate, and no loss of momentum when the direction is flipped.
3.8.0 had a ground "skid" (LegacyRunBrakeScale 0.4 / LegacyWalkBrakeScale 0.7) and, at various points during this work, several different airborne models. All of them were inventions, and all are deleted.
Ground movement
| Quantity | 3.8.0 | Original (70 Hz) | Now (60 Hz) | How far 3.8.0 was off | How far Now is off |
|---|---|---|---|---|---|
| Walk speed cap | 4.900 | 4.0 | 4.6667 | +5.0% | exact |
| Dash speed cap | 9.800 | 16.0 | 18.6667 | −47.5% | exact |
| Acceleration, no Run | 0.380 | 0.18311 (12000/65536) | 0.24924 | +52.5% | exact |
| Acceleration, Run held | 0.380 | 0.36621 (24000/65536) | 0.49847 | −23.7% | exact |
| Deceleration, walking | 0.154 | 0.12207 (8000/65536) | 0.16616 | −7.3% | exact |
| Deceleration, in dash state | — | 0.42725 (28000/65536) | 0.58155 | (absent) | exact |
| Turn-around skid multiplier | ×0.4 / ×0.7 | none | 1.0 | (invented) | exact |
Note 3.8.0 used the same acceleration whether or not Run was held; the original doubles it. That is why walking felt too twitchy (+52%) and dashing too sluggish (−24%) at the same time.
Full stop times independently confirm the deceleration model — these were measured, then found to agree with the constants, rather than fitted to them:
| From | Original | Predicted by the applied constants |
|---|---|---|
| Walk cap | 33 ticks (0.47 s) | 4.0 / 0.12207 = 32.8 ✔ |
| Dash cap | 49 ticks (0.70 s) | 16 grace ticks + 33 ✔ |
The dash is a timed state, not a cap
Not present in 3.8.0 at all. Holding Run raises both the cap and the acceleration. Letting go does not bleed the speed off: the player keeps the full dash speed for 16 ticks (13.71 of ours) and the speed is then clamped straight back to the walk cap in a single tick — measured 16.0000 → 4.0000 in one tick, in mid-air exactly as on the ground. See Jazz2::
Applied-movement cap, the subtlest difference
The original caps applied movement at 8 px/tick on both axes. A dash reports an xSpeed of 16 but only ever travels 8. The excess is momentum, not motion, and it still counts wherever the speed is read — which is what makes a dashing jump launch at −14 while moving no faster than a capped run.
3.8.0 had this on the vertical axis only (LegacyRiseSpeedCap, already correct at 8). Adding the horizontal half was worth 300–570 px of error per dash scenario.
Slopes
Measured: slopes have no effect on horizontal speed or acceleration whatsoever. Walking downhill xs stays at exactly 4.0 and x advances exactly 4/tick while y drops 4/tick; uphill is identical, with no slowdown. The acceleration curve is unchanged on an incline (10.8032 at the same tick both ways). This engine already behaved correctly here — no change was needed.
Jumping into a slope, where 45 degrees is exactly the wrong number
Walking or dashing along a slope needed no change (see above), but jumping into one did, and the failure sat on an exact equality rather than anywhere near it.
Jazz2::Actors::ActorBase::TryMoveSubstep()'s airborne branch retries a blocked horizontal step with an increasing upward offset, which is what lets a mostly-horizontal move ride up a gentle incline instead of stopping at its foot. It was gated on abs(stepX) > abs(stepY). A dashing jump travels 8 px across while rising at the applied cap of 8, so against a 45-degree face those two are not merely close, they are equal — and a strict > switches the climb off at precisely the angle it exists for. What follows is automatic: the loop below grinds the horizontal step down until something fits, the leftover is under 30% of the step, HitWall is reported, and Jazz2::
| Scenario | Original | Before | After |
|---|---|---|---|
sl_up45_jhold (dash + jump) | 16.0000 throughout | 0.9977 | 18.6667 throughout |
sl_up45_run (dash, no jump) | 16.0000 | 18.6667 | 18.6667 |
sl_up45_jwalk (walk + jump) | 4.0000 | 4.6667 | 4.6667 |
Crossing a gap, and the "ledge hop" that never existed
This engine had an original-style ledge hop: running off a ledge gave a small upward boost so the player would clear a small gap instead of dropping into it. Measured over gaps of one, two, three and four tiles, the original gives no such boost. Running off the edge, ys goes straight into the fall gravity — 0, 0.125, 0.250, 0.375, … — and a four-tile gap is crossed having dropped under 10 px, landing on the far edge purely because 8 px of travel a tick outruns the fall. A walk drops far enough to miss the far edge of a three-tile gap and falls in, which is the same arithmetic rather than a separate rule. So gap clearing needs no code at all: the applied speed cap and the fall gravity already produce it, and both are measured exactly. The boost is gone, and with it the last two hand-tuned scale factors in the whole model.
That fix was worth two tiles of reach, and a sweep over gaps of one to six tiles - each with a full run-up, so every gap is met at true top speed - then exposed a second one. The engine was landing a whole gap short of the original at both speeds, because the fall matched tick for tick but the landing did not: how far below a ledge's surface the player may arrive and still be put on top of it, rather than caught by its side.
The original's allowance follows from its own four boundary cases. It clears a two-tile walk, arriving 6.25 px below the far edge, and a five-tile dash, arriving 18.1 px below; it fails a three-tile walk (20.25 px) and a six-tile dash (27.6 px). So the allowance sits between 18.1 and 20.25 px — five eighths of a tile, and Jazz2::
This engine's reach was whatever the horizontal sub-step happened to be, about 6 px: the airborne branch of Jazz2::Actors::ActorBase::TryMoveSubstep() already climbs when a horizontal step is blocked - that is what lets a sidekick ride up a gentle slope - but only by abs(stepX) + 2.5. A descending actor was therefore caught by the ledge's side. The allowance is opt-in per actor (ActorBase::_landingTolerance, 0 by default) and only the non-Reforged player sets it, since that is the only thing it was measured on. With it, all twelve scenarios agree with the original:
| Approach | Original | Ours |
|---|---|---|
| Walking (4 px/tick) | clears 1 and 2, falls into 3 | same |
| Dashing (8 px/tick applied) | clears 1 to 5, falls into 6 | same |
One residual: after landing from a crossing, the original keeps more speed than this engine does, so a run that takes the four-tile gap and then meets the five-tile one clears it there and not here. Each gap approached with its own run-up matches.
Jumping and air movement
| Quantity | 3.8.0 | Original (70 Hz) | Now (60 Hz) | How far 3.8.0 was off | How far Now is off |
|---|---|---|---|---|---|
| Jump launch, standing | −10.967 | −10.0 | −11.6667 | −6.0% | exact |
Launch bonus per unit of xSpeed | max(0, abs(xs) − 4) * 0.3 | abs(xs) / 4, no threshold | abs(xs) / 4 | wrong shape | exact |
| Rise gravity, jump held | 0.45101 | 0.375 | 0.51042 | −11.6% | exact |
| Rise gravity, jump released | (speed clamped to −4.387) | 0.875 | 1.19097 | wrong mechanism | exact |
| Rise gravity, released, last px/tick | (as above) | 0.625 | 0.85069 | (absent) | exact |
| Fall gravity | 0.16623 | 0.125 | 0.17014 | −2.3% | exact |
| Underwater gravity | 0.01879 | 0.015625 (1024/65536) | 0.02127 | −11.6% | exact |
| Applied rise cap | 9.3333 | 8.0 | 9.3333 | correct | exact |
| Terminal fall speed | 13.160 | 12.0 | 14.0 | −6.0% | exact |
| Air acceleration | 0.380 + skid | same as ground | same as ground | (invented) | exact |
| Standing jump height (end to end) | (not measured) | 132.0 px | 128.2 px | — | −2.9% |
Releasing jump changes the rise gravity (0.375 → 0.875) from that tick onward; it does not clamp the speed. That is why a tap hop has its own arc in the original rather than a clipped one, and it is what gives Spaz's double jump its hold-duration control for free.
Measured arcs, all original: standing jump 132.0 px (4.13 tiles), apex at +27 ticks, airtime ~73 ticks. Tap jump 72.9 px. Walking jump 153.4 px, dx 312 px. Dashing jump 217.0 px, dx 752 px.
The rise, tick by tick
Read off ap_r01..ap_r30 — thirty standing jumps released one tick apart, which is the only family that samples the apex on purpose rather than incidentally. In the original's own units, once per tick:
if (ys < 0) { // still rising brake = held ? 0.25 : (abs(ys) > 1.0 ? 0.75 // ... and the released rate eases off : 0.5); // for the last pixel per tick if (ys + brake > 0.125) ys = 0; // the rise ENDS rather than overshooting else ys += brake; } ys += 0.125; // gravity, every tick, rising or falling y += max(ys, -8); // the applied rise cap
This reproduces all thirty peaks to 0.01 px — 58.87, 63.62, 68.00 … saturating at 132.00 from ap_r27, where the release comes later than the apex and stops mattering. Three things in it are worth naming, because each was wrong or missing before and none is visible in a single arc:
- The released rate is two rates. 0.875 while the rise is faster than one pixel per tick, 0.625 below it. Measured either side of that threshold: the step is 0.875 at
ys= −1.125 and 0.625 at −1.000. The held rate has no such band, on 379 samples. - The rise never overshoots by more than one tick of gravity. From −0.375 a released rise lands on +0.250 and keeps it, but from −0.250 and −0.125 it lands on +0.125, not the +0.375 and +0.500 the deceleration alone would give. This is not a one-tick detail: an overshoot becomes a fixed offset on the entire descent that follows.
- The applied cap is what makes the first six ticks flat. They travel exactly −8.0 while
ysreads −10.000 down to −8.125, which is worth 6.375 px of the 132 — and a model without it overshoots every peak in the sweep by exactly that.
What it does not fix is the standing jump, still 128.0 px against 132.0. That residual is the time step, not the model: at 60 Hz a frame covers 7/6 of the original's ticks, so the flat capped part of the rise is sampled 6 times where the original samples it 7, and a coarser sample of the same capped ascent covers less ground. The sweep shows it plainly — the error follows the release tick between +3.0 and −4.0 px, and pairs of adjacent release ticks collapse onto one frame (ap_r03/ap_r04 both give 70.96, ap_r17/ap_r18 both 119.00) which no change to the rise rule can separate.
Air turn-around, the one that took five attempts
Since ground and air share one table, turning around in the air is symmetric and keeps its momentum, which is what makes it slow. Four different invented models were tried and rejected before measurement settled it.
Objects
| Quantity | 3.8.0 | Original (70 Hz) | Now (60 Hz) | How far 3.8.0 was off | How far Now is off |
|---|---|---|---|---|---|
| Spring, red — both axes | 7.090 vert / 11.111 horiz | 16.0 | 18.6667 | −62% / −40% | exact |
| Spring, green — both axes | 7.847 / 11.111 | 24.0 | 28.0 | −72% / −60% | exact |
| Spring, blue — both axes | 9.774 / 11.111 | 32.0 | 37.3333 | −74% / −70% | exact |
| Vertical pole launch | 4*sign + entry*1.4, ×2.5 | 15.625 + abs(entry) | 18.229 + abs(entry) | wrong shape | exact |
| Horizontal pole launch | 10*sign + entry*0.2 + force 10 | clamp(3 * abs(entry), 8, 20) | clamp(3 * abs(entry), 9.333, 23.333) | wrong shape | exact |
| Vine cap, no Run | 2.0 | 2.0 | 2.3333 | −14.3% | exact |
| Vine cap, Run held | 3.2 (×1.6) | 4.0 (×2) | 4.6667 | −31.4% | exact |
| Push speed (solid object) | 0.6 × timeMult | 0.375 | 0.4375 | frame-rate dependent | exact |
Springs set the speed outright
No formula, no added external force, and only the colour matters — the same figure on both axes. 3.8.0 used one shared strength for every horizontal spring and a completely different scale for vertical ones; neither matched. The colours differ in how long they push, not how fast the player visibly moves, because the applied cap holds all three to 8 px/tick.
The first: a spring left under the player's feet fires during the settle. The probe removed the previous scenario's spawned object just before the run rather than at the reset, so the player was launched while the next scenario was still lining up, and that scenario's first logged tick was already 60 px up with a third of its speed spent. This affected the second and third spring in the row and both games — which is why green appeared to rise less than red despite the stronger launch, the figure that made the whole thing look impossible. Cleaned up at the reset, the original's rises come out 267.5, 438.4 and 609.0 px for red, green and blue: linear in launch speed, +170.7 px for each +8 of it. The numbers that had been quoted for green and blue, 326.8 and 377.9, were the contamination.
With both fixed, and both sides measured clean:
| Spring | Original | Ours | Difference |
|---|---|---|---|
| Red (16) | 267.5 px | 282.1 | +14.6 (5.5%) |
| Green (24) | 438.4 px | 452.5 | +14.1 (3.2%) |
| Blue (32) | 609.0 px | 597.3 | −11.7 (1.9%) |
The two ticks a spring holds its launch speed before gravity starts — which this engine does not reproduce — account for the blue deficit exactly: the simple model of a capped rise decaying at 0.375 predicts 256, 426.7 and 597.3, and the original sits about 11.5 px above all three while ours lands on the model precisely for blue. Adding the hold would therefore fix blue and make red and green worse by the same amount, so it is not the whole story and is deliberately not applied on its own. What is left is a couple of percent that grows as the launch weakens, which has the shape of integration detail near the apex rather than a missing rule.
Every scenario built out of props missed this, for a structural reason: they all walk into a horizontal spring, at the height it already sits at, where the midpoint of the two positions is the position. Only something falling onto one is affected, and it took a chain in shipped level geometry to produce that — Diamondus 3's west end, where a fall down a shaft lands on a horizontal spring and is thrown along a corridor. There the 10 px decided whether the player cleared a wall one tile up and escaped the chain, or hit it, dropped onto a horizontal pole with no speed left, and was thrown back to the spring for ever. That scenario scored 204.5 px/s before the fix and 0 after it. Reforged keeps the snap.
Float-up areas hold a speed, they do not push
An EventType::AreaFloatUp assigns ys = -8 every tick the player is inside it. Not an acceleration, not an external force, and no ceiling to accelerate towards: measured inside a solid ten-tile column of them, the original reads exactly −8.0000 on every one of the 43 ticks in the field and only starts decaying once the player leaves. It also needs no jump and lifts a grounded player straight off the floor — pressing nothing gives a trace identical to jumping, tick for tick.
| 3.8.0 | Original (70 Hz) | Now (60 Hz) | How far 3.8.0 was off | How far Now is off |
|---|---|---|---|---|
force -2*g*timeMult | ys = -8 while inside | ys = -9.333 | wrong shape | exact |
The continuous force never assigns a real upward speed, so it oscillated around zero instead of lifting. Riding the test level's diagonal ladder of them gained 149.8 px against the original's 520.3 — the horizontal travel matched to 0.2 px the whole way, so the player crossed the ladder without climbing it. It is now 520.7.
A buttstomp is the one descent the field cannot simply overrule, because it re-asserts its own speed every tick and so never lets the rise be assigned at all. What the original does instead is hold the movement* down to Jazz2::
| Where | Original's ys | Original's movement | This engine, before | Now |
|---|---|---|---|---|
| Above the field | 10 | 10 px/tick | 10 px/tick | 10 px/tick |
| Entering | 12 | 4 px/tick | 10 px/tick | 4.08 px/tick |
| Crossing | 16 | 4 px/tick | 10 px/tick | 4.08 px/tick |
Measured on fu_col_butt, a stomp dropped into the isolating column from clear air: the original covers exactly 4.00 px on every one of the 52 ticks it takes to cross, and the 176 px column takes it 44 ticks against this engine's 43. Three entry speeds land on the same figure — 6 on an ordinary fall, 12 and 16 on the stomp — which rules out the reading that the field's 8 px lift is simply subtracted, since 6 − 8 would carry the player upwards.
This engine skipped the whole field while a buttstomp was running and fell through at full speed, which is the reported "buttstomping a float-up event should slow the fall noticeably". The cap is applied to the buttstomp alone: an ordinary fall does touch it for the single tick before the rise assignment takes over, but capping that as well would put the tighter limit on every frame of a ladder ride where _inFloatUpArea is a frame stale, to reproduce one tick that the assignment overwrites anyway.
A double jump was measured at the same time and needs no change: inside a column it climbs 261.1 px against 257.2.
The copter is a different story, and the answer is that it is not a float-up question at all. It cannot be measured in the placed column, because a copter is only available after a real jump and a probe-placed mid-air drop never makes it so — neither a held key nor a tap train gets the original to engage one from there. fu_copter therefore rides the level's own ladder from the block west of it, which gives both games a jump to start one from, and there the original copters into the field and climbs 479.7 px where this engine climbs 174.6**.
None of that 305 px is the field. This engine never engages the copter at all: it models the original as spending its one chance per airtime on the first press, wherever that press lands, which is what refuses cp_tap55 and cp_tap60 for their whole airtime and is why they match. fu_copter's first tap lands at tick 20 while the player is still rising, so the chance is spent and every later tap refused — but the original, whose first tap is also rising, engages on the second pair at tick 27 with anim 56. So the one-chance rule is too strict somewhere, and the scenario is a knife edge for it.
It is left alone deliberately. The rule was fitted on the cp_* family, which currently matches, and moving it means re-measuring that family rather than trading one mismatch for another. Until then fu_copter is a copter** scenario that happens to run over float tiles, and its gap must not be read as a float-up defect.
And there is no gravity on a tick spent inside the field, which is a second rule and had to be implemented separately. The original travels exactly the 8.0 it assigns; ours travelled 7.79 of the original's units because the velocity-Verlet half-step is folded into the move before the applied rise cap gets to clamp it. Every other launch is immune to that by accident: they are all assigned above the cap, so the half-step is clamped straight back off and the travel comes out at the cap regardless. A float area is the one source whose assignment is equal to the cap — Jazz2::
A sucker tube re-arms while you are inside it
The tube assigns a speed on each axis outright, and both games agree on that number exactly — a tube set to 8 reads a flat 8.0000, one set to 20 reads 20.0000. What had to be worked out is how the hold ends, and a single tube tile cannot answer it: the player leaves the 32 px tile in four ticks whatever happens, so "a timer" and "still inside a tile" predict the same thing.
Two measurements separate them. A 30-tile row of tubes carries the player the whole length in both games, so the speed is re-assigned every tick they are inside one — not applied once. And the two single-tube scenarios hold for different lengths: 20 ticks at 8 px/tick, 18 at 20 px/tick. That difference is exactly the two extra ticks the slower tube keeps the player inside its own tile, which puts the window at Jazz2::Actors::Player::LegacyTubeControlTime = 16 ticks, re-armed while inside.
The window suppresses horizontal friction but not gravity: a tube firing straight up holds its speed only while the player is still in the tile, and the ascent decays at the ordinary rise gravity from there. That is why the vertical case looked like a 3-tick hold while the horizontal one looked like 19.
| Quantity | 3.8.0 | Original (70 Hz) | Now (60 Hz) | How far 3.8.0 was off | How far Now is off |
|---|---|---|---|---|---|
| Speed assigned | xs = param | same | same | exact | exact |
| Hold window | 10 ticks, fixed | 16, re-armed inside | 13.71 frames | −38% | exact |
| Speed on release | kept in full | clamped to the walk cap | clamped | wrong shape | exact |
| Speed on release, Run held | kept in full | clamped to 16 | clamped | wrong shape | exact |
That last row is inert here and is stated anyway: a tube carries 8 px/tick, so a clamp to 16 never bites and the ride simply continues. It matters because the clamp is one rule shared with the accelerating belt and the sidekick — Jazz2::tb_right_run a fifth of its distance: 166.4 px against the original's 222.9, for holding a key that should if anything help. It is 219.9 now.
The release clamp is the one that showed: a tube set to 20 px/tick carried the player 1066.9 px before, against the original's 407.5, because the whole tube speed was handed back instead of being cut to 4. Its Wait Time parameter is still a TODO, and at a value of 3 changes nothing in either game.
Where in the tile the tube holds the player was wrong by 7 px, reported as a centring problem and measured as one. Riding tb_row, the original sits at y = 1327.000 for the whole ride, unchanging — 15 px into the tile at row 41, a pixel above its centre. This engine snapped to 8, a quarter of the way down. The horizontal branch had always used the tile centre; only the vertical one was off. It is Jazz2::Actors::Player::LegacyTubeSnapY now and the ride matches to the digit.
The flying carrot is two accelerations
Reported as the mechanic this engine had least of, and that was right. It is also the second thing on this page that scripted input cannot reach: with the carrot collected and jjPLAYER.fly reading FLYCARROT for a whole run, eighty ticks of scripted Jump move the original's player not at all — not even an ordinary jump — while Left and Right get through normally. The flight reads the raw key, exactly as the rev-up does, so it was measured from a hand-played recording of 3871 ticks of continuous flying.
| Quantity | Original | Ours before |
|---|---|---|
| Rising | Up held, accelerating 0.25 px/tick to the 32 cap | no rise at all; Jump ignored |
| Still rising, Jump held | brakes at 0.25 | — |
| Still rising, nothing held | brakes at 0.75, clamped at zero | — |
| Then falling | accelerates 0.0625 to a terminal of 12 | hovers; no gravity whatever |
| Climb actually travelled | 8 px/tick, the ordinary applied cap | — |
| Down held | a fixed 1.0625 px/tick, assigned outright | fixed 3.429 px/tick |
| Horizontally | ordinary ground movement — 4 walking, 16 with Run | 4 |
| Duration | until a Fly Off area or a death | ten seconds |
The travel is capped at 8 px/tick throughout, the same applied cap a spring or a dash gets, so the speed number running on to 32 buys height only in how long the climb sustains itself — the trace shows ys reading −27 while y still moves exactly 8 px a tick. That cap was already in place here and needed nothing; it is what makes the climb rate match at 8.035 against 8.000.
The accelerations are as clean as measurements on this page get: 934 ticks of the first recording step by exactly −0.25 and 1243 by exactly +0.0625.
The brake has a held and a released form, exactly as an ordinary jump does and with its own two numbers: 0.25 with Jump down against 0.75 with nothing. The second recording shows both inside one manoeuvre — a jump taken from the ground into the carrot decays at the ordinary 0.375 for the six ticks it is still a jump, and at 0.25 from the tick the flight animation takes over, for thirty ticks with Jump held throughout. Jumping while already flying launches at −10 as usual.
And Down is a fourth rate that is not an acceleration at all: it assigns 1.0625 px/tick and holds it. The recording snaps from a 3.3125 px/tick fall straight to it on the tick Down goes down, then reads 1.0625 for 734 consecutive ticks without varying; letting go resumes the ordinary fall acceleration from wherever the speed had got to. That is what "descends slowly on Down at a rate that is not the copter's" turns out to mean — Jazz2::
Ours now reproduces all four: 0.2502 against 0.25 with Jump held, 0.7507 against 0.75 with nothing, 0.0625 exactly for the fall, a flat 1.0625 on Down, and the crossing from rise to fall clamping to exactly zero rather than overshooting, as the original's does.
The slide tile swaps the brake
A MODIFIER_SLIDE tile adds nothing to the movement and changes no speed. It replaces the deceleration that applies with no direction held, so a player who lets go coasts further — which is the whole of what it does, and why it was missed: EventType::ModifierSlide was converted from the JJ2 event and then read by nothing at all.
Its 2-bit Strength parameter scales both brakes, linearly, and all four values were measured:
| Strength | Walk brake | Dash brake |
|---|---|---|
| — (no slide) | 8000 | 28000 |
| 0 | 4000 | 12000 |
| 1 | 3200 | 9500 |
| 2 | 2400 | 7000 |
| 3 | 1600 | 4500 |
All in 65536ths of a pixel per tick squared, and both columns are straight lines: 4000 - 800*s and 12000 - 2500*s. Note that strength 0 halves the walk brake but takes the dash brake to 3/7 rather than 1/2, so a single "slide factor" applied to both would be wrong — and measuring only strengths 0 and 3 would have given ratios of 2.5 and 2.67 and fitted no divisor at all. Four points for a four-value table.
Travel after letting go, against the original:
| Strength | Original | Ours | Before |
|---|---|---|---|
| 0 | 645.7 px | 653.5 | 587.8 (no slide at all) |
| 1 | 678.5 px | 686.2 | — |
| 2 | 733.1 px | 740.7 | — |
The residual ~1.2% is the same discretisation the ordinary deceleration carries; a walk that ends in a stop matches to 2%. See Jazz2::
Wind and belts move the player, the accelerating belt drives them
Three event families push the player sideways without being touched, and none of them had ever been measured. Two of the three move the player by position — the original's xs reads 0.0000 for the whole time a player is being blown or carried along — and the third works on the speed instead.
| Quantity | 3.8.0 | Original (70 Hz) | Now (60 Hz) | How far 3.8.0 was off | How far Now is off |
|---|---|---|---|---|---|
| Wind, per unit of strength | 0.7 px/tick | 0.5 | 0.5833 | +20% | 0.2% |
| Belt, per unit | 0.7 px/tick | 1.0 | 1.1667 | −30% | 0.1% |
| Belt strength, no parameter | 3 | 2 | 2 | +50% | exact |
| Accelerating belt, per unit | xs += 0.1, uncapped | 1.5 px/tick, held | 1.75 | wrong shape | exact |
| Acc. belt step, per unit | 0.1/frame, unscaled | 0.5 px/tick^2 | 0.6806 | frame-rate dependent | exact |
| Acc. belt strength, no parameter | 3 | 4 | 4 | −25% | exact |
Each figure is fitted on two strengths, not one — the mistake Poles obey two different laws was recorded with. Wind at 8 gives 4.0 px/tick and at 4 gives 2.0, so it is linear through the origin. A belt at 8 gives 8.0 and a parameterless one 2.0, which is what pins its default at 2 rather than 3: a single point would have fitted "strength 4 × 0.5" just as well and been wrong everywhere else. Wind and belts share an event type in this engine but not a strength — a belt pulls exactly twice as hard as a wind of the same number.
Steady speed and cap are now exact at both strengths measured, and travel is within 1.4-3.7%. What is left is the first two or three ticks of the ramp, where a per-frame reading is being compared against a per-tick one and the acceleration conversion makes ours a third of a tick ahead.
The belt is also the one carry fast enough to show that the exit clamp has two targets rather than being skipped while Run is held: both strengths snap from 18 and 24 to exactly 16 and then decay at the dash brake, where with nothing held they snap from 6 and 12 to 4 and decay at the walk brake. A tube carries 8 px/tick and Spaz's kick leaves at 15.88, so the 16 clamp is inert at both — which is why the sidekick's measurement read as "Run skips the snap" and could not be generalised. See Jazz2::
Riding the belt with Run held, the original also travels less than the speed it reports — 10 px/tick while reporting 18, and 16 while reporting 24 — which was recorded here for a long time as unfittable, because those two points alone cannot tell a cap from a subtraction of 8. The ramp is what tells them apart, and it was in the trace all along: on bl_acc_right_run the travel follows xs exactly while it climbs, 5.145, 6.719, 8.291, 9.863, and only then flattens at 10 while xs carries on to 18. A subtraction would have started at 10 and it does not, so it is a ceiling.
One ceiling then fits all four combinations — the belt's own strength plus the walk cap, with the Run bonus left out:
| Strength | Run | Reports | Travels | Ceiling (strength + 4) |
|---|---|---|---|---|
| 4 | — | 6 | 6 | 10, never binds |
| 8 | — | 12 | 12 | 16, never binds |
| 4 | held | 18 | 10 | 10 |
| 8 | held | 24 | 16 | 16 |
So Run raises what the belt drives to by twelve and what it actually moves the player by four. Ours was fully exempt from the applied cap and travelled the whole reported speed.
A one-way floor is passable to the body and solid to the jump
Nearly all of this was already right. Both games let a rising player straight through a one-way platform and both catch a falling one on it, frame for frame: on ow_hop, a hop from the platform at row 33 that peaks just above the one at row 31, the two pass through at the same tick and come to rest 2 px apart. Holding Down on one does not drop the player through in either game, and neither does holding it through the whole fall onto one — ow_down and ow_down_fall both land and stay.
What differs is what the jump test makes of the tile. In the original the platform's own mask still answers "is there ground under the feet", so crossing one with the jump key held launches a fresh jump. A ladder of them can therefore be climbed by simply holding the key, and that is measurable: on ow_jump the original's rise resets to exactly −10 as the feet cross rows 31 and 29, two extra launches where this engine took one and stopped four tiles up.
| Quantity | Before | Original (70 Hz) | Now | Before was off by | Now off by |
|---|---|---|---|---|---|
| Rise through one | passes | passes | passes | exact | exact |
| Fall onto one | lands | lands | lands | exact | exact |
| Down while standing on one | stays | stays | stays | exact | exact |
| Down through the fall onto one | lands | lands | lands | exact | exact |
| Jump held, climbing the ladder | 1 launch, row 29 | 7 launches, row 10.45 | row 10.51 | 4.5 tiles short | 2 px |
The last row is ow_hold, and it is the scenario the rule should be read off. Every other one here releases the jump key at a fixed tick, and a release that falls within a tick or two of a crossing is worth a whole jump's height — while this engine's scripted input runs about two ticks behind the original's throughout, which is exactly that margin. An earlier ow_jump let go at tick 50 with the original crossing at 49 and this engine at 52, and read as a 50 px error that was nothing of the kind. Both releases are now kept well clear of a crossing, and with the key never released at all the two climbs agree to 2 px over seven rungs.
Two things about the implementation are deliberate and neither is obvious.
Jazz2::_speed.Y < 0, and it is also what selects the ground-bound slope path in Jazz2::Actors::ActorBase::TryMoveSubstep(), so setting it while rising would have the slope search snap the player back down onto the platform they are passing through. The question is asked separately, by Jazz2::Actors::Player::IsOnLegacyOneWayFloor(), and only the relaunch reads it. That probe asks the same box the grounded test uses — just below the feet — twice, once with Downwards and once without, and a surface that is solid only in the first answer is a one-way and nothing else. Asking once would also catch the real floor a jump has just left, whose feet are still inside that box for a frame or two, and relaunch every frame: an unbounded rise from any ordinary standing jump.
And the relaunch runs from the end of the update rather than from Jazz2::Actors::Player::HandleJump(), because the original asks the question of the position its move ended on. There is no _jumpTime gate on it: the original's gaps between successive launches are 7, 9 and 12 ticks, and that cooldown is 10 frames — 11.7 ticks — so honouring it would swallow the first two. A cooldown is not what limits this in the original; the spacing of the platforms is.
Poles obey two different laws
- Vertical: additive.
15.625 + abs(entry), slope exactly 1.0, fitted over entry speeds 6.6 → 24.5. A pole hands back everything it was given plus a fixed bonus. - Horizontal: multiplicative, floored and clamped.
clamp(3 * abs(entry), 8, 20), fitted on 0 → 8, 4 → 12, 9.34 → 20, 16 → 20.
This is why poles compound, which is what exposed the earlier flat-value model as a regression. Measured heights against a single pole's 268.6 px:
| Setup | Height | Launch |
|---|---|---|
| 2 poles chained | 440.6 px (13.8 tiles) | −23.375 |
| 3 poles | 611.1 px (19.1 tiles) | −24.5 |
| 5 poles | 951.4 px (29.7 tiles) | −26.75 |
| Blue spring → 1 pole | 804.5 px (25.1 tiles) | −40.125 |
A pole takes ~140 ticks from grab to launch, which is why chains take so long to play out — and why the probe gives pole scenarios a much longer window than the rest.
The case that separates them is an entry speed of exactly zero — a player who drops onto a pole rather than running into one — and no scenario made of props ever produced it, because every one of them approaches a pole by moving towards it. It turned up the first time the probe was pointed at a hand-built spring-and-pole chain in the test level, where a fall lands on a horizontal pole with the horizontal speed already spent against a wall. Measured on both sides, three ways round (dropped in from the chain, and jumping straight up into an overhead pole facing each way):
| Entry | Original launch | Ours before | Ours now |
|---|---|---|---|
| 0 | −8.0 | 0.0 | −8.0 |
| 4 | +12.0 | +12.0 | +12.0 |
| 16 | +20.0 (clamped) | +20.0 | +20.0 |
So the launch never returns less than 8, and at zero it goes left — not in the direction the entry speed's sign suggests, and not the way the player is facing either: facing right, having arrived from the right, the original still launches left. Both halves matter, and they are one line each: Jazz2::
Without them the engine sticks on the pole for ever. A launch of zero leaves the player inside the pole's own tile; _lastPoleTime excludes that tile for 10 ticks, the player has not moved by then, and it re-grabs — one grab every 70 ticks, indefinitely. That is exactly what a player reported, and the same layout in the original runs the chain through without ever sticking.
What a pole launch decays at
Porting the chain scenarios turned up a difference that had been hiding in plain sight: the ascent off a pole was nearly three times too long, so a two-pole chain rose 772 px against the original's 440. Reading the original's trace tick by tick settles it — the launch is −22.25 and the rise decays by exactly 0.875 a tick over 25 consecutive ticks, which is the released rise gravity, even though jump is held for the whole scenario. Applied travel sits at 8.00 a tick throughout, the cap doing its usual job.
This engine used 0.375, the held rate. Since a chained pole is entered with whatever survives the previous ascent, the error compounded down the chain. The pole launch now hands the ascent over in the released state:
| Setup | Original | Before the ascent fixes | Now |
|---|---|---|---|
| 1 pole | 270.4 px | 480.7 | 270.6 |
| 2 poles | 440.6 px | 772.6 | 442.7 |
| 3 poles | 611.1 px | 932.5 | 614.8 |
| 5 poles | 951.4 px | 529.2 | 1009.0 (+6.1%) |
| Blue spring → 1 pole | 804.5 px | 539.4 | 806.3 |
The spring-fed case is the one the arrival fix above settles — it was a third short and is now within 0.2%. The five-pole chain is the residual: every individual launch matches, and the entry speeds converge to 0.85% by the fifth pole, so the 6.1% is accumulated grab timing rather than a wrong figure. Note the earlier 1011.2 px reference for it was contaminated by a pole-attached start; 951.4 is the clean one.
And it is not even per launch type: the same pole gives 0.875 reached by jumping and 0.375 reached off a spring, so what a pole hands back depends on how the player arrived at it. Four sources measured, three answers, one of them conditional — there is no rule here to infer, and every attempt to state one has been wrong. Read the decay off the trace tick by tick; the differences are exact multiples of 0.125 and show up immediately, while the height they produce takes 20 ticks to diverge visibly.
The clean reference is 951.4 px, not the 1011.2 that had been quoted. And once both sides are read launch by launch rather than as a total height, the chain turns out to agree closely — the earlier "17% shortfall" was the same contamination on this engine's side, in a trace captured before its own settle check was tightened, where the five-pole scenario began 98.5 px above the ground:
| Pole | Original launch | Ours | Difference |
|---|---|---|---|
| 1 | −22.250 | −21.246 | 1.004 |
| 2 | −23.375 | −22.565 | 0.810 |
| 3 | −24.500 | −23.884 | 0.616 |
| 4 | −25.625 | −25.203 | 0.422 |
| 5 | −26.750 | −26.522 | 0.228 |
The original's launches grow by exactly +1.125 a pole; ours by +1.319, so the two converge and by the fifth pole they are 0.85% apart. The pole-to-pole spacing is identical at 160 px, and the final free ascent measures 205 px here against 204 there, decaying at the same 0.875 with the same 8 px applied cap. So the law, the bonus and the ascent are all right.
What is left is when the pole is grabbed, and it is visible on the very first one. The original jumps at −10, rises with the held gravity of 0.375 and the usual 8 px applied cap, and at the tick before the grab it is at y = 1253.75 doing −6.625; the next tick it is snapped to y = 1231.00, the pole tile's centre, and 6.625 is the entry speed that goes into the launch. This engine grabs about a tick later — 5.7 px higher up the pole and at 5.62 rather than 6.625 — so a single pole launches 4.5% weak. Each further link then recovers a little of it, which is why a five-chain ends up much closer than one pole does. Correcting it means moving when the pole event is detected relative to the player's hitbox, which is shared event-detection geometry rather than a movement constant.
Push speed was frame-rate dependent
3.8.0 computed pushSpeedX * 1.2 * timeMult — scaling a speed by the frame delta. That made pushing 2.5× faster at 24 FPS than at 60. Measured: a flat 0.375, player and object together, and Run makes no difference (walking into a box and dashing into a rock push at exactly the same rate). This was the one genuine frame-rate bug the whole exercise turned up.
Special moves
| Quantity | 3.8.0 | Original (70 Hz) | Now (60 Hz) | How far 3.8.0 was off | How far Now is off |
|---|---|---|---|---|---|
| Buttstomp descent | 10.967 | 10.0 (all three characters) | 11.6667 | −6.0% | exact |
| Buttstomp descent, held | (accelerated) | governed: 10.0 every tick | 11.6667 every tick | ran away to the fall cap | exact |
| Buttstomp sideways drift | 0.2 * movement, ×2.6 Run | 0.18311, ×2 Run | 0.21362 / 0.42725 | −6.4%, Run ×1.3 too strong | exact |
| Copter descent | 1.5 | 1.0 (Jazz = Lori) | 1.16667 | +28.6% | exact |
| Copter horizontal | (unrestricted) | walk accel and walk cap | same | (uncapped) | exact |
| Uppercut launch | −2.4127 + external force −1.4476 | −6.59375 of travel | −7.6927 | wrong mechanism | exact |
| Uppercut gravity while driving | (ordinary rise gravity) | 0.03125 (2048/65536) | 0.042535 | ~12× too heavy | exact |
| Uppercut driven length | (until the rise decayed to −2) | 30 ticks | 25.7 frames | (absent) | exact |
| Uppercut height (end to end) | 189.4 px | 228.3 px | 228.5 px | −16.4% | +0.1% |
| Spaz double jump launch | −0.6 + internal force −1.15 | −8.0 | −9.3333 | wrong mechanism | exact |
| Spaz double jump horizontal | clamp(x * 0.4, -1, 1) | 0 | 0 | damped, not dropped | exact |
| Spaz double jump window | (none) | falling, ≤ 29 ticks after the release | ≤ 25.7 frames | (absent) | exact |
| Spaz sidekick speed | 14.4 | 16.0 | 18.6667 | −22.9% | exact |
| Spaz sidekick driven distance | ~1728 px (timeout 120) | 440 px, then coasts to 504 | 440 px, coasts to 503.2 | +243% | −0.3% |
| Lori kick speed | 9.3 flat | (n/2)² ramp, peak 42.25 | ramp, peak 49.29 | wrong shape | exact |
| Lori kick distance | ~372 px (timeout 40) | 204.75 px | 204.8 px | +82% | +0.02% |
| Lori kick period | (input-paced) | 45 ticks | 44–45 ticks | (absent) | −2% to 0% |
| Special move asked for mid-slide | full uppercut/sidekick | refused, an 8 px hop then a buttstomp | refused, 3.5 px hop then a buttstomp | 201 px of rise, 700 px of kick | hop −56% |
| Shot fired while looking up, sliding | fires | fires | fires | (agrees) | (agrees) |
| Kick against a pushable, Lori | (stops dead) | 4.5 px per kick, 12 ticks | 4.7 px, 12 ticks | no push at all | +4% |
| Kick against a pushable, Spaz | (stops dead) | 19.1 px, 51 ticks | 21.2 px | no push at all | +11% |
| Pushed object's own rate | 0.4286 (a per-frame 0.5) | 0.375, the player's own | 0.375 | +14% | exact |
| RF blast throw speed | (one-off force 4) | 8.0 | 9.3333 | wrong mechanism | exact |
| RF blast hold before friction | (none) | 30 ticks | 25.7 frames | (absent) | exact |
| RF blast upward kick, airborne | (none) | 12.0 | 14.0 | (absent) | exact |
| RF blast reach | ~52 px | blasts at 36, not at 48 | 32 px | too generous | fitted |
| RF blast travel (end to end) | (a few px) | ~302 px | 304.9 px | −99% | +1.0% |
Buttstomp drift bypasses the speed entirely
The original moves the position directly and leaves xSpeed at zero for the whole stomp. There is also a distinct hold phase at the top (ys = 0.0625 for ~30 ticks) before the descent begins.
What the two games do with a powerup monitor
Written once the test level grew a section holding them — two weapon monitors and a morph monitor, none of which _pt contained before, which is the whole reason these had never been measured rather than any difficulty in measuring them.
Most of it agrees, which is worth stating as plainly as a gap would be:
- Walking into a monitor pushes it along the floor in both games rather than breaking it or collecting anything.
pu_walkmoves the weapon monitor 289.6 px here against 290.6 there — one pixel over a push of nine tiles. - A buttstomp landing on one breaks it in neither. The player ends up standing on top of it in both.
- An uppercut beside one does nothing to it in either.
Two differences, both about the object rather than the player:
- A monitor at rest sits at
y1968.9 here and 1975.0 there, a consistent 6 px higher. The floor's surface is at 1984, so both are resting on it and the gap is in how tall the object is taken to be. - A monitor that spawns above the player stays there in the original.
mb_stompputs the player directly under the morph box's tile: the original's box holds aty1903 — the row it was authored on, two tiles up — for the whole scenario, resting on the player's head, while the player stands at 1970. Ours drops to 1968.9 and ends up overlapping the player, who is at 1968.5. That is the closest thing the harness has found to the reported "morph box floating", and it is a real difference; whether it is the reported one is not established.
A warp hands control back almost at once
The original lands on tick 73, shows one frame of its warp-out pose on 74, and is walking again on 75. Ours waited for the whole Jazz2::
The fix is not to shorten the animation but to stop control waiting on it: the pose still plays and its callback still runs, and a timeout armed alongside simply gets there first. Everything that callback does is idempotent, so the two cannot disagree. Invulnerability and gravity come back with control rather than after it, because the original is plainly subject to both the moment it starts moving. See Jazz2::
One ordering trap, and it cost a build to find: Jazz2::removeControl zeroes _controllableTimeout on its way past, so a timeout armed before the transition is wiped without trace. It has to be armed after.
A frozen exit is left alone — there control is meant to stay away, and Freeze() in the callback is what takes it. The checkpoint respawn goes through the same path and so also returns control promptly, which is deliberate.
Result: 5 ticks against the original's 3, the remainder being frame quantisation (two original ticks is 1.7 of ours). End-to-end travel on wp_walk went from 305 px adrift to 90.
A special move wants the player stopped; the crouch does not
These two rules are easy to read as one and they are not. The crouch engages the moment no direction is held, carrying whatever speed is left — that is what makes a run end in a slide, and it is why the crouched gunspot can put shots out low and moving. The move the crouch leads to does not follow while that slide is still running. Asking for one mid-slide gets a buttstomp on the spot instead, which is exactly how it was reported and exactly what the traces show.
Measured on all three characters with Down held through a dash and Jump four ticks later. The original's specialMove never leaves 0, and what happens instead is an ordinary jump: it launches at −13.08 with 12.33 px/tick still under it, which is Jazz2::
We fired the full move: 201 px of uppercut for Jazz and a 700 px sidekick for Spaz, against 48 px of travel and an 8 px hop. All this needed was to hand over to the standard jump and re-arm the Down edge — _wasDownPressed would otherwise swallow the stomp, having been set when the crouch engaged four ticks earlier and never cleared, because Down is held throughout.
The boundary is Jazz2::Actors::Player::StopSettleSpeed rather than a literal zero. The original tests its own speed against exactly 0, but ours decays on a 60 Hz curve and reaches 0 on a different frame, so a strict comparison would decide this on frame timing across a band about a pixel per tick wide.
One residual: the hop comes out at 3.5 px against 8, and the travel at 39.6 against 48. The sequence is right — crouch, jump, stomp, hold, descend, land, crouch — and the descent matches at 10 px/tick; what is short is the single tick of rise between the jump and the stomp taking hold.
Spaz's double jump
A straight speed assignment, exactly like the first jump. 3.8.0's small speed plus a sustained internal force is why it felt far too weak. Height is then governed by how long the key is held, through the same released-gravity switch as an ordinary jump.
The horizontal speed is thrown away: a dashing double jump leaves at zero and has to build its speed up again from the air acceleration, which makes the move a way to stop as well as a way to climb. Measured directly — sp_dj_a_dash reads xs = 16.0000 the tick before the press and 0.3662, exactly one dash acceleration step, the tick after; the walking cases the same with a 0.1831 step. 3.8.0 only damped it (clamp(_speed.X * 0.4, -1, 1)) and this page previously recorded the speed as carrying over untouched, because every earlier measurement of it was a travel to a wall that both games reach either way.
There is a trigger window, and it is a timer, not a band of fall speeds. Two conditions, both necessary:
The player must be falling, read from the vertical speed at the start of the frame rather than the live one — the original applies its gravity after the test, so a press at the exact apex reads
ys= 0.0000 there and is refused, where the live value has already crossed zero and accepts it.- The press must land within Jazz2::
Actors:: Player:: LegacyDoubleJumpWindowTime — 30 ticks — of the jump key being let go. Counted the way the original counts it: set on the tick of the release, decremented at the top of every tick after it, tested for "still above zero", so a press 29 ticks later is the last one accepted.
| First press released at | Delays that fire | Delays that do nothing | Scenarios |
|---|---|---|---|
| tick 50 | 15, 25, 29 | 30, 35 | sp_dj_r50_*, sp_dj_hold and the direction family |
| tick 60 | 28 | 30 | sp_dj_r60_* |
| tick 72 | 5, 10, 20, 22, 25, 28, 29 | 30, 40 | sp_dj_d*, sp_dj_a_* |
| tick 85 | 10, 20, 25 | — | sp_dj_r85_* |
sp_dj_r60_d28 is accepted at a fall speed of 3.00 while sp_dj_hold is refused at the same 3.00, differing only in a delay of 28 against 35 — so the speed cannot be what decides it. The release-at-85 row then retires the speed cap outright: it holds the key well past the apex, so the timer opens twelve ticks into the fall, and delays of 20 and 25 fire at about 4.0 and 4.6 px/tick. Height above ground (accepts at 81 px, refuses at 85) and time since the apex (accepts at 27, refuses at 20) are ruled out the same way.
Watch for this in the probe itself: a scenario that holds jump to its last tick arms the timer when the harness clears the input at the reset, and the next scenario inherits an open window. A first version of those two pressed at ticks 5 and 20 and both games double jumped off exactly that.
Pinball paddles and bumpers
Gating it exposed a second one: the paddle had ActorState::IsSolidObject cleared, so with the launch removed the player simply fell through it. That never showed while it launched on contact, because they were always thrown clear before they could drop past.
So the paddle carries the player itself: each frame it places whoever is over it on surfaceY = pos.Y + 0.3 * (distance - 16) and takes their vertical speed out. That reproduces the slope to within a pixel across the whole paddle, and it is also how the original's from below behaviour falls out for free — driven up into a paddle the player is not blocked at the underside but appears on the surface in one tick, 27 px above where they were, and stays there. The carry pauses while a launch is in flight, or it would snap the player straight back down onto the surface on the very next frame and undo it.
The launch is a straight speed assignment, linear in the distance from the mounted end on both axes, with a floor near the mount. Measured eight pixels at a time across both paddles, in the original's units:
| Distance from mount | 24 | 32 | 40 | 48 | 56 | 64 |
|---|---|---|---|---|---|---|
Right paddle, ys | −7 | −15 | −23 | −31 | −39 | off the end |
Left paddle, ys | −9 | −17 | −25 | −33 | −41 | off the end |
Right paddle, xs | −0.4688 | −0.7188 | −0.9688 | −1.2188 | −1.4688 | — |
Left paddle, xs | +0.5313 | +0.7813 | +1.0313 | +1.2813 | +1.5313 | — |
So ys = -max(4, d - 16) and xs = ±max(0.5, (d - 8) / 32), directed away from the mount. The two paddles come out a constant two units apart vertically and a sixteenth apart horizontally — a mirrored pivot landing on a different pixel rather than a different rule — so one mirrored formula sits within a unit of either, which is where ours now is at every measured distance.
The player is held in the sucker tube's curled-up ball the whole time they are standing on one, not just during the launch — the original's trace shows that pose several ticks before the jump press that fires it. It is chosen in UpdateAnimation() alongside the lift poses, because anything the paddle assigns directly is overwritten on the next frame.
speed = (playerPos - bumperPos + 1) / 4 // both axes
Every hit measured fits it to four decimal places: a drop through the centre caught 36 px up leaves at −8.75; one 16 px to the side caught 28 px up leaves at (−3.75, −6.75); an approach from below caught 47.25 px down is driven back down at +12.0625; and a later bounce at an offset of (0.134, −38.12) leaves at (0.2834, −9.2803). 3.8.0 used a normalised radial impulse times a blind 15, which is the wrong shape as well as 35% weak — normalising throws the distance away, and the distance is what the original scales with.
That +1 is why a player ever escapes a bumper. A drop through the exact centre is otherwise a perfect fixed point: no horizontal component, straight back up, for ever. That is what ours did — thirteen bounces on the spot in 800 ticks and still going — while the original's offset grows every bounce, because the rule is linear in it, and throws the player clear after about eight. Ours now escapes in six.
What is left is where the catch happens, which now sets the strength: the original catches 36 px above the centre and ours 30, worth −8.75 against −7.25. Much of that is which tick the overlap first lands on, since the player is falling about 8 px a tick there, and the rest is the trigger being a radius where the original's is not quite one — it catches at 36 px directly above but only 28 px up when 16 px to the side. Pinning it needs a scenario that drifts into a bumper slowly rather than dropping past it.
Hitting your head on a ceiling
Measured against a staircase of 2-tile-wide bays over one floor, clearing 2, 3, 4, 5 and 6 tiles. A standing jump rises ~132 px, so the first three are struck and the last two are cleared — which is the whole interesting range in one place. The strike stops the ascent where the head meets the tile and the fall begins from there; nothing else about the arc changes.
| Clearance | Original rise | Ours | Struck |
|---|---|---|---|
| 2 tiles | 40.0 px | 40.7 px | yes |
| 3 tiles | 70.1 px | 72.7 px | yes |
| 4 tiles | 104.2 px | 104.6 px | yes |
| 5 tiles | 132.0 px | 128.2 px | clears |
| 6 tiles | 132.0 px | 128.2 px | clears |
The two that clear differ by the ordinary standing-jump residual (132 against 128, the same figure sp_jazz_dj shows) rather than anything to do with the ceiling. Walking and dashing through the section agree as well — apex 345.1 against 347.4 walking, 366.5 against 366.3 dashing — read heading west, so the ceiling drops ahead of the player: going east runs out of the section entirely and into a 17-tile wall, which the player then climbs.
Spaz's double jump in the same bays agrees at 2, 3 and 4 tiles and diverged at 5 and 6, which is what uncovered the trigger window in the first place — see Spaz's double jump.
Lori's kick accelerates
Its speed is exactly (n/2)² px per original tick — 6.25, 9, 12.25, 16, 20.25, 25, 30.25, 36, 42.25 — a quadratic ramp over 13 ticks summing to 204.75 px. It is also the only move exempt from the applied cap (its last tick alone travels 42 px). Spaz's is the opposite: a flat 16 while he visibly travels 8, the cap doing its job. Matching her distance while starting at the 42.25 peak covered the same ground in a third of the time, which is what "endpoints hide a wrong curve" means in practice.
She also kicks over and over while Down and Jump are worked, unlike Spaz. The repeats come 45 ticks apart (kicks logged at t48, 93, 138, 183, …) against a jump press every 15, so at first this looked like a cadence the game itself paced. It is not: 45 ticks is simply how long the move runs. Holding control for its own length and needing the crouch back before the next press counts produces exactly that spacing — ours lands at t42, 86, 131, 176, gaps of 44/45/45. An explicit cooldown on top of that was tried and made it worse, stretching the gaps to 75, because arming it in the launch callback (a third of the way in, at the end of the wind-up) pushed every repeat a whole input period further out.
Spaz's sidekick drives, then coasts
His 504 px total was first read as a decaying dash, 81 ticks of falling speed, and written off as unreproducible. That was a misreading: the burst detector counting those 81 ticks had swallowed the wind-up. He actually holds a flat 16.0 (8.00 px of travel per tick, the applied cap doing its job) from t56 to t110, then drops to about 4 and decays at ~0.122/tick, which is the plain walk deceleration. In other words the dash ends after a fixed driven distance and the ordinary walk model takes over.
So the constant is the driven part only, 440 px, and the coast that follows is ordinary ground physics rather than anything special. Terminating the move snaps him to the walk cap instead of zeroing his speed (Lori zeroes, because her ramp ends at 42.25 and coasting from there would carry her tiles too far), and the countdown ends the move on the same tick it runs out rather than a frame later. Total travel comes out at 503.2 px against 504.5, 0.3% off, and he coasts through 4.17 → 3.34 → 2.84 → 1.67 → 0.84 → 0 exactly as the original does.
With Run held he does not coast from the walk cap: sp_side_run leaves the kick at 15.88 px/tick and decays at a flat 0.4273, the ordinary dash brake, for 664 px against the snap's 456 — and with a direction held as well he simply carries on as a dash at 16 for ever. That was first written up as "the snap belongs
to letting go of Run", which is not what it is: the snap has two targets, the walk cap and 16, and his kick leaves at 15.88, so the second one is inert. The accelerating belt is where the difference shows, because its carry reaches 18 and 24. All three sites now clamp to Jazz2::
A kick shoves a pushable, at walking pace
Both characters' kicks push a pushable rock in the original and neither did here. The push is not a special one: it is the ordinary Jazz2::
Two gates kept it out, and neither could ever have opened: _controllable is false for the whole of a special move, and _isActivelyPushing wants a direction key that a kick does not use. What is allowed instead is the kick while it still has budget — what remains of a move after that is a recovery the player stands still through, and it shoves nothing. That one condition is the entire difference between the two characters:
- Lori spends her 204.75 px in twelve ticks and then waits out twenty-odd of recovery, so each kick nudges the rock 4.5 px and stops. Over a held-down run of 22 kicks that is 96 px.
- Spaz runs out exactly as his move ends, so he pushes the whole time: 4 ticks of drive and then 51 of pushing, 19 px in one kick. His 440 px at the capped 8 px/tick is 55 ticks, and 4 + 51 is 55.
A blocked kick therefore spends its budget on what it meant to travel rather than on the crawl it manages. Billing it for the crawl instead is not a small error — the budget outlasts any plausible push, so a kick that meets a rock never ends at all.
Three things then had to agree that had never been compared with each other:
- The object coasts for Jazz2::
Actors:: SolidObjectBase:: PushDecayTime after the last shove, which is what keeps an ordinary push looking smooth across frames where the contact probe misses. The original has no such coast; its rock stops on the same tick the kick does. Left in, it added exactly those 6 frames — 7 original ticks — to every kick, 19 ticks of travel where there should be 12. - The object's own rate was a per-frame
0.5, which is 0.4286 px per original tick against the player's measured 0.375. The two are supposed to move as one, so the rock crept ahead of whoever was pushing it. This one was never a kick bug at all — it is every push, and it went unnoticed because no scenario had ever compared the object's position with the player's. - Her ramp summed frame-wise does not reach her budget in the same elapsed time: 60 Hz needs twelve frames where 70 Hz needs twelve ticks, which is fourteen. The distance is what shows in an unobstructed kick and is already right, so the ramp stands there; the duration only shows when something holds the kick up, and there the budget drains at the flat rate the two numbers imply — Jazz2::
Actors:: Player:: LegacyLoriKickPushDrain.
With all three, 22 bursts against 22, starting on the same ticks (76, 111, 146, …, 741, 776 on both sides), 12-tick bursts of 4.7 px against 4.5, and the rock ends the run at 2891 against 2882.
One thing here is deliberately not reproduced. The original drives the player into the object — Spaz lunges 21 px inside it and stays there, and Lori ends a long kick 4.7 px from the rock's own centre, which is inside it too. That is a collision bug in the original rather than anything the movement is supposed to feel like, so our collision goes on stopping the player outside. It is why the single-kick scenarios still come up a few tiles short on distance while the push itself matches, and that gap is expected: do not close it.
Bouncing off a wall onto a ledge
Dash at the wall closing the right end of the test level's upper floor, jump, and turn back in mid-air to land on the platform above and to the left. Everything this needs is already covered by the air-turn and launch-bonus constants, so it is a check on all of them at once rather than a mechanic of its own — and a demanding one, because the platform is six tiles up and only a dashing jump reaches it.
How far out the jump happens decides it. Pressed flat against the wall the player has no horizontal speed left, the launch gets no speed bonus, and the jump reaches 132 px instead of 209 — not even close. Both games agree on all of that:
| Jump | Original peak | Ours | Original outcome | Ours |
|---|---|---|---|---|
| 8 tiles out | 706.3 | 707.1 | over the platform, falls past it | same |
| 5 tiles out | 709.9 | 708.6 | lands, then walks off the edge | lands, stays |
| 2 tiles out | 710.3 | 706.9 | lands on the platform | lands on the platform |
| flat against the wall | 782.5 | 784.5 | no speed bonus, falls short | same |
| walking, near the wall | 761.8 | 764.1 | falls short | same |
The one disagreement is the 5-tile jump, where the two land within 2 px of each other on the platform's left edge and then differ on whether the player walks off it — a knife-edge outcome rather than a different arc.
Jazz's uppercut is nearly weightless
The move resisted several attempts at a model, because ys never reads below −7.5 and yet it climbs 228 px — so it looked like neither an impulse nor a constant force. It is an impulse; what hides that is the gravity. Three clean phases:
- A wind-up of about 14 ticks during which the player does not move at all.
- A driven climb of exactly 30 ticks under a gravity of
2048/65536= 0.03125 — a twelfth of the ordinary rise gravity, which keeps the rise almost linear for its whole length. - Then ordinary gravity, from the tick the move ends, with the rise still at about 5.7 to spend.
3.8.0 instead gave it a small speed plus a sustained external force and ended it once the rise had decayed to −2, which came out 16% short. With the measured model the height is 228.5 px against 228.3.
One oddity, recorded rather than resolved: the original reports ys = −7.5 decaying to −6.59 while the position advances 6.59/tick decaying to 5.69 — a constant offset of about 0.84 that the applied rise cap cannot explain, since every value involved is already under it. The constant is taken from the travel, because that is what the player sees and nothing reads this move's vertical speed. See Jazz2::
Bouncing off an enemy you buttstomped
Measured against the test level's own tube turtle, dropped onto from 420 px up. The stomp arrives at its governed 10 px/tick and leaves at a flat −13, then decays at exactly 0.875 a tick with travel held to the usual 8 px cap:
| Quantity | 3.8.0 | Original (70 Hz) | Now (60 Hz) | How far 3.8.0 was off | How far Now is off |
|---|---|---|---|---|---|
| Bounce speed | -0.6 * impact | 13.0 flat | 15.1667 | wrong mechanism | exact |
| Bounce decay | (held rate) | 0.875 (released) | 1.19097 | wrong rate | exact |
| Rise after the bounce | ~48 px | 78.4 px | 82.2 px | −39% | +4.8% |
The bounce is faster than the fall that caused it, which is what rules out the old model on its own: no factor applied to a 10 px/tick impact yields 13, and since the descent is governed at a constant 10 the impact never varies anyway. The remaining 4.8% on the rise is discretisation, not a constant — ours lands on the analytic 82.3 px while the original's discrete stepping loses about four pixels of it.
Two things had to be got right besides the number:
- The enemy has to be one the level contains. An enemy spawned with
jjAddObjectis inert in the original: the stomp descends straight through a tube turtle sitting exactly where it was aimed and lands on the floor beyond it, with the turtle still alive afterwards. This is the same limitation that defeated the pushable scenarios, and the same fix — both probes now locate the level's own object by scanning the event map at prime. - The ascent decays at the released rate. Like a pole launch and unlike a spring, so
_jumpReleasedis set here too. Three launch sources, three different answers, and the only way to know which is to read the trace — see What a pole launch decays at.
Blasting yourself off a wall with an RF shot
Firing an RF into a nearby wall throws the player off it, and the throw is not the ordinary knockback: the horizontal speed snaps to exactly 8.0 — the applied-movement cap, so every pixel of it is travelled — and is then held for 30 ticks with friction and the direction keys both suppressed, even standing on the ground, where nothing else in the game slides at a constant speed. When the hold ends the speed snaps to the walk cap and decelerates at the walking rate, exactly as Spaz's sidekick does, for about 302 px in all. Off the ground the blast also assigns ys = −12, and the rise that follows decays at the released-jump rate even with jump still held. See Jazz2::
3.8.0 had a one-off external force of 4 here, which moved the player a few pixels. Ours now reproduces the whole shape, 304.9 px against ~302. Three things had to be got right for it to work at all:
- The direction cannot come from the positions. Point blank the shot comes to rest within a pixel of the player and can easily end up marginally behind them, which threw the player into the wall instead of off it. Which way the shot was travelling is what decides it at that range.
- The reach is one tile. Parked 24 and 36 px from the wall the original throws the player; parked 48 and 64 px away nothing happens. The engine's own search tests the player's box rather than their centre, which reached about 52 px and blasted in cases the original ignores, so the distance is now re-tested against Jazz2::
Actors:: Player:: LegacyRFBlastReach. - Fire from a standstill to measure any of this. Fired while dashing the shot inherits the player's speed and reaches the wall long before they do, so the distance at detonation is not the distance that was set up — a sweep done that way looks like a reach measurement and is not one.
The reach was later re-run at whole tiles, one to six, with the ammo count logged alongside so a trace showing no blast could be told from one where the shot was never fired. It holds up exactly:
| Distance | Original push | Ours | Peak speed, original | Ours |
|---|---|---|---|---|
| 1 tile (32 px) | −299.5 px | −306.2 px | 560.2 px/s | 560.0 px/s |
| 2 to 6 tiles | nothing | nothing | — | — |
So the cut-off is real and sits inside the first tile, the speed is exact, and the travel is 2.2% long.
The airborne kick is exact too, which took a tick-by-tick read to establish because the totals disagree badly. Fired point blank from a jump (wb_rf_j0) the original peaks 168.6 px up against our 101.4 — but every constant in it matches: the blast assigns ys = −12.0 there (−14.0 here) and the ascent then decays at 0.875 a tick (1.1921 here), both read off the two traces directly, and the jump before it decays at the held 0.375 on both sides. The ammo column pins the firing to tick 169 on both sides as well.
What differed is when the shot goes off: ours detonated on the very tick it was fired, the original's takes nine more ticks. So the blast replaced the ascent nine ticks earlier here, and the 67 px was the jump the player did not get to finish. That is shot flight time, not knockback, and it lives in RFShot rather than anywhere in this document's model — which is why it sat here as a note for as long as it did.
Features the original does not have must not read the legacy constants
The ledge climb is this engine's own addition. It rises on one assigned speed with gravity off for the whole transition, so the height is simply that speed times the animation's length. The old code wrote -1.4 and then had a frame of gravity added to it by the movement tail, which is why the climb was frame-rate dependent — at 24 FPS barely a third of the 60 Hz rise survived.
Folding that frame in fixed the frame-rate dependency but introduced a worse bug, because it was folded in as GetGravityModifier() rather than as the level gravity. In non-Reforged mode that returns the original's rise gravity, which is three to five times heavier than the engine's:
| Rise folded in | Value | Climb speed | Height |
|---|---|---|---|
| Intended (level gravity) | 0.24 | −1.16 | 44 px |
GetGravityModifier(), jump held | 0.51042 | −0.890 | 34 px |
GetGravityModifier(), jump released | 1.19097 | −0.209 | 8 px |
The released case is the one that mattered: _jumpReleased is set by any approach that involved letting go of jump, which is nearly all of them, so in practice the climb lifted the player 8 px instead of 44 and never reached the ledge. It now reads the level gravity, giving −1.16 in non-Reforged and −1.10 in Reforged, both exactly what they were before this work, and 44 px at 24, 60 and 144 FPS alike.
The camera
The camera is not movement, but it is measured the same way and for the same reason: both probes log camx / camy, the camera's offset from the player, on every scenario, so the whole existing matrix doubles as camera coverage and no cm_* family was needed. It is gated on its own preference, Jazz2::EnableReforgedGameplay — the camera is local and purely visual, so a client may reasonably want the original's camera in a session whose physics a server decides. A configuration written before the split derives it from the gameplay setting.
| Quantity | Reforged | Original (70 Hz) | Now (60 Hz) | How far Reforged is off |
|---|---|---|---|---|
| Horizontal lead, walking | 80.3 px | 28.0 | 27.6–28.0 | +187% |
| Horizontal lead, dashing | 143.6 px | 120.0 | 119.0–119.4 | +20% |
| Approach to the lead | eased, 0.04/frame | 1 px/tick, flat | 1 px/tick | wrong shape |
| Final approach | (same ease) | half the gap per tick | half per tick | wrong shape |
| Vertical lead | ±24 px deadzone | none | none | wrong mechanism |
| Lead during a sidekick | 117 px and climbing | 0 (−8 of lag) | 0 | (invented) |
The residual half-pixel on both leads and the ~0.8 px on camy are the whole-pixel snapping that keeps the player crisp (std::round on the lead, std::floor on the camera), not the model.
The lead is aimed by the input, not by the speed
This is the finding the other three follow from. Reforged computes its lead from GetSpeed(); the original aims it at whichever direction is being held. Both readings fit an ordinary run, and two scenarios separate them:
g_dash_relreleases the direction while the player is still coasting at 9.6 px/tick, and the original's lead collapses to zero anyway.g_dash_relrunreleases only Run, keeping the direction. The lead keeps growing past the walk value to 65 px and only starts closing once Jazz2::Actors::Player::UpdateDashState() clamps the speed back to the walk cap — so the size of the target does follow the dash, even though its existence follows the key.
Reading the speed is what produces the forward lurch at the start of a sidekick, which was reported separately and is not a separate defect: a sidekick carries 16 px/tick with nothing pressed, so the original pans not at all — sp_spaz_side_rel reads a flat -8.00 for the whole move, the camera sitting one tick's travel behind the player — while reading the speed runs the lead out to 117 px and back. The same rule covers every other move that carries speed the player did not ask for.
It has to be read as travel, not as a speed, because the two disagree at exactly this point. The original's xs is 0.0000 against the wall, but ours keeps a phantom 0.4989: the collision stops the movement without clearing the speed. Any threshold low enough to leave a genuine crawl alone — the lead is already growing at 0.1831 px/tick on the tick a run starts — would be far too low to catch that. What separates the two cases is not how fast the player is going but whether they are moving at all.
Note the target follows the dash state rather than the speed continuously: the two measured points are the two speed caps, and Jazz2::Actors::Player::IsDashActive() is exactly the window in which the speed is the dash cap, since the grace that outlives the Run key and the clamp back to the walk cap end together by construction.
How the lead gets there
Once per tick, in the original's own units:
step = (target - lead) * 0.5; // half the remaining gap... lead += clamp(step, -1.0, +1.0); // ...but never more than a pixel
The 0.5 is read straight off the end of the approach, where the steps go 1.000, 0.500, 0.250, 0.125, 0.0625 — halving exactly. The clamp is what turns the first and longest part into a straight 1 px/tick ramp, and it is active from the first tick of a walk as well as of a dash, which is how the target is known to be a flat distance rather than anything proportional to the speed.
Vertically the camera simply is the player
Measured through a standing jump, the original's camy reaches +7.75 px on the tick of the launch and is back to zero by the apex, tracking the player's own Y to within one tick of its vertical movement for the whole arc. There is no deadzone, no lead and nothing to hold: the vertical camera is the player's position.
Reforged's ±24 px deadzone is a different mechanism rather than a mistuned one, and it does not merely desensitise the follow — it holds a stale offset, because an offset acquired anywhere inside the band is never corrected below Jazz2::Rendering::PlayerViewport::VerticalRecenterThreshold. A probe scenario begins with 21 px of leftover offset from the one before it and keeps it, and through a jump the offset swings from +21 to −22. That is the opposite of the "camera is always vertically fixed to the player" it was meant to produce, and the original is the thing that actually is fixed.
The run-in-place rev-up
Tapping Run on the spot winds the player up and then launches them into a run. The engine had no such mechanic at all, and unlike everything else on this page it is live in both modes — it is a feature that was missing, not a difference between two movement models.
| Quantity | Original (70 Hz) | Now (60 Hz) | How far Now is off |
|---|---|---|---|
| Launch speed, full charge | 16.0 (the dash cap exactly) | 18.6667 | exact |
| Launch speed, minimum | ~7.0 | 8.1667 | within ~0.5 |
| Time to full charge | ~90 ticks | 77 frames | ±1 tick on the launch |
| Launch delay after the last tap | ~20 ticks | 18 frames | 2 ticks |
| Tap gap that sustains the wind-up | ≤ ~16 ticks | ≤ 21 ticks | (see gaps) |
| Hold before the speed drops | **~320 ticks**, then a single-tick snap to the walk cap | 274 frames | exact |
The wind-up is entered on a charge, not on a tap count. Four recordings were needed to establish that, and each of the first three suggested a count that the next one contradicted — 3 taps, then 4 to 5 depending on cadence, then 2. The shape that fits all of them at once is an accumulator: a press adds a fixed amount, holding it adds more up to a cap per press, and it bleeds away while the key is up.
| Observation | What the accumulator says |
|---|---|
| Two taps never start it, however spaced | Two presses reach 8.9 against a threshold of 14 |
| Three do, if the last is held longer | Three reach 12.6; holding the last adds up to 4 more |
| Four do without holding | Four reach 16.4 |
| Gaps of ~20 ticks or more never start it | The decay outruns the gain between presses |
| Tapping faster reaches it sooner | Less decay between presses |
| Holding Run alone never starts it | One press is capped at 4 + 4 = 8 |
| There is a grace in which a tap resumes a wind-up | The charge has not decayed to nothing yet |
A separate timer decides the launch, refreshed by each press and by holding, and running only once the wind-up is up. That is what reconciles a burst whose gaps ran 21–28 ticks without ever launching and which then launched 21 ticks after its last tap: the early gaps had no wind-up to interrupt, because gaps that wide never build one — each press only flashes the start pose before it falls back to idle.
Charge follows time, not taps. 15 taps over 75 ticks launched at 15.87; 12 taps over 106 ticks reached the full 16.0.
The launch is not steerable. It goes in the direction the player faces, a direction key held at the moment of launch does not change it, and the recording shows Right held for dozens of ticks while the player continues to travel left at 16 without the slightest effect. The hold then ends in a single-tick snap to the walk cap, which is the dash grace's behaviour rather than ordinary friction, so the launch arms _dashGraceLeft as well as _keepRunningTime.
Replaying the recording
The twenty tap offsets of one recorded burst are replayed verbatim by the rt_rec scenario, each press held the five ticks the recording shows, which is what makes the two traces comparable tick for tick rather than merely similar in shape:
| Quantity | Original | Ours |
|---|---|---|
| Launch, ticks after the first tap | 227 | 229 |
| Launch speed | 16.0000 | 18.6667 (= 16.0 converted) |
| Animation order | 53 → 54 → 55 → 60 | RevUpStart → RevUp → RevUpEnd → Dash |
The three animations are the original's own revup_start / revup / revup_end, which were already exported for all three characters and simply had no Jazz2::
Sliding to a halt is a chain of three, and only one link is a speed
Letting go of the direction key leaves the player sliding, and the original shows three different animations on the way down. This engine had a chain of two, fired on the change into Jazz2::
walk_stop— the skid, played once for its own 12 ticks, from any speed.dash_stop— looping, for as long as the speed stays at or above Jazz2::Actors::Player::LegacyStopWalkSpeed (1 px/tick). Entered only if the speed is still that high when the skid ends.run_stop— played once, 32 ticks, and standing follows it — even though the player reached zero speed about 24 ticks into it.
Four traces pin it down, and they agree on the skid and disagree on everything else, which is what rules out the speed bands it first looked like:
| Scenario | Released at | Skid | Then |
|---|---|---|---|
an_slide_stop | 16 px/tick | ticks 91–102 | dash_stop 103–130, run_stop 131–162, standing 163 |
g_walk_rel20 | 4 | 21–32 | dash_stop 33–44, run_stop 45–76, standing 77 |
sd_walk (on a slide tile, so it decays half as fast) | 4 | 46–57 | dash_stop from 58 |
tb_right | 1.8 | 38–49 | nothing; the skid's last frame held to tick 52, standing 53 |
Twelve ticks every time, from 16 px/tick and from 1.8, and on a tile that halves the deceleration. Ours reproduces all four to within a tick of entry lag. What differs with speed is only how much of the rest of the chain there is — which is why a tap stops so much sooner than a dash without its first pose being any shorter, and why reading the first stage as a band left a light tap with no skid at all.
Two mechanical details follow from the poses being a state of their own rather than transitions fired on a change:
- The base state is pinned to Jazz2::
AnimState:: Idle for the whole slide, and the pose is drawn over it as a transition. Jazz2:: Actors:: ActorBase:: SetAnimation() cancels a cancellable transition whenever the base state changes, so letting the speed bits decay Dash → Walk → Idle underneath ended the pose part-way through. With an unconditional cancel in the same branch as well, an_slide_stopshowed the stopping animation for one tick, which is how it came to be reported as missing. - Each link of the chain is started from the previous one's finish callback, not from the next frame's Jazz2::Actors::Player::ApplyStopAnimation(). A frame later is a frame of the standing pose showing through in between, which an earlier attempt left visible in the trace as one-tick gaps. The same callback is what loops
dash_stop: a transition cannot loop by itself, sinceAnimationLoopMode::Loopstill calls Jazz2::Actors:: ActorBase:: OnAnimationFinished() after one cycle, which clears it. Jazz2:: Actors:: ActorBase:: SetTransition() runs the outgoing callback before installing the incoming transition, so swapping poses has to suppress it — otherwise every swap reads as the new pose having already run out.
The per-pose frame rates are measured too and are not the ones baked into the sprites: every one of Jazz's animations carries JJ2 frame rate 10, while the player's stop poses actually run at 4, 7 and 4 ticks per frame. The original drives these from code rather than from the animation, so the three FrameRate values in each Player<Character>.res are the measured cadences converted through AnimDuration = 5 / FrameRate, per character, because the frame counts differ.
Speeding up
Starting a run is the mirror image, and this one really is chosen by speed: the spinning-feet animation between walking and dashing is a band, not a transition. Measured on an_run_start, with Right and Run pressed at tick 20:
| Band | Original | Ours |
|---|---|---|
run | tick 22, up to 4 px/tick | 23 |
dash_start (spinning feet) | tick 32, 4 to 8 px/tick | 33 |
dash | tick 43 (23 ticks after the key) | 44 (24) |
This engine issued dash_start as Jazz2::
The run animations are played at the speed of the player
Which band is showing is one question; how fast it is playing is another, and that one had never been asked. The original does not play the run animations at a rate of their own — a frame lasts max(1, 12 - |speed|) of its ticks, so the feet go round faster the faster the player moves and saturate at one frame per tick, which is all a 70 Hz game can draw.
The assets say nothing about this. run.aura, dash_start.aura and dash.aura all carry a flat 0.5 s for Jazz and Spaz and 0.4531 for Lori — the converter's fallback, not a per-animation rate — so the rate is in the game's code and nowhere else. This engine used the duration from the metadata, with no speed term at all. Measured across every scenario in the committed original traces, and both characters agree:
| Speed (px/tick) | 2 | 3 | 4 | 9 | 10 | 11 and up |
|---|---|---|---|---|---|---|
| Ticks per frame | 10 | 9 | 8 | 3 | 2 | 1 |
The two ends can be checked against a whole loop rather than a single frame, and both land exactly: walking is 8 frames at 8 ticks, which is the 63-tick loop g_walk shows, and dashing is 4 frames at 1 tick, which is the 4-tick loop g_dash shows. Ours were 42.0 and 17.1 — the walk half again too fast and the dash 4.3 times too slow. It is Jazz2::Actors::Player::UpdateLegacyRunAnimSpeed(), and it is the whole of the mechanic: Jazz2::Actors::Player::LegacyRunAnimFrameTicks with Jazz2::Actors::Player::LegacyRunAnimMinFrameTicks under it.
Two things are worth knowing about it.
The duration is rescaled, not assigned. Which frame is on screen is AnimTime / AnimDuration of the way through the loop, so changing the duration on its own teleports the animation — accelerating from the walk cap to the dash cap shortens the loop sixteenfold and would jump the feet by three frames on the tick it happened. Scaling the time by the same factor keeps the phase and changes only the rate.
Reforged had a rule of its own in the same place and it has to be turned off, not merely overridden. Jazz2::Actors::Player::OnHandleMovement() ends with an "adjust walking animation speed" line that stamps AnimDuration for Jazz2::AnimTime kept the value scaled to the other one, so the phase compounded by 46% a frame and the walk ran nine times too fast — worse than before the fix, and reported as such. The dash was untouched because that line only ever looked at the walk.
At 60 FPS this engine cannot reproduce the top of the curve, and that is arithmetic rather than a defect: one frame there is 1.167 of the original's ticks, so a one-tick cadence is not available at all and g_dash measures a 4.69-tick loop against the original's 4.00. At 144 it is.
The double-jump pose lasts the rise, not the animation
The same mistake, in the other direction. Spaz's double jump shows spring for 11 ticks in the original (an_dj_len, ticks 63 to 73) and gives way to the falling pose on the very tick the vertical speed turns positive — the pose is the rise, and the rise is all it is. Ours issued it as a non-cancellable transition, which ran the whole eight-frame animation over 34 ticks, so the player was still spinning a third of the way back down. The animation's own cadence was never wrong (4 ticks per frame on both sides); only its end was, so it is cut where the original cuts it rather than being sped up.
Down and Jump on the same tick, which was read as a shooting rule
Reported as a pose problem — uppercutting or sidekicking straight after a shot looked wrong — and it is not one. It is also not what this page said for one revision, which was that the original refuses the move while the shooting pose is up. That reading came from one bad control and is worth keeping on the page, because the bad control looked airtight.
| Scenario | Shot first | Down pressed | Original | Ours |
|---|---|---|---|---|
sp_jazz_upper | no | 20 ticks early, held | rises 226 px | 228 (matches) |
sp_upper_downhold | no | 20 ticks early, Jump tapped | rises 201.5 px | 208 |
sp_upper_jumphold | no | with Jump, Jump held | rises 132 px | 228 |
sp_upper_tap | no | with Jump, both tapped | rises 8 px | 208 |
an_shoot_upper | yes | with Jump, both tapped | rises 8 px | 208 |
sp_spaz_side | no | 20 ticks early, held | travels 507 px | 503 (matches) |
sp_spaz_side_rel | no | 20 ticks early, released later | travels 507 px | 503 (matches) |
sp_side_tap | no | with Jump, both tapped | travels 0 px | 503 |
an_shoot_side | yes | with Jump, both tapped | travels 0 px | 503 |
Read down the "Down pressed" column rather than the "Shot first" one. Every null result is a scenario that presses Down and Jump on the same tick, shot or no shot, and the two shooting rows are identical to their shotless twins to the pixel. What the original wants is the crouch established before Jump.
The real difference is in the gaps table as Down and Jump pressed on the same tick, and is deliberately not implemented yet: three of its four cases are partial rather than absent — 201.5, 132, 8 px of a 226 px uppercut — so it is a strength curve rather than a gate, and fitting it needs the same sweep run with Jump held for a range of lengths.
Hanging on a vine is not a static pose
The same shape as the sliding stop, and found the same way — by noticing a sprite the converter exports for all three characters that nothing in this engine reads. vine_idle_flavor is a flourish the original plays while the player hangs still, alternating with the one-frame vine_idle 70 ticks each, for ever. Measured on ob_vine_low, which hangs for 800 ticks and goes round six times, changing at ticks 28, 96, 165, 236, 305, 376, 445, 516, 585, 656 and 725. Ours now changes at 30, 100, 170, 240, 310, 380, 450, 520, 590, 660 and 730 — the two ticks are the grab landing later, and every half after it is the same 70.
The flourish loops inside its half: Jazz's is five frames at 8 ticks, so 40 ticks, and it goes round nearly twice before the static pose takes over mid-loop. That rules out the obvious alternative, which is that the animation's own length decides the half. Frame for frame against the original in one window: 7 ticks on the first frame (the phase it was already at) then 8, 8, 8, 8, 8, 8, 8, and 6 as it is cut. It loops through the finish callback for the same reason the stopping poses do.
Shared with Reforged, like the stop chain and for the same reason: there is no Reforged behaviour to preserve, because nothing read the sprite at all. Spaz's flourish is seven frames and Lori's twenty-eight, so neither completes in its 70 ticks; only Jazz's cadence is measured and the other two are given the same 8 ticks per frame.
Frame-rate independence
The original complaint that started this work was physics misbehaving at low frame rates. The whole scenario set is re-run at 24, 30, 60 and 144 FPS whenever the model changes, all four scored against the same captured original and against each other — the second of those is what actually answers the question, and it needed a tool of its own (Tools/CompareFrameRates.ps1).
1. The cross-game score broadly improves with the frame rate and the median does not move at all (broadly, not monotonically — 24 edges 30 by two scenarios, which is inside the chaotic scenarios' own run-to-run swing):
| 24 FPS | 30 FPS | 60 FPS | 144 FPS | |
|---|---|---|---|---|
| Scenarios within 25 px/s | 352 / 405 | 350 / 405 | 363 / 405 | 375 / 405 |
| Median speed-distribution error | 0.1 px/s | 0.1 px/s | 0.1 px/s | 0.1 px/s |
(Four full sweeps of one build, 2026-09-17, each holding its target rate to within 0.4 ms of the median frame.)
1b. Do the four rates agree with each other? Positions compared on a shared tick grid, with two runs at the same rate as the control:
| Compared | Median worst per-tick difference | Agree within 1 px |
|---|---|---|
| Same rate, two runs | 0.34 px | 324 / 406 |
| 144 vs 60 | 2.70 px | 153 / 406 |
| 60 vs 30 | 2.84 px | 149 / 406 |
| 30 vs 24 | 4.13 px | 140 / 406 |
| 144 vs 24 | 11.22 px | 124 / 406 |
| All four | 12.70 px | 119 / 406 |
Three groups make up the disagreement, and only the third is a defect: scenarios that are chaotic at any rate and move between two identical runs as well (pb_bump_*, sl_up45_jhold, sp_dj_a_dashflip, sp_chain); scenarios decided by an input window shorter than a frame, which at 24 FPS is 2.92 of the original's ticks (sp_slide_* reads 3.8 / 9.2 / 18.7 / 0.3 against the original's 9.6); and two mechanics sampled once a frame where they should be swept along the path — see the gaps table's first row.
2. Frame-rate-*invariant* quantities, engine only, so the spread is pure frame-rate dependence:
| 24 FPS | 30 FPS | 60 FPS | 144 FPS | Spread | |
|---|---|---|---|---|---|
Standing jump height (ap_r30) | 128.0 | 127.9 | 128.0 | 128.0 | 0.1% |
Plain jump height (a_jump) | 128.3 | 128.0 | 128.1 | 128.0 | 0.2% |
Vertical pole rise (ob_vpole) | 270.3 | 270.9 | 270.6 | 272.2 | 0.7% |
One-way ladder climb (ow_hold) | 704.0 | 704.3 | 704.1 | 704.1 | 0.0% |
| Walking jump height | 149.3 | 149.2 | 149.3 | 149.3 | 0.1% |
| Dashing jump height | 213.9 | 213.6 | 213.3 | 213.3 | 0.3% |
| Blue spring height | 597.2 | 597.3 | 597.3 | 597.3 | 0.0% |
| Float-up ladder rise | 520.8 | 520.7 | 520.5 | 520.9 | 0.1% |
| Push distance | 142.0 | 142.3 | 142.0 | 142.3 | 0.2% |
| Belt travel | 799.2 | 798.4 | 801.1 | 801.4 | 0.4% |
| Sidekick distance | 499.8 | 500.9 | 503.2 | 504.6 | 0.9% |
| Walk distance @ 3 s | 790.2 | 792.0 | 794.9 | 799.1 | 1.1% |
| Dash distance @ 3 s | 1580.6 | 1584.3 | 1589.6 | 1598.0 | 1.1% |
| Double jump height | 198.2 | 200.5 | 202.1 | 201.9 | 1.9% |
| Uppercut height | 237.1 | 228.0 | 228.3 | 227.4 | 4.1% |
| Tap jump height | 81.5 | 76.2 | 76.2 | 77.3 | 6.5% |
A velocity-Verlet gravity correction is what got the jump heights there, by folding half a step of gravity into the applied speed before the move. It took the 24 FPS standing jump from +5.95% to −3.05%, the walking jump from +5.08% to −2.67% and the dashing jump from +3.57% to −1.84%; every rate now sits on that same −3%, which is the residual described under The rise, tick by tick.
Verification
Comparing endpoints hides a wrong curve — that is exactly how Lori's kick passed a distance check while being three times too fast. The routine check is therefore the sorted distribution of speeds each build visits, which catches wrong magnitudes and wrong shapes while ignoring the few-tick timing offsets that make a naive per-instant diff useless. Tools/CompareTraces.ps1 in the harness does this.
Against the captured original run:
Compared 328 scenarios present in both traces. Median speed-distribution error: 0.3 px/s Scenarios within 25 px/s: 286 / 328
Twenty-five of the twenty-eight outside that band are cases where the two sides do not measure the same thing: the original's frozen spring never fires without a Freezer shot, sp_spaz_side has a held-jump re-jump on the end that this engine does not do, the pushable sits at a different spot in the two copies of the level, and seven scenarios that hold the player against a wall or a slope face differ only in where in the tick each probe samples the speed. Sources/Jazz2/Tests/README.md lists them all under scenarios that do not measure the same thing on both sides — check that list before treating a new outlier as a regression. The remaining three are the uppercut, the copter and the standing jump's 3%, all in the gaps table below.
Most of the pinball family sits outside the band for one shared reason, and it is not the paddles: they reach the top of the level, where the original stops the player dead at y = 0 and this engine does not. It is visible in the comparison itself — pb_pad_x1, pb_pad_d24, pb_lpad_x1, pb_pad_hold and pb_lpad_hold all report a rise of exactly 400.0 on the original's side, which is the drop height, not a trajectory. A held key pumps every bounce higher, so reaching the ceiling is what the mechanic does rather than a fault in the scenario, and the chamber cannot be made tall enough to avoid it — a −39 launch alone needs about 550 px. Read the first launch, which is taken before the player has gone anywhere.
Two families used to be there and no longer are. Spaz's double jump: all fifty-eight scenarios that exercise it are inside the band save one (sp_dj_a_dashflip, on an 800-tick bounce chain whose travel agrees to 0.06%) and the twenty-two that ask whether it fires agree on the tick it fires on. And the rise itself: all thirty ap_r* score exactly 0.0 px/s. See Spaz's double jump and The rise, tick by tick.
Passing -Baseline compares two engine runs instead, which answers "did anything change at all?" — the same build twice scores 0 on every scenario, so any non-zero figure there is a real change. Its position columns are noisy for a different reason and the harness README.md says why.
Known remaining gaps
| Gap | Status |
|---|---|
Resolved, and it was the fifth site of a rule the other four already had. Reported as "a player being run along by a pole or a spring should not be able to crouch". The crouch is not refused — measured on an_crouch_carry, the original ducks at 17.70 px/tick with the pose and all — but what happens next is the difference, and it is what makes it look like a refusal: on the tick the carry ends the original snaps the speed to the walk cap, 17.70 to 3.88, and the player is stopped 35 ticks later. This engine kept the whole 17.8 and coasted, still doing 12 px/tick two hundred ticks on and finishing 853 px past where the original stopped.It is Jazz2:: _keepRunningTime, armed by a pole launch, a spring and the rev-up) was the one that never did. It hid because every pole scenario in the harness holds a direction and Run for its whole length, and with those held the ordinary dash cap converges on the same 16 the clamp would have given — let go instead and the two part company by a factor of four. Now 5971.9 px against the original's 5948.2 where it was 6801.5, and ob_hpole_z0 and ob_hpole_zr come in 197 px closer each without being the reported case at all. | |
| A crouching player walks off a ledge | Open — attempted, reverted, and the first reading of it was wrong, which is the useful part. What is measured is not in doubt: on an_crouch_gap the original's crouching player comes to rest at x = 10768.5 with xs going 3.63 to 0.00 in two ticks, holds there for 39 ticks sinking at 0.0625 a tick, and then drops straight down with no horizontal speed. Ours slides over at 4 px/tick and carries 47 px of travel into the fall, landing 1470 px below at 10816 against the original's 10768.It was written up here as "the original stops a crouching player at the brink", and a probe was built for that — refuse to step onto empty ground while crouched. It stops the player, but 16 px short of the original's resting place and it never lets them fall at all, which is worse than the slide it replaced. Moving the probe from the hitbox's leading edge to a point half a tile ahead of the centre did not change where it fired, and that is what gives the reading away: the gap's last solid tile ends at x = 10752, so the original's resting place of 10768.5 is 16.5 px past the end of the floor, with a 20 px-wide hitbox that no longer overlaps it. The original is not refusing to walk off the ledge. It walks off, snags on the tile corner, hangs there sinking a sixteenth of a pixel a tick, and falls when it finally clears. A forward ground probe cannot express that at all, whatever it is calibrated to.So what this wants is the original's tile-corner collision for a crouched hitbox, not a movement rule — and the sl_*, sd_* and ow_* families would still have to be re-swept behind anything that touches it. The two halves of the same report that were reported, the pose and the mid-air uppercut, are independent of this and are fixed. |
| Resolved, and the second half was the serious one. Reported from play: duck at the lip of a hole, go in, and the player keeps the crouch all the way down — and can then uppercut back out. Both are one bug. Nothing ever cleared Jazz2:: Measured on an_crouch_gap_j, which holds Down and presses Jump 40 ticks into the drop: the original ignores it completely and goes on falling at 10 px/tick with sm at 0, while this engine started the move on the very next tick and climbed 110 px back out of the hole, landing on the ledge it had fallen from. The bit is now cleared the instant the player leaves the ground, which is where the original's pose changes too (anim 65 to 17 on the tick the fall starts), and the ground test is stated outright in IsSpecialMoveCrouchReady() as well rather than being left to the pose. an_crouch_gap_run confirms the player re-crouches normally on landing. | |
Resolved. Reported from play, and the rule turned out to be a cancel rather than a priority. an_slide_fire is an_slide_stop with a shot in it and nothing else held, and the two run identically until the fire lands: on that tick the original leaves the skid and shows the ordinary movement pose for whatever band the speed is in — dash_start at 13.86 px/tick, run at 4.00, the standing pose once stopped — and never returns to it. This engine set Jazz2::_fireFramesLeft rather than on the key, so the suppression and the pose it makes room for are the same condition by construction. | |
Resolved — a missing mechanic, not a wrong constant. Reported as the dash looking too slow at high speed, and it was 4.3 times too slow: the original's dash loop is 4.00 ticks and ours was 17.1. The original plays its run animations at a rate taken from the player's speed, max(1, 12 - abs(speed)) ticks per frame, where this engine used the animation's own duration with no speed term. The walk was wrong in the other direction, half again too fast. See The run animations are played at the speed of the player, which also records the Reforged-only line in OnHandleMovement() that has to be turned off with it — left in place it made the walk nine times too fast, which is worse than the bug. | |
| Sidekick stalls after breaking a row of monitors | Reported from play and NOT reproduced — recorded so the next attempt starts from what has already been ruled out. The report is that Spaz, kicking through several weapon monitors in a row, ends up holding the sidekick pose at zero speed for about three seconds before the move finally ends. A scenario was built for it (pu_side_chain, Spaz kicking rightwards through all six monitors, the only one that drives a kick into them — pu_stand_side deliberately kicks the other way so it does not consume them) and run on both sides from three start positions: three tiles short of the first monitor, immediately beside it, and standing on top of it. None stalls. From on top the two games agree closely — the kick runs 68 ticks in the original and 71 here, travelling 508.5 px against 504.1, and the speed coasts down smoothly afterwards.The mechanism it would have to be is known, which is what makes the absence informative: Jazz2::Actors::Player::CheckEndOfSpecialMoves() bills the kick's budget against the distance actually travelled, so anything that holds the player at zero speed without setting _kickPushedThisFrame freezes the budget and the move cannot end. That is exactly the shape of the report.A second pass added the details the report was missing — Down only, no Run, on the floor, left of the first monitor, and a second kick to finish the row — and it still does not stall. Two things had to be fixed in the scenario first, and both are worth knowing. The kick has to be delayed: the six monitors are events on row 59 and fall to row 61, so a kick fired at tick 40 passes under them before they land and the whole row is missed. And the monitor positions have to be in the trace, or there is no way to tell "flew through them" from "they were never there" — with objx wired up, the column walks 528, 624, 720, 848, 976, 1136 and then −1, which is the row being destroyed one at a time. Delayed to tick 200 and kicked twice, the two games agree to about a pixel: the original's sm runs t=201 to 399 over x 528.9 → 1470.6 and this engine's t=202 to 403 over 529.3 → 1471.5.An internal freeze was also ruled out, which the report raised as a possibility. The probe's clock runs off timeMult, so a stall would leave the trajectory untouched and show only in the ms column — and it does not: the worst frame in the whole scenario is 24.5 ms against a 16.7 ms median, with every monitor in the row being destroyed. Whatever is happening in play is not a kick meeting a monitor head-on, and is not the cost of breaking them. |
Resolved, found by sweeping all four frame rates. It was evaluated at the position the player ended the frame in, where the trigger events — poles, tubes, warps — are swept along the whole path travelled at half-tile steps (see Jazz2::Actors::Player::OnHandleAreaEvents()). At 60 FPS and above that rarely matters. At 24 FPS a frame is 2.92 of the original's ticks, so a 12 px/tick fall covers 35 px between samples — more than a tile — and the tile is never seen. The count of rows on which the assignment actually fired, over fu_jump_right_rel's ladder of separate float tiles, is what pins it: 41, 34 and 17 at 144, 60 and 30 FPS, and two at 24, and the climb collapses from ~495 px to 176.6 with it. It now walks the same samples the triggers do, stopping at the first that finds the field — the effect is an assignment, so finding it twice in a frame means nothing. A stationary player still samples one point and is unaffected. Re-swept at all four rates: 24 FPS goes 176.6 → 513.8 px with the hit count 2 → 15 against the original's 18, fu_butt 176.6 → 200.0, and 60 FPS does not move — before and after agree to 0.16 px median with 394 of 406 scenarios inside a pixel, better than the run-to-run noise floor.ow_jump was in this entry as a second instance and does not belong here. Its rises are 224.5 / 224.7 / 225.6 / 202.3, which has the same shape, but the trace says the cause is different: the launches all happen, and what moves is when — the original launches at tick 21 and this engine at 20, 22, 23 and 23 as the rate falls, because the jump key is sampled on a frame boundary. That is the input quantisation two rows below, not a missed tile. The one-way test's own box is half a pixel tall, so sweeping it would need a far finer step than the events do and has no measurement asking for it. | |
Resolved. The rate a pole launch decays under depends on how the player reached the pole. Read off the original: ob_vpole, reached by jumping, decays at exactly 0.875; ob_spring_vpole, reached off a spring with no key ever pressed, at exactly 0.375 for 25+ consecutive ticks. Same pole, same key state, so the key is not what decides it. NextPoleStage() set _jumpReleased unconditionally, leaving a spring-fed pole rising 535 px against the original's 804. Now !_poleEnteredOnSpring, captured at the grab — _isSpring covers only the rising part of a spring launch and a pole holds the player at zero speed for ~140 ticks, which clears it long before the launch. The speed-distribution check scored this scenario a perfect 0.0 while it was 33% short, for the usual reason. | |
| Resolved. The original's pole assigns −40.125 and the very next tick reads −32.00, so its internal vertical limit is 32 of its own units — Jazz2:: | |
| Not a movement defect - corrected after re-measuring. This was recorded as "a single pole launches 4.5% weak", and the reported launch does differ by that much: -22.25 against -21.25 in the original's units. The travel does not. ob_vpole rises 270.6 px against 270.2, ob_vpole_adjx 270.6 against 270.4, ob_vpole_x2 442.8 against 440.5, and ob_hpole travels 3636.9 against 3636.8 with ob_hpole_x3 exact. The applied rise cap of 8 governs the ascent, so a few percent on the assignment never reaches the trajectory. Both probes also capture the entry speed at a different point in the tick - the original before the step that carries the player into the tile, this engine after it - which is where the 4.5% comes from. It belongs under reported versus travelled speed below, not here. | |
| Spring hold before decay | A spring holds its launch speed for two ticks in the original before gravity starts; this engine decays immediately. Worth ~0.75 of launch speed. It is not the cause of the residual on spring heights, though: it accounts for blue's deficit exactly and would make red's and green's worse, so it is deliberately not applied. Re-verified against the current build: the original holds −32.0000 for ticks 0, 1 and 2 and decays from 3, where ours is already at −31.81 by its second row; and the rises are red +2.2%, green +0.5%, blue −1.9%, so the error still runs from positive at a weak launch to negative at a strong one. The hold would add the same amount to all three, which is the wrong shape — as would the Euler correction under Standing jump above, and for the same reason. Whatever is left grows as the launch weakens, and neither candidate does. Where the residual actually lives, measured since: on the original side both springs sit a uniform +7.6 px above the naive model (capped ticks at 8, then the uncapped tail) — red 267.7 against 260.1 and blue 609.0 against 601.4 — which is the hold, and it is flat as expected. On ours blue lands on its model exactly, 597.3 against 597.3, and red is +16.4 above its own. So the shape is not in the hold at all; it is a surplus on this side that appears at a weak launch and not at a strong one. That is what to chase, and adding the hold on top of it would take red from +4.6 to about +21. |
| Speed kept through a landing | Recorded from a chained crossing: a run that takes the four-tile gap and then meets the five-tile one was said to clear it in the original and not here. Not reproducible in the current scenarios - lh_g4_dash and lh_g5_dash now travel 6186 against 6153 and lh_g4_walk 3148.8 against 3099.1, all inside the band and all taking the same route. Either the intervening fixes closed it or no scenario exercises the chained case; each lh_* gap is approached with its own run-up. Left open rather than resolved, because what it describes is not something these scenarios can show. Re-verified: all twelve lh_* are within 0.5% on travel bar lh_g4_walk at 1.8%, and the fall depths agree to a pixel in every one, so both games are still taking the same route through each gap. |
| Resolved. It was never test-level geometry: an internal speed limit sat below the launch it had to carry, and the figures that made no sense (green rising less than red) came from a contaminated probe run. See Springs set the speed outright. | |
Resolved. The original's Seeker moves the player by nothing at one, two or three tiles, with the ammo count proving the shot was fired, and TNT does nothing at any range — there is no shared explosion mechanic for RF's to be an instance of. SeekerShot carried AddExternalForce(±8, 0), twice what RF had before it was measured; that is now Reforged-only. See Blasting yourself off a wall with an RF shot. | |
Resolved. The starting position was the remaining half, and it is the player's leading edge minus half the shot's own width: a shot detonates when its box meets the wall, not when its centre does, so spawning the centre at the edge put every reading three ticks early at every range. With both corrections the flight is 9, 10, 13, 17 and 18 ticks against the original's 9, 9, 13, 16 and 17 — within a tick everywhere, where it used to be a flat 11 with no relationship to the distance at all. And wb_rf_r48 now moves the player 1.2 px against the original's 0.9, where it threw them 306: once the shot actually flies to the wall, the distance handed to Jazz2::Superseded: Half resolved, and the half that is left is a starting position rather than a speed. Ours detonated on the tick it was fired; the original's blast lands 9 ticks later point blank and further out in proportion to the range. Measured against the true gap to the wall — taken from wb_wallfind, not from the scenario names, which are offset by the player's own half-width — it is one tick per 3 px plus a flat 9: 0.6 px → 9 ticks, 11.7 → 13, 20.6 → 16, 23.7 → 17, and nothing at 35.7. Both constants are now in RFShot as LegacyBlastDelay and LegacyFlightSpeed, the engine's own 6 px/frame having turned out to be twice the original's. Point blank is now exact at 9 ticks against 9, and wb_rf_j0's rise went from 104.2 px to 127.9 against the original's 168.6.What is still short is the flight at range: 12 ticks against 13, 16 and 17 at the three longer gaps, flat where the original grows. Flat is the clue — our shot spends two frames hidden and is then teleported to the gunspot, which for any gap under about 24 px is already at or past the wall, so the distance it actually flies is neither the gap nor proportional to it. The original behaves as though its shot starts at the player's front edge and covers exactly the gap: 0.6 px → 0 ticks beyond the fuse, 11.7 → 4, 20.6 → 7, 23.7 → 8, all at 3 px/tick. Fixing it means knowing where the original's muzzle is, which needs the shot's own position logged — neither probe records it. The same short flight is why wb_rf_r48 still throws the player 306 px where the original moves them 0.9: the shot dies about 12 px short of the wall, so Jazz2:: | |
| Resolved by fitting it, since the mechanism behind it is still not in evidence. Standing on the floor the original lifts the player and ours lifted nothing at all. Four points against the true gap: −0.75 at 0.6 px, −2.75 at 11.7, −4.25 at 20.6, −4.75 at 23.7, and nothing at 35.7. The lift grows with range while the horizontal component saturates at 8, which is not the shape a blast of fixed strength has; the reading that would explain it is a shot that falls as it flies, putting the explosion further below the player the longer it travels and tilting the push upward. Nothing here measures the shot's own gravity and Jazz2:: Fitted instead: -(0.646 + 0.1732 * range) reproduces all four points to within 0.08, with Jazz2::wb_rf_r36 10.6 px against 12.6, wb_rf_r24 2.8 against 4.1, wb_rf_r48 and wb_rf_r64 nothing against nothing, where every one of them lifted 0.1 before. Point blank is about a pixel generous. A fit of what was measured is still the measured behaviour, and it beats applying nothing. | |
| A buttstomp landing hops in six scenarios of nine | Implemented as a bounce, reported wrong from play, and reverted — the six are a collision artefact and not a rule. Worth keeping in full, because the evidence for it was as strong as any measurement on this page and it was still wrong. What the traces show is not in doubt. On sp_spaz_butt, sp_lori_butt and sp_jazz_butt, which are byte-identical through the whole landing, the descent holds 10.0 px/tick to the impact tick, the speed there is a flat -4.0, and the player hops 9 px and is back down sixteen ticks later — decaying at 0.875 and then 0.625 below a pixel a tick, which is Jazz2::It is still not a rule, and the check that shows it is running the other scenarios in the family. sp_butt_high, sp_butt_hold_r and sp_butt_tap_r land the same stomp on the same tile at the same 10 px/tick and go straight to ys 0.0000 with no hop at all. What separates the two groups is nothing about the move: the ones that hop finish their last 10 px step a fraction of a pixel inside the floor (landing at y 1330.69 against a resting height of 1329.94, having been clamped out of an 8 px overshoot) and the ones that do not land exactly on it (1329.189 both). So the -4 is that game pushing a penetrating body back out, and which group a stomp falls into is decided by the sub-pixel phase of where the descent began.Two lessons, and the second is the one that cost the time. A figure repeating exactly across characters proves the code path is shared, not that it is a designed behaviour — a de-penetration is as deterministic as a rule and will reproduce just as perfectly. And a family is not a sample until its members disagree: the three scenarios first measured were identical in everything including the landing phase, so they could only ever agree with each other. The rest of the family was three commands away and settles it outright. |
| Resolved. It is a flat −13, not a fraction of the impact, and the ascent decays at the released rate. Launch speed now exact; the rise is 4.8% long, which is discretisation rather than a wrong figure. See Bouncing off an enemy you buttstomped. | |
| Spring re-triggers under a resting player | The observation is real, the rule stated here was wrong, and the correction is worth more than the entry was. It read as "the original's spring does not fire again under a player resting on it" — but the original re-fires springs under a resting player perfectly happily, and we match it every time: ob_spring_red fires again at ticks 109 and 216 against our 104 and 210, ob_spring_green at 148 against 144, ob_spring_blue at 185 against 181. Implementing the rule as written would have broken three scenarios that are currently exact. What actually happens in ob_spring_vpole is geometric: the pole drops the player 15 px to the side of the spring they launched from — x = 4527 against the 4512 they started at — and the original's spring does not reach that far while ours does. Both games put the player within a pixel of each other, so the whole difference is a pixel or two of trigger width at the very edge of the box, which is the knife-edge case Results/README.md warns against fitting. Left alone deliberately: it is one scenario, the gameplay case it describes does not exist, and chasing it would be tuning a hitbox to a binary outcome. |
Resolved, and the window turned out to be two rules rather than the one recorded here. The phase half of it is fixed as this entry proposed: the check now reads _frameStartSpeedY, the speed before the tick's gravity, which is what the original tests, and sp_spaz_dj_hold went from 166.8 px to 128.4 against the original's 132.1. The other half was wrong. There is no fall-speed cap at all — holding the first press past the apex opens the window twelve ticks into the fall, and the original then fires at 4.0 and 4.6 px/tick (sp_dj_r85_d20, sp_dj_r85_d25) where this engine refused. What actually closes the window is a 30-tick timer started by letting the jump key go, which the old (0, 3.75) reading could not tell apart from a speed band because every scenario released at the same tick. Nine scenarios used to double jump where the original does nothing. See Spaz's double jump. | |
Resolved. The original throws it away: sp_dj_a_dash reads xs = 16.0000 the tick before the press and 0.3662 — one dash acceleration step — the tick after, and the walking cases the same with 0.1831. This engine carried the full speed through, worth 1925 px of travel on sp_dj_a_dashflip. The old reading came from travel measurements that both games ended against the same wall. | |
Resolved. The rule was found by building the sweep this entry said was needed: ap_r01..ap_r30, thirty standing jumps released one tick apart. The released rate turns out to be two rates (0.875 above one pixel per tick, 0.625 below), and the rise ends at zero rather than overshooting by more than one tick of gravity. Written out in full under The rise, tick by tick, where it reproduces all thirty of the original's peaks to 0.01 px, and all thirty now score exactly 0.0 px/s. The payoff is on the descent rather than the peak, because the old overshoot carried up to +0.94 of spurious fall speed out of the apex and offset everything after it: sp_dj_r50_d25 went from 11.2 px short to 1.1 px over, sp_dj_r50_d29 from 6.4 short to 1.9 over, and all three fu_col_* improved. What it did not close is the standing jump's 3% — see the entry below. | |
| Standing jump is 3% short | Now explained exactly, and it is not sampling scatter. 128.0 px against 132.0, and both figures fall straight out of the constants: summing min(10 − 0.375n, 8) over the original's ticks gives 132.000, and the exact integral of the same curve gives 128.000. The original integrates the rise with Euler — it moves by the speed it had before that tick's gravity — and this engine's velocity-Verlet half-step computes the true integral, which is lower by ½ · g · (uncapped ticks) = exactly 4.000 px. The 30-scenario ap_r* sweep confirms the shape: the error is ~0 for releases of 1-6 ticks, where the rise is still capped and the two agree, and grows monotonically to −4.0 px from tick 27 on, where the whole uncapped rise is present. It was recorded here as "+3.0 to −4.0, the signature of a sampling difference"; the range is right and the reading was not, because there is no scatter in it at all.Applied, measured and taken out again, and the measurement is worth more than the entry was. It went in as a constant speed offset of half a step of the original's tick per frame rather than as plain Euler — plain Euler overshoots by g · T · dt / 2, which is the original's figure only when dt is one of its ticks, so it would have restored the frame-rate dependence the half-step was added to remove; written as a constant the total over a flight is bias · T at any step size. It does exactly what was predicted at the fully-uncapped end and rather more elsewhere. Against the original, before and with the bias: a_jump 132.0, 128.2, 132.0; ap_r30 132.0, 128.0, 132.0; an_shoot_air 132.0, 128.0, 132.0; ap_r05 77.1, 77.3, 81.3; sp_jazz_copter 77.1, 77.3, 81.3; ob_spring_red 267.7, 272.4, 277.2; ob_spring_green 438.4, 440.0, 448.1. Total absolute error over those and three more: 33.0 px to 39.7. Three scenarios become exact and seven get worse by about four. The reason is now clear and is the thing to keep: the released-rise gravity and its easing were fitted against the original using this engine's exact integration, so they already absorb whatever the original integrates with, and the bias double-counts it. Only the held rate — a flat 0.375, read off the speed column rather than fitted to a height — was missing it. A version that applied only under the held rate would leave the springs, which use it too. So the entry stays open, and a note in ActorBase::OnUpdate() says not to re-apply it blind. |
Resolved, and it is two rules rather than one. The descent was implemented as an assignment of Jazz2::cp_tap65, which engages at 0.625 px/tick and then climbs 0.633, 0.641, 0.648, 0.656: 1/128 a tick, still only 0.977 forty-five ticks later. Its neighbour cp_tap70 engages at 1.250, above the ceiling, and drops to 1.0078 in a single tick. So engaging caps the speed rather than setting it, and what follows is Jazz2::The ceiling itself was also slightly wrong: a round 1 where the original holds 1.0078125, which is 129/128 — exactly one wind-up step above a pixel a tick, which is unlikely to be a coincidence. sp_jazz_copter now reads 1.008 against the original's 1.008 where it read 1.000. Gravity is re-asserted off on every frame the copter is up rather than only where it engages, because the copter now survives a warp and Jazz2::Actors::Player::DoWarpOut() hands gravity back. | |
Resolved. Jazz2::_copterFramesLeft unconditionally. The original keeps it: on wp_walk it comes out of the warp still coptering — its own animation 30, the one sp_jazz_copter shows throughout a descent — and falls at 0.008, 0.016, 0.023, 0.031, which is the wind-up above starting from the standstill a warp leaves behind. Ours dropped into an ordinary fall and was doing 2.6 px/tick by the time the original had reached 0.1. This is also the case that makes the wind-up visible at all, a warp being one of the few things that zeroes vertical speed without landing. Gravity is no longer handed back at the warp-out while a copter is running, for the same reason a modifier already blocked it — without that the ramp restarted from 0.42 px/tick instead of a standstill. A small residual is left: ours begins the ramp at 0.265 where the original begins at 0, so the descent is that much faster until both reach the ceiling. | |
| Copter rise | Largely resolved by the double-jump-phase fix, and the residual is the row above rather than the copter. This read "3-5% off, high in one direction and low in the other" on the strength of sp_spaz_copter at 110.6 against 107.4; that scenario is now 107.7 against 107.4, and nine of the fifteen copter scenarios agree within 0.3%. What is left is short, never long, and always by about the same amount: cp_tap55 77.3 against 81.5, sp_copter_lrl 77.3 against 80.5, sp_copter_late 128.0 against 132.0. That last pair is the standing jump exactly — the copter never engages there — so the deficit is 4 px of Euler-versus-exact and not a copter constant. Worth re-reading once that is settled rather than measuring the copter again. |
Resolved. EventType::ModifierSlide was converted and then read by nothing; the mechanic was simply absent. It replaces the no-direction brake, and its 2-bit Strength scales that linearly. All four strengths measured and implemented; travel now within 1.2%. See The slide tile swaps the brake. | |
Resolved by sharing it, at the user's request. Everything measured about the tube now applies in both modes, because the tube is the original's mechanic either way: its speed comes out of the level in the original's units, so it means the same thing whichever movement model is running. Four figures changed for Reforged — a speed conversion that was 14% slow, a fixed 10-tick window against the original's 16 re-armed, a snap 7 px too high, and no clamp at all on release — plus the hard speed clamps, which default to 16 px/frame on both axes there and would have truncated any tube stronger than that and undone the shared conversion. LegacyTubeControlTime and LegacyTubeSnapY lost their prefix with the gate, per Features the original does not have must not read the legacy constants; the applied rise cap is deliberately not shared, because that one is the movement model rather than the tube. | |
| Resolved. The speed assignment was already exact; the hold was a fixed 10 ticks where the original re-arms a 16-tick window every tick the player is inside a tube tile, and the release handed the whole tube speed back where the original clamps to the walk cap. A 30-tile row, plus the two-tick difference between an 8 px/tick and a 20 px/tick tube, are what separated the window from the exit condition. Its Wait Time parameter is still unread here, and changes nothing in either game at a value of 3. See A sucker tube re-arms while you are inside it. | |
| Measured. Orientation-down, Keep X Speed, Keep Y Speed, Delay and Reverse had never been set by any scenario. All five now agree between the two games, but getting there needed three attempts and the reason is worth keeping: a spring is not read from the event map the way a pole or a belt is - the game has to spawn an object from the event, and it only does that when it first scans that region. An event written anywhere near the scenario origin therefore never appears at all, whether it is written at the reset or at the run start. Forty tiles east, where the region is still untouched, it spawns on arrival. A spring that spawns on top of the player also never fires, which is why the two that stood on their own spring measured nothing until they were moved aside. Of the five, four produce a launch and match (orientation-down cuts a 132 px jump to 92.6 against 94.4; Reverse throws the player back 1031 against 989; Keep X and Delay both launch 360.7 against 360.0) while Keep Y produces no launch in either game with this geometry - an agreement rather than a measurement of the flag. | |
Resolved. Four separate things: no ceiling and no timeMult on the step, the applied horizontal cap clipping a strength-8 belt's 12 px/tick to 8, the step applied before the brake instead of after it, and no clamp to the walk cap on leaving. Each only became visible once the one before it was fixed. Steady speed and cap now exact, travel within 1.4-3.7%; what is left is two or three ticks of ramp where a per-frame reading meets a per-tick one. See Wind and belts move the player, the accelerating belt drives them. | |
| Resolved. Wind was 20% too fast and belts 30% too slow, both from one shared 0.7 factor where the original uses 0.5 for wind and 1.0 for belts; the parameterless belt defaults were 3 where the original substitutes 2 and 4. Each figure is now fitted on two strengths. See Wind and belts move the player, the accelerating belt drives them. | |
| Resolved. They assign a fixed rise speed while the player is inside; this engine applied a continuous force that oscillated around zero instead. Riding a ladder of them gained 149.8 px against the original's 520.3, and is now 520.7. See Float-up areas hold a speed, they do not push. | |
| Resolved, and the cost of doing it was overestimated. While a float area holds the speed the original travels exactly the speed it assigned — 8.0 px per tick, with no gravity that tick. Ours applied the usual velocity-Verlet half-step and travelled 7.79 in the original's units, 2.7% short, for the reason that only shows up once it is written down: Jazz2:: | |
Resolved. Leaving a float area, the original decays at 0.875 whenever jump is not being held — including when it was never pressed at all, which ours treated as "held" and decayed at 0.375. It is a fifth launch source for What a pole launch decays at's per-source table, and the one whose answer is simply the live key state. The flag could not be retuned: _jumpReleased is shared with the spring and pole sources, where "nothing pressed" measures 0.375. The ascent now remembers that it came from a float area (_riseFromFloatUp) and reads the live key only in that case. fu_col_none decays at 0.876 against 0.875 and eases at 0.626 against 0.625; its peak overshoot fell from 35 px above the original to 13.6 px below, the remainder being where the player leaves the column rather than how the rise decays. | |
Resolved. OnHitSpring() pulled the player halfway towards the spring's own centre before assigning the speed; the original leaves the position untouched. Invisible to every prop scenario, because they all walk into a horizontal spring at the height it already sits at — only a fall onto one is affected, and then it is worth 10 px. Found by pointing the probe at a spring chain in shipped level geometry (Diamondus 3), where those 10 px were the difference between escaping the chain and looping in it for ever: 204.5 px/s before, 0 after. See Springs set the speed outright. | |
| Resolved. The launch is floored at 8 and goes left at an entry speed of zero, neither of which the three-point fit could see, because two of its points were clamped and the third was the only one determining the curve. Without both, a player who drops onto a horizontal pole is launched at nothing, never leaves its tile, and re-grabs it every 70 ticks for ever — reported from a hand-built spring chain in the test level, which the original runs through without sticking. See Poles obey two different laws. | |
Resolved, and the entry's own description of it was wrong in a way that mattered. It read "stops the player dead at y = 0", which suggests the speed is killed. Measured on sp_pole_loop, only the position is bounded: the original reaches y = 0, stays there for 69 ticks while ys carries on decaying at the ordinary rise gravity — −26.00, −25.625, −25.25 — and falls away by itself once that turns positive. Zeroing the speed would have ended the rise early and dropped the player sooner than the original does. Ours had no top bound at all and overshot 469 px there, costing 51 ticks coming back down.It also has to be applied after the move, next to Jazz2::Actors::Player::TryLegacyOneWayRejump() and for the same reason: the level-bounds block near the top of Jazz2::Actors::Player::OnUpdatePhysics() runs before it, and clamping there left the frame's own step still to come, so the player settled one frame's rise above the bound — 9.3 px at the applied cap, which is exactly what the first attempt measured. sp_pole_loop, pb_pad_x1, pb_bump_left, pb_lpad_d40, pb_pad_d24 and pb_pad_below now all read exactly 0.0 where they read −13 to −469, and sp_pole_loop's speed-distribution error went from 12.8 px/s to 0. | |
Resolved. A V-Pole event directly above another is a real construction, and the original chains off the second — launching −22.25 then −37.38 for 406.7 px. InitialPoleStage() excluded everything within two tiles of the pole just used, which swallowed it, so a third of the height was lost. Now only the exact tile is excluded: 401.0 px against 406.7. Side by side is unaffected and needs no rule at all — neither game chains there, because a launch straight up the column never overlaps the neighbouring tile. | |
Resolved — and there was no crouch gate here at all. This read: Spaz jumps with Down and Jump held through a sidekick and Lori never does, so the original must be gating on some crouch-or-coast state. It is not. Reading sm, the original's own move timer, settles it: Spaz's counts up to 70, returns to 0 at tick 110, and on the very next tick he jumps at −13.939 — which is Jazz2::sm returns to 0 at 110 and immediately restarts at 2, 3, 4…: her kick re-triggers off the still-held keys, which is the separate behaviour already resolved under Lori's kick does not repeat. She never jumps because there is never a tick free for one, not because anything refuses her. So the rule is simply that a special move ending with Jump still held starts a fresh action on the next tick, and which action that is follows from whether the character's kick repeats.Fired from Jazz2::Actors::Player::CheckEndOfSpecialMoves() on the tick the kick's budget runs out, before the exit snap, because the launch boost is taken from the kick's speed and not from the 4 it snaps to. The snap still happens — the original's own trace reads 15.88 on the tick the kick ends and 4.00 on the tick it launches, so the jump borrows the speed for its boost and does not carry it into the air. Grounded is asked of the tileset, through Jazz2::Actors::Player::HasLegacyFloorBelow(), rather than of CanJump(). That is not a refinement, it is the whole difference between working and not: the dash clears ActorState::CanJump when it arms itself — it also turns gravity off, so nothing would ever clear the flag again — and so by the time the budget runs out the flag reads false whether the kick ended over floor or over a gap. The first build of this used CanJump(), compiled, ran clean and measured 0.4 px, exactly what it replaced.Now sp_spaz_side 213.3 px against 216.3, sp_side_run 213.3 against 216.3 and sp_spaz_side_rock 130.0 against 133.8, each of which gave 0.4 px or less before. The residual is the standing jump's own known 4 px (see Standing jump is 3% short). Swept at all four rates, sp_spaz_side reads 213.6 / 213.4 / 213.8 / 213.3 px at 24, 30, 60 and 144 FPS, and the other 31 scenarios of that sweep are unchanged to the digit. | |
Resolved, and the sometimes was the whole clue. With ToggleRunAction on, a key already held when a level starts produces no rising edge inside it, so _isRunPressed is seeded from the live key state instead — but it latched on the first frame Jazz2::Actors::Player::OnHandleMovement() ran, and after a level load the input has not necessarily been sampled by then. Seeding there reads "not pressed" for a key that is being held, latches false, and then waits for a rising edge a held key will never give. It now latches only once the key is actually seen down; until then the flag simply follows it, which is what an unpressed Run means anyway, so nothing changes for a player who starts the level not holding it. | |
Resolved, and confirmed against the original's own trace. Originally changed from a report rather than a trace. Re-reported after the change, which is worth stating plainly: the fix is gated on non-Reforged, so it is the old behaviour that is still seen in Reforged, and that is deliberate rather than a fault. The new half of the report — that a special move needs the speed to have truly reached zero — is a separate entry above. Reforged enters the crouch only once abs(_speed.X) is exactly zero, so a run ends and only then does the player duck; the original ducks the moment no direction is held, so a run ends in a slide and the crouched gunspot puts shots out low and moving. Jazz2::Actors::Player::HandleLookupAndCrouch() now gates non-Reforged on the input rather than the speed, sharing the 0.4 dead zone with Jazz2::Actors::Player::HandleHorizontalMovement() so the two cannot disagree within a frame. Confirmed, and then a second half of it was reported from play and fixed — the first confirmation was real but did not cover the case that was actually wrong. The crouch state was already right: the original's crouch is a distinct animation id per character (10 Jazz, 88 Spaz, 170 Lori), and on sp_slide_* it switches to the crouch pose one tick after Down at xs = 14.04, over three times the walk cap, where ours enters at 14.58 and decays identically.But every one of those scenarios presses Down on the same tick the direction is released, so the stop chain never starts first and none of them could see the reported fault: run, let go, then duck, and the crouch does not appear until the player has genuinely stopped. The cause is the third instance of a shape this page already records twice — the stop chain is drawn over the base animation, so the crouch engaged underneath a skid that kept playing until StopPhaseHold cancelled it at zero speed. an_crouch_slide, an_crouch_late and an_crouch_walk press Down 8 and 22 ticks after the release and at two entry speeds, and the original cancels the skid immediately: on an_crouch_slide it holds the stop pose (anim 63) through tick 48 and is in the crouch on tick 49 at xs = 10.62. Jazz2::Actors::Player::IsSlidingToHalt() now excludes a crouching player, alongside the rev-up and the carries, and ours crouches on tick 50 at 10.97. The late and walking variants agree as closely: 4.99 against 4.82, and 2.86 against 2.90. Look-up is deliberately untouched — it was reported as already matching. | |
| Boosting a player standing on you | Changed from a report, not from a trace, and not measurable by this harness at all — both probes drive a single player. Reforged bumps the lower player sideways out from under the upper one; non-Reforged now hands the rise upward instead, at Jazz2:: |
Already resolved when this was re-measured — the entry was stale. It asked for the probe to report vine tile positions the way ScanFloorRow reports floors, and that now exists (ScanVineTiles): the level carries four separate vines at tiles 25-26 on rows 36, 39, 42 and 45, three rows apart, plus a 14-tile run on row 50. So the second grab is the row-42 vine, 96 px above the first. On ob_vine_low_hold this engine grabs at y = 1456 and 1360, against the original's 1458 and 1362 — two pixels apart, both grabs present, and the climb reaches 1040 against 1034. What closed it was Jazz2::Actors::Player::VineDropCooldown, whose own comment describes this exact grab; the entry was simply never struck through afterwards.Reading it needs care, which is probably why it looked open: with Jump held the grab and the jump off it happen in the same frame, so the susp column is already back to 0 by the time the row is logged. What identifies the grab is anim = 12 with y snapping to the hang height and ys to exactly −10.00. That is the mirror of the trap recorded under A vine climbed while jump was held, where the pose outlived the state; here the state does not outlive the frame. | |
| Resolved. The hang height moved with the approach speed — a tapped jump settled at 1456 and a held one at 1452, where the original hangs at 1458 however it is reached. The settle loop in Jazz2::Actors::Player::CheckSuspendState() only corrects a player who is inside the suspend region, and the grab box reaches above them while rising and across the whole distance travelled that frame, so a fast approach resolved the grab from outside it, ran the loop zero times, and the lone step back up left them high. It now walks back down onto the vine first, and abandons the grab rather than leaving the player hanging in open air if nothing is within a tile. | |
| Resolved, and then found to have been resolved the wrong way — see the row below. The jump-side drop nudged the player 4 px up before jumping, and that nudge was removed on the reading that the original adds nothing. | |
| Resolved. The Down-side drop took a hardcoded 12-frame cooldown before another vine could be grabbed, which is 14 of the original's ticks — and a release at Jazz2::Actors::Player::LegacyVineDropSpeed covers the 64 px to a vine two tiles below in about 13, so the grab was swallowed and the player fell to the floor. It now takes the same Jazz2::Actors::Player::VineDropCooldown the jump-side drop already took, for the same measured reason; four or five ticks is all it has to outlast, since after the 4 px step down the grab box clears the released vine's mask by y = 1407. Measured on ob_vine_down, a pair two tiles apart at (28,43) and (28,45): a quick Down now lands on the lower vine at y = 1456 against the original's 1458, and holding Down falls through both to 1520.7 against 1521.8. Both score 0.0 px/s. The control is what makes the pair worth having — before the fix the tapped and the held variants were byte-identical, which is what says the key was never the variable. | |
Resolved, and it overturns the row above. The original lifts the player 12 px on the tick they let go and jumps from there; every tick after that moves the 8 px Jazz2::ob_vine_up shows it three times in one run, at three unrelated heights: 1443 → 1431, 1437 → 1425, 1299 → 1287. See Jazz2::Actors::Player::LegacyVineJumpLift.The reading it replaces took ob_vine_low's 1458 → 1438 over two ticks for two moves of a plain −10 jump. Under the applied cap a plain jump moves 8 + 8 and lands at 1442; only 12 + 8 reaches 1438. Two rates that co-vary and one observation to separate them — the same trap as Spaz's double-jump window, and it survived because removing the nudge did fix the bug above it, so the arithmetic was never checked. What exposed it was a layout no existing scenario had: two vines stacked four tiles apart, where the original clears the upper one with a pixel to spare and this engine, launching 12 px lower, stopped 13 px under it and could never climb the pair. Reported from play as "the player cannot reach the second vine".Worth knowing for anything else aimed at a vine: the mask is not the tile. The pair at (22,44) and (22,40) carries its suspend band at y 1424-1439 and y 1285-1295 — one at the bottom of its tile and one near the top — so the gap the jump actually has to cover is 129 px, not the 128 the tile rows suggest. ScanVineTiles now sweeps the point sampler down a column to report exactly that. | |
Resolved, and it was never a grab problem. The jump-side vine drop in Jazz2::Actors::Player::HandleJump() moved the player 4 px up and set CanJump but never cleared _suspendType - so with continuous jump on, which is the default, the branch re-fired on every frame the key was held: 4 px higher each time, still suspended, and the jump it enabled was cancelled the next frame by Jazz2::Actors::Player::CheckSuspendState() re-zeroing the vertical speed. Measured on ob_vine_low_hold, y ran 1452 → 1448 → 1444 with the suspend state reading 1 throughout, against 1456 for a tapped jump that settles properly. It now detaches, takes the same 12-tick cooldown the Down-side drop already took, and clears _jumpTime so the jump that follows in the same call can actually fire - without which the player merely drops off the vine instead of jumping from it. Two wrong diagnoses came first, both from reading the animation as the state: anim = 12 is the suspended pose, which outlives the suspend state, and only adding a susp column settled it. | |
Resolved, and it is a class of bug no trajectory check can see. Reported from play: launched out of the rev-up, the player ran along in the sliding-to-a-halt pose. The base state was never wrong — Jazz2::Actors::Player::UpdateAnimation() already forces Jazz2::_keepRunningTime is running — but the stop chain is drawn over the base animation, and Jazz2::Actors::Player::IsSlidingToHalt() excluded the rev-up's three wind-up timers and not the launch that follows them. A carry has no directional input of its own, so it read as "let go and coasting" for its whole length. _keepRunningTime now excludes it too, which covers every other carry that sets it as well — a spring, a pole, a wall bounce. Reproduced on rt_b8, rt_b16, rt_t3 and rt_rec, which are engine-only but do launch here: 319 ticks of TransitionDashToIdle before, 50 after, and those 50 begin at tick 488 as the carry ends and the player genuinely halts. Travel is identical either way (2641.9 against 2643.1) which is exactly why every position and speed check in the harness scored it perfect. It is what prompted CompareTraces.ps1 to compare the animation columns at all. | |
| Run-in-place: when the wind-up pose appears | The sustained pose starts a little earlier here than in the recording — 24 ticks after the first tap against the original's 41, which is its fifth tap rather than its third. The escalation rule is not pinned: across nine recorded bursts the pose appears anywhere from the third tap to about ten ticks after the fourth, and human cadence varies too much to separate "N taps" from "N taps inside a window". The launch itself is unaffected in anything that charges fully, which is why it is left as it is. Worth another recording with deliberate, evenly spaced taps at two or three fixed cadences. |
| Resolved — measured from a hand-played recording. See The run-in-place rev-up. | |
Resolved, and it was the probe's own fault. Spring::Activate() has always refused to fire while frozen; ob_spring_frozen simply never set the flag — spawnSpring() writes the type and orientation and left params[2] zero — so it spawned a plain green spring and measured ob_spring_green twice under two names. It read as the largest single discrepancy in the harness. Now inert on both sides. | |
Resolved. vine_idle_flavor is exported for all three characters and was read by nothing, exactly as walk_stop was. The original alternates it with the static pose 70 ticks each for as long as the player hangs, and the flourish loops inside its half rather than setting the half's length. See Hanging on a vine is not a static pose. | |
Measured, and the entry's premise was wrong: the original has no start transition either. vine_shoot_start is exported and unread here, and that looked like the same signature walk_stop and vine_idle_flavor had — but the original's trace does not play it. an_vine_shoot fires from the low vine at tick 60 and the original goes straight from hanging to the shooting pose, holds sprite 819 without advancing for 19 ticks, then plays a 3-frame return (817 → 818 → 816, 6 + 6 + 2 ticks) and hangs again at 94. That is exactly the structure this engine already has — pose, then TransitionHookShootToHook, then hanging — so nothing was implemented, and implementing the "missing" start would have added a pose the original does not show. Two small differences remain and neither is vine-specific enough to act on from one trace: ours holds the pose 26 ticks against 19, which is the general shooting cooldown rather than anything about vines, and ours advances the pose to a second frame at tick 78 where the original holds one sprite throughout. The up-aimed variant still cannot be scripted at all: neither probe has an Up key, and JJ2+ exposes no keyUp. | |
Resolved. The vertical snap used the tile's quarter point where the original uses 15 px down, one above its centre; the horizontal branch had always used the centre. tb_row now holds y = 1327.000 for the whole ride, matching the original digit for digit. See A sucker tube re-arms while you are inside it. | |
Resolved, from a hand-played recording — scripted input cannot reach the flight at all, the same wall the rev-up hit. Vertically the original is three rates: Up climbs at 0.25 px/tick to the 32 cap, letting go brakes the climb at 0.75 to zero, and only then does it fall, at 0.0625 to a terminal of 12. The climb travels 8 px/tick whatever the speed reads. Horizontally it is ordinary ground movement, 4 walking and 16 with Run. It flies until a Fly Off area or a death. Ours hovered with no gravity at all, descended at a fixed 3.429 on Down, ignored Jump, and expired after ten seconds — and EventType::AreaFlyOff cancelled only the airboard, so the very event meant to end it did not. See The flying carrot is two accelerations. | |
fc_carrot measured a dead carrot | Resolved (scenario, not engine). The Fly Off events are along row 49 only, tiles 0 to 15, and the carrot is at tile 18. The scenario opened with its leftward leg, which at 4 px/tick covers those three tiles in about thirty ticks, so the flight was switched off before the first leg ended: the original's trace holds level to tick 90, begins falling at 93 — the tick it crosses x = 512 — and every leg after that measures a fall. It now climbs first, which clears row 49 and leaves the later legs on a live carrot. Both probes re-ordered identically; both need re-running. |
| Pinball paddle: repeated bounces compound | A single paddle launch matches: pb_pad_fall and pb_pad_tap rise 171 and 172 px against 172 and 172, at ys −4 on both sides, and pb_pad_d56 / pb_pad_x2 / pb_pad_x3 and their left-paddle twins agree to a tenth of a pixel. What differs is what happens when Jump is held: the player pumps the paddle, each bounce a little higher than the last, and a per-bounce difference of a few percent compounds. Over pb_pad_hold's 800 ticks the original climbs from y 572 to 0 (the top of the level, reached at tick 740) and ours to 294. Both pump; ours pumps more weakly.This entry previously read as a 2-4x launch weakness and that was wrong. The number it quoted was the envelope of 800 ticks of chaotic bouncing, which is not a launch strength and not a fittable quantity - the offset sweep's ys peaks are the strongest bounce in the window, not the first one, so every scenario that holds Jump measures the pumping rather than the paddle. Measuring the paddle itself needs scenarios that launch once, and the two that do already match.The pumping now has a number, which is what a fix would have to target. Reading pb_pad_hold bounce by bounce, each launch is a fixed multiple of the one before it once the first two chaotic ones are past: the original runs −7.136, −9.715, −12.923, −16.977, −22.218, −29.187, a steady ×1.31, and reaches the top of the level on its eighth bounce. Ours runs −4.595, −5.744, −6.893, −8.042, −9.197, −10.511, −12.025, −13.784 — ×1.145, and four bounces before that it does not grow at all, repeating −3.999 while the original has already started climbing. So it is one ratio against another rather than a launch strength, and the first bounce is identical on both sides at −4.000. Note also that a dozen pb_* scenarios are measuring the missing y = 0 clamp instead of the paddle; see Above the top of the level. |
| Resolved. The lead is aimed by the direction held, which is right, but the key is necessary and not sufficient - the player has to actually be moving. Pressed into a wall the original returns the lead to zero at its full 0.997 px/tick step while the key is still down; ours held +119.4 indefinitely. Keyed on travel rather than on a speed, because ours keeps a phantom 0.4989 px/tick against a wall where the original reads 0.0000. See The lead is aimed by the input, not by the speed. | |
Resolved, and the collision half of it was already right. The test level now carries a ladder of seven 3-tile platforms at tiles 27..29, rows 33, 31, 29, 26, 23, 19 and 15 — found with a new ScanTileEvent() rather than taken on trust, since a scenario aimed at where platforms were described to be measures the empty air beside them. Rising through one, landing on one, and refusing to drop through one with Down held — standing on it or held through the whole fall onto it — all matched to within a few pixels before anything was changed, and the Downwards worry recorded here did not materialise: ow_hop, whose apex sits right at a platform, agrees frame for frame. What differed is the jump test. In the original the platform's own mask still answers "is there ground under the feet", so crossing one with jump held launches a fresh jump and a ladder can be climbed by holding the key; on ow_hold the original climbs all seven rungs to y 334.5 where this engine managed one launch and stopped at row 29. Now 336.6 against 334.5. Jazz2:: | |
| Scripted input lands about two ticks late | Not a movement defect and not fixable in the model, but it decides scenarios and has to be allowed for when designing them. This engine's probe writes a scenario's input on the frame its own tick counter reaches the step, which lands roughly two of the original's ticks behind the original probe's — visible as ow_jump launching at tick 22 against 21 and every later event carrying that offset. It costs nothing where a scenario is measured by where it ends up, and everything where a release tick falls near an event that the key state decides: an earlier ow_jump released at tick 50 with the original crossing a platform at 49 and this engine at 52, and read as a 50 px error that was nothing of the kind. Scenarios whose answer turns on a key edge should keep that edge several ticks clear of the event, or hold the key throughout as ow_hold does. |
| Resolved, and the sidekick's reading of it was wrong in an instructive way. It is not that Run skips the snap — it is that the snap has two targets: the walk cap with nothing held, and 16 px/tick with Run. Spaz's kick leaves at 15.88 and a sucker tube carries 8, so a clamp to 16 is inert at both, which is exactly why the sidekick looked like "no snap while Run is held" and why one measurement could not generalise. The accelerating belt is the only one of the five whose carry is fast enough to tell them apart, and both its strengths do: 18 and 24 px/tick snapping to exactly 16 and then decaying at Jazz2:: | |
Found while measuring the row above, and it is not an ending rule at all. The belt's target rises with Run — the original holds 18 px/tick at strength 4 and 24 at strength 8, against 6 and 12 with nothing held — which is the same 1.5 per unit of strength shifted by a flat 12, or equivalently eight more strength. This engine held 6 and 12 whatever was pressed, so bl_acc_right_run came out 12.6% short and was being blamed on the snap. Two strengths were needed and the second was added for it: one point cannot tell 4 × 4.5 from 6 × 3, and the answer turns out to be neither. Only the target moves; the ramp's step is unchanged, and what differs about the ramp with Run held is that the brake it is applied after becomes the dash brake — visible as the step reading 1.573 instead of 1.878, one Jazz2:: | |
Resolved, and it needed a fourth reading rather than a third strength. The entry compared the two Run cases — reports 18 travels 10, reports 24 travels 16 — and could not tell a cap from a subtraction. What settles it is the ramp: on bl_acc_right_run the travel tracks xs exactly while it climbs, 5.145, 6.719, 8.291, 9.863, and only then flattens at 10 while xs carries on to 18. A subtraction would have started at 10. So it is a cap, and one ceiling fits all four combinations: the belt's own strength plus the walk cap, with the Run bonus left out of it. Run raises what the belt drives to by twelve (Jazz2:: | |
Resolved, and it is the same bug the double jump had. Whether the player counts as falling was read from the live vertical speed rather than from the one at the start of the frame. The original applies its gravity after that test, so a press landing on the exact apex reads 0.0000 and is refused; the live value has already crossed zero and accepts it. On sp_jazz_dj, whose press lands within a tick of the apex, the original refuses and falls the rest of the way at the ordinary 0.125 gravity while ours engaged the copter and held 1.0 px/tick for eighty ticks. Its fall is now 128.3 px against 132, and the tapped copter — which presses afresh while genuinely falling — still engages exactly as before. The double jump was given this rule when it was measured; the copter, three lines away, was not. | |
Resolved. The snap back to the walk cap that ends Spaz's kick belongs to letting go of Run, not to the kick running out — every sidekick scenario in the harness pressed no Run, which is how it was missed. With Run held the original leaves the kick at 15.88 px/tick and decays at a flat 0.4273 px/tick, the ordinary dash brake, covering 664 px against the 456 the snap gave; with a direction held as well it simply carries on as a dash at 16 for ever. sp_side_run now travels 660 px against 664, and sp_side_run_dir 3636.8 against 3636.5. | |
Resolved, and it is a gate after all — the "strength curve" reading came from comparing readings that each varied something different. This entry had sp_upper_downhold at 201.5 px, sp_upper_jumphold at 132 and sp_upper_tap at 8, concluded that three partial results cannot be a gate, and asked for a sweep holding the two keys for a range of lengths. That sweep was built — sp_upper_jh08, sp_upper_jh16, sp_upper_jh24 — and the original gives 8.0 px in all three, exactly what the tap gives. So the hold length changes nothing: what the other readings were varying was how long Jump was held, and 8 px against 132 is simply a tapped jump against a held one. Pressing Down and Jump together never produces the move at all; the player just jumps.Implemented as Jazz2::Actors::Player::IsSpecialMoveCrouchReady(), reading a crouch captured before Jazz2::Actors::Player::HandleLookupAndCrouch() can set it — that runs immediately before Jazz2::Actors::Player::HandleJump() in the same frame, so the live bit cannot tell the two cases apart. The refusal is handled where the sliding one already is rather than in the three character branches, because a refused move still has to jump: gating the branches alone left the player doing nothing and the three scenarios measured 0 px against the original's 8. Now 9.3 px against 8.0 for all four taps and 128.3 against 132.0 held, the latter being the standing jump's own known 4 px (see Standing jump is 3% short). The moves that should fire are untouched: sp_upper_downhold 207.6, sp_jazz_upper 228.2, and every sidekick travel within 4 px of before. This also replaces an earlier entry claiming the original refuses the move while the shooting pose is up — see Down and Jump on the same tick, which was read as a shooting rule for why that reading was wrong. | |
Resolved. dash_start is a speed band between 4 and 8 px/tick, not a transition fired on the change into the dash: as a transition it plays all eight of its frames whatever the player is doing, so the dash pose arrived 47 ticks after the key against the original's 23. See Speeding up is three bands. | |
| Resolved. The original shows it for exactly the rise — 11 ticks, ending on the tick the vertical speed turns positive — where a non-cancellable transition ran the whole animation for 34. The cadence matched already; only the end did not. See The double-jump pose lasts the rise, not the animation. | |
Resolved. Three separate faults on top of each other: a chain of two poses where the original runs three; the two it did have attached to the animations whose names sounded right rather than the ones the original uses (walk_stop is the skid, not the last pose); and an unconditional _inIdleTransition cancel that cut whatever was playing to a single tick. See Sliding to a halt is a chain of three, and only one link is a speed. |
| Resolved, and it turned out to be a mechanic this engine had already measured and was applying to only one of the two things that use it. The cp_mod_* set finally reaches the copter the test level carries at (236,26) — JJ2's COPTER (0xE2), the one a lizard drops and the player hangs from — and the original's vertical under it comes out as two accelerations: hold Up and the speed climbs by exactly 0.25 px/tick, hold nothing and it falls by exactly 0.0625, across 75 and 154 consecutive samples with no exceptions at all. Those are Jazz2::Actors::Player::LegacyFlyRiseAccel and Jazz2::Modifier::Copter alone, so the lizard copter fell through to the Reforged-style block below it and hovered: cp_mod_right held y at exactly 832.000 for its whole flight, and cp_mod_get, pressing nothing, hung there for all 1199 ticks of the window without the ride ever ending. It now shares the branch. The horizontal needed nothing — the original flies at 4.0 px/tick and 16.0 with Run, which are its ordinary walk and dash caps, and ours at the 4.6667 and 18.6667 those convert to. Beware the displacement columns here: read per-tick speeds instead. Totals said the original "sinks while flying right and barely sinks holding Down", which is only where in the level each scenario happened to catch its copter.
The ride's length and its animation were both wrong too, and both are now measured. Making each scenario catch six or seven copters instead of one settled the first: every ride that starts on a freshly spawned copter lasts 279 ticks — in all eight scenarios, whatever is held during it — so it is a plain timer, and the earlier readings of 160 and 135 were rides joined part-way through the object's life. That is a hair under four seconds; ours was set to three and measured exactly the 210 ticks that converts to. See Jazz2::vine_idle, which has one frame, and cp_mod_get held frame 0 for all 279 ticks. The original animates continuously — seven frames, 1368 to 1374 and round again, a new one every 8 ticks, with no still interludes at all — and those seven are vine_idle_flavor, whose FrameRate of 6.25 already works out to exactly 8 ticks a frame. Jazz2::Actors::Player::UpdateHookIdleAnimation() now loops it for a copter where a vine alternates it 70 ticks at a time.
Shooting from one needed two more fixes, and the original's model is three animations rather than one: the hang loop (105, seven frames), a single frame held for the whole burst while firing (107, frame 1378), and a three-frame return out of it (106, frames 1375-1377). Ours had the right assets — vine_shoot_end is three frames — and got the order wrong. First, the four ShootTo return transitions are issued non-cancellable, and only the ground one was in the list of transitions a new shot force-cancels, so firing again inside one left that shot's ending drawn over the new shot: on cp_mod_fire the state went to Hook|Shoot on the tick the burst began while TransitionHookShootToHook was what was on screen for the next ten ticks. All four are now in Jazz2::Actors::Player::ShouldCancelTransitionOnFire(), so the vine and the fall are fixed with it. Second, the flourish has to stand aside during a shot, and neither obvious test for that works: newState never carries Jazz2::UpdateAnimation() computing it; and the current animation does not carry it either at that point in the frame, since UpdateAnimation() has already set the bare movement state and the fire path puts the bit back afterwards. It is read from _fireFramesLeft, which is what the shoot pose itself is keyed on.
Third, and this is what actually made a copter differ from a vine: Jazz2::UpdateAnimation() builds when nothing is carrying the player, and the two flight branches assign their state instead of compositing. A vine reaches that composite through _suspendType and so holds Hook|Shoot from the press until the return transition is issued; a copter did not, so the bit survived only on the frames the fire path itself re-applied it and the pose dropped back to the bare hanging one the moment the key came up — leaving 23 ticks of a still frame before the return. Both flight branches now carry the bit, and cp_mod_fire matches an_vine_shoot step for step: the shot held to the return, the return's three frames, then the hang loop. Modifier::Copter is fixed with it, and it had the same defect — CopterShoot and TransitionCopterShootToCopter exist in the metadata and were reachable for a frame at a time. Ordinary ground shooting was never affected, because it is the case the composite was written for.
Getting a trace of it at all took three tries and each failure is worth recording. The set was a schedule of ticks — hop until 300, test from 440 — and a schedule cannot work when the generator's period is one of the things the two games disagree about: it caught the copter in one and missed it in the other, and what a miss measures is a player falling past where it used to be. They now hop until they are genuinely on one and time everything from that tick. Jump then has to stop immediately, because pressing it there lets go again — the first state-driven version still hopped on its cadence and knocked itself straight back off. And the detector is not the obvious field: jjPLAYER.helicopter is the copter ears and reads 0 for the whole of a copter ride, while position alone calls a player standing on a free copter attached. What says so is the object's own STATE::EXTRA, held for exactly the ride and dropped to STATE::DONE on the tick it ends. The sidekick's ending pose was never seen | Resolved, and the pose was never the problem — the stop chain was. Reported as "cancelled every time, it's not visible". A kick ends with speed still on it and no direction held, which is exactly Jazz2::Actors::Player::IsSlidingToHalt()'s trigger, and the _currentSpecialMove test it already had cannot see the ending because Jazz2::sp_spaz_side_rel had Jazz2::sp_spaz_side_jump gives the original 3 ticks of the pose before the jump pose takes over, against ours at 5. The stop was shorter the slower you were going | Resolved, and it disproved the first model. Reading the three poses as speed bands fitted an_slide_stop alone and gave a light tap no skid at all, since a tap never reaches the top band. Four traces released at 16, 4, 4-on-a-slide-tile and 1.8 px/tick all show the skid lasting exactly 12 ticks, so the first link is timed and only the second boundary is a speed. Reported from play before the second trace was read. See Sliding to a halt is a chain of three, and only one link is a speed. Tapping Run on the spot (superseded) | Implemented from a report; the probe cannot reach it. rt_hold, rt_b2, rt_b4, rt_b8, rt_b16, rt_b8_long and rt_b8_jump tap Run on the spot at cadences from 2 to 16 taps and then stop, since the launch is released by letting go - an earlier family tapped for the whole window and never released, and measured nothing for that reason alone. The corrected family measures nothing either: the original gives xs = 0.0000 and zero travel at every cadence, and its animation column runs 36 → 66 → 37 → 66 with the frame counter creeping about five per forty ticks, which is the idle/bored cycle rather than a wind-up. (That cycle is also why more taps appeared to produce more distinct animations, which is what the first reading of this family got wrong.) So scripted jjPLAYER.keyRun does not reach the mechanic, almost certainly because the original reads the raw key for it, upstream of the script override. It is implemented anyway, at the user's request, in both modes - the threshold and window are theirs, everything else is invented and named RevUp* rather than Legacy* so nothing here can be mistaken for a measurement. See Jazz2::Actors::Player::UpdateRevUp(). Jumping into a 45-degree slope | Resolved, and it was one character. Jazz2::Actors::ActorBase::TryMoveSubstep()'s airborne up-slope climb was gated on abs(stepX) > abs(stepY). A dashing jump travels 8 px across while rising at the applied cap of 8, so against a 45-degree face the two steps are exactly equal and a strict > turned the climb off at precisely the angle it exists for: the fallback loop then ground the horizontal step down to nothing, HitWall was reported, and Jazz2::sl_up45_jhold, the original holds 16.0 px/tick for the whole climb while this engine dropped to 0.9977; with >= it holds 18.6667 throughout, which is the original's 16.0 converted. Live in Reforged too, like the other two collision fixes, because it makes the search cover the range its own comment already claims. See Jumping into a slope, where 45 degrees is exactly the wrong number. A stop pose rode through the loss of control | Resolved, and it is the second instance of the same shape as the rev-up one. Jazz2::Actors::Player::UpdateAnimation() returns early when the player is not controllable, and it dropped _stopPhase without cancelling the transition, which is what is actually on screen. So a skid that began while the player was still steering kept playing for as long as control was gone. Measured on tb_gap5, which coasts into a sucker tube: the chain starts in the approach, the tube takes control a few ticks later, and TransitionDashToIdle then rode the entire nine-tube crossing — the engine ran the stop chain nine times across that scenario where the original holds one pose from the first tube to the last. The early return now performs the same cancel the controllable path already does, which only fires when a stop pose was showing, so an uppercut's wind-up or a warp is untouched. Found by the animation comparison; every position and speed in tb_gap5 was already inside the band. No pose for riding a tube or a pole | Re-measured, and judged acceptable in play — kept as a record rather than as work. The difference is real and the trace is unambiguous: over a whole ride the original holds one distinct pose and this engine holds a generic movement state. tb_down is animation 58 for all 800 of the original's ticks against our 7 (Walk, Run and Jump together) for all 1641 rows; tb_right runs 66/37/36 against our 0/7; ob_vpole runs 82/58/16 against our 8/4/0. But it was looked at in the game and does not read as wrong, which is the only test that matters for something purely cosmetic, and the work is large and partly unmeasurable — see below. Nothing is planned for it unless it is reported from play.
Original description: The original has a curled ball it holds the player in for a pole or tube ride — animation 58, which dominates ob_vpole and covers all 800 ticks of tb_down — and this engine has nothing like it: anim reads 7 (Walk, Run and Jump together) for every tube whatever its direction. The original also shows different poses for different tubes (58 down, 66 and 37 for tb_right, 36 for tb_row) so it is at least three poses against our one, and which one it picks is not yet known. Implementing it needs an Jazz2::README.md — the docs already anticipated this one, under the pinball paddle, which is supposed to hold the same ball. The bored idle animation almost never played | Resolved, and it is the same mechanic as the vine flourish at exactly twice the period. Everything for it was already here — IdleBored1..IdleBored5 and TransitionIdleBored — with the threshold at 600 frames, which is 700 of the original's ticks. The original plays one every 140. Three long scenarios agree to the tick: bl_acc_right, bl_right and tb_right all hold the idle pose for 141 sampled ticks between flourishes, and the flourishes run 104, 136, 189 and 216, different lengths because several animations exist and one is picked at random exactly as here. g_stand shows what the old figure cost: 250 ticks of standing still, the original plays a flourish at 196, and this engine never played one — a 250-tick scenario cannot reach 700. The gap is also measured from the previous flourish finishing, not from it starting: letting the timer run underneath one makes the next gap depend on how long that one happened to be, which gave 93 ticks after a short flourish and 299 after a long one. bl_right now reads a steady 142 ticks against the original's 141. Both halves are gated, unlike the vine cycle: 600 frames is a deliberate Reforged behaviour rather than an animation nothing read. See Jazz2::Actors::Player::LegacyIdleBoredTime. A special move begun while still sliding | Resolved. The crouch and the move it leads to do not share a condition: the crouch begins the moment no direction is held, whatever speed is still being carried, and the move needs that speed gone. Measured on all three characters with Down held through a dash and Jump four ticks later — the original's specialMove never leaves 0, and what happens instead is an ordinary jump at −13.08 (Jazz2::Copter: when it engages, not how fast it descends | Resolved. Reported for Jazz and Lori as a descent speed that is static here and variable in the original. The descent speed was never the difference: where the original's copter engages it holds ys at 1.0078 px/tick, which is Jazz2::cp_tap55..cp_tap80, five scenarios sharing one jump and differing only in when the tapping starts — all rise at tick 46 and fall from 61. Taps from 65 engage 4 ticks into the fall and taps from 70 at 9; sp_jazz_copter engages at 15 ticks and 1.875 px/tick; taps from 80, nineteen ticks in at 2.375, are refused and so is every later press of that airtime, eleven of which land at 0.375 to 1.875. Taps from 55 and 60 are refused the same way, having spent the attempt while still rising. All five now match, and the two that engage do so on the same tick as the original. The attempt is taken in Jazz2::Actors::Player::HandleJump() on the press itself rather than where the copter is engaged, because _jumpTime hides a press made within ten frames of a jump from that code entirely — cp_tap55's first tap is exactly such a press, and without this the engine never saw it and happily coptered off the next one. sp_jazz_copter went from three engagements in 800 ticks to one, against the original's one; sp_lori_copter likewise, on the same tick. Bound: 2.0 px/tick, a round figure inside the measured bracket of 1.875 engaging and 2.375 refused — only the bracket is measured. See Jazz2::Actors::Player::LegacyCopterEngageMaxSpeed. Copter scenarios diverge after the first flight | Not a gap, and recorded so the same false trail is not followed twice. sp_jazz_copter_fwd and sp_lori_copter_fwd tap every 6 ticks for 800 and the original engages the copter twice — at 76 and again at 159 — where this engine copters once. That reads as a missing rule about the attempt coming back when a flight expires, and 83 ticks is almost exactly the 70 frames a flight is armed for, which makes it read like a good one. It is not: by tick 149 this engine has landed, at y=1328.7, while the original is still airborne at 1277 having jumped again. The two engage once each, within a tick of one another, and everything after that belongs to trajectories that had already parted. The lesson is the one the copter entry above already carries in a different form — read the positions before treating a difference in event counts as a difference in rules. Ordinary fall terminal | Measured on the way to the above and worth keeping: dropped into clear air the original accelerates at one Jazz2::sp_copter_deep, ticks 100-120) which is the same terminal the flying carrot's fall uses. Nothing has been checked against it yet; it is recorded because the trace exists and the number is clean. Lori's kick does not repeat | Resolved. Reported as "the sidekick should repeat as long as the keys are still pressed", and it is a timer rather than a second press. sp_lori_side_hold holds Down and Jump for the whole scenario and the original kicks at ticks 41, 76, 111, 146 ... 566 — twenty-two times in 800, every gap exactly 35, no variation at all. This engine re-kicked on a fresh press, which is why a worked Down+Jump looked right and a held one did nothing: one kick of 204.75 px and then 735 ticks of standing still, against 3636.75 px, which is the far wall. One kick already travelled the same distance in both (204.75 against 204.5) so only the repeat was missing and Jazz2::sp_lori_side, which holds the same keys and had been quietly under-kicking for as long as it has existed: 204.8 px before, 1228.5 after, against the original's 1227.0. Spaz's sidekick and everything else in the family are untouched. See Jazz2::Actors::Player::LegacyLoriKickPeriod. Lori's kick flew off slopes instead of following them | Resolved, and it corrects a reading in the entry above. Reported as "it should stick to down slopes, but not if there is really a gap". Jazz2::Actors::Player::BeginLoriKick() cleared both Jazz2::sp_lori_side_hold, which kicks across the long slope at tiles 193-210, this engine ran 133 px above the original by tick 340 before catching up at the bottom. The original's animation reads 229 — the kick — for the entire descent and never once shows a fall. Outside Reforged both flags now stay set and the ordinary ground movement does the rest; y tracks the original to within 2.4 px for the whole slope and the trace contains no falling ticks at all, against 22 kicks and a final position 0.24 px apart. A real gap needs no special case: Jazz2::CanJump by itself the moment there is space below. What this corrects above is the claim that the original kicks through the air — it does not; that stretch is the slope, and the guard is back on the repeat. Lori's kick cannot be started in mid-air | Verified against the original, and this engine already matched. The move needs the crouch, and the crouch cannot be entered off the ground, so an airborne Down+Jump becomes a buttstomp instead — which is exactly what the original does: sp_lori_butt presses both in mid-air and its animation runs 226, 204, 176, 225, 177, never reaching 229. Recorded because the repeat briefly did not honour it while the slope behaviour above was misread as airborne kicking. Lori's kick was sliced into three animations | Resolved. The original plays one animation for the whole kick — her sidekick.aura, nine frames at about four ticks each, id 229 throughout — and it lasts 35 ticks, which is exactly the repeat period. The animation is the cycle: the drive is its first four frames, where xs ramps 1.0 to 42.25, and the rest is recovery before the next kick begins on frame one again. This engine cut the same file into three states — SidekickA (frames 0-1), Sidekick (2+), SidekickC (9+) — and played them in sequence, so the kick pose was gone after 20 ticks and she stood in the crouch pose for the other 15 of every cycle. A new SidekickFull plays the nine frames straight through at the rate the original's timing asks for, bound to Jazz2::TransitionUppercutEnd that used to follow is suppressed for her as well, since her one animation has already run its own tail. Frames now step at ticks 41, 45, 49, 53, 58, 61, 66, 69, 74 and wrap to frame 0 at 76, against the original's 41, 43, 47, 51, 55, 59, 63, 67, 71 and 76 — a continuous cycle with no gap, where the sliced version left one every time. Lori's kick bounced her off a pushable | Resolved, and it was a sign, not a collision. Reported as being thrown backwards off a pushable rock and ending further away than she started stopping. It only ever happened kicking leftwards, which is why the first two scenarios aimed at it saw nothing: sp_lori_side_rock and sp_lori_side_rock_far both approach from the left and were clean on both sides. sp_lori_side_rock_left approaches from the right and reproduces it exactly — the engine reaches x 2811.3, stops, and the very next tick reads xs +48.15 out of nowhere, throwing her to 2859.5, where the original stops at 2816.3 and stays. The cause is one call: her ramp was applied as std::copysign(ramp, _speed.X), and anything that stops her mid-kick zeroes _speed.X first. copysign reads positive zero as positive, so the next tick relaunched the whole ramp rightwards whatever direction the kick was going — invisible on a rightward kick, because that is the sign it invents. The direction now comes from which way she is facing, which is what Jazz2::Actors::Player::BeginLoriKick() already uses for the launch itself. She ends at 2811.4 against the original's 2816.3, with no backwards step on either side. Lori's kick does not push a pushable | Resolved, and it was never a special rate. Her kick pushes at the ordinary Jazz2::_controllable is false for the whole of a special move and _isActivelyPushing wants a direction key a kick does not use. What is allowed instead is the kick while it still has budget — the recovery that follows shoves nothing — and a blocked kick spends that budget on what it meant to travel rather than the crawl it manages, or it never ends at all. 22 bursts against 22, starting on the same ticks, 12-tick bursts of 4.7 px against 4.5. See A kick shoves a pushable, at walking pace. Spaz's kick against a pushable is unmeasured | Resolved. Reordering the four rock scenarios so the one that shoves it runs last freed his, and he pushes too: 19.1 px in a single kick against our 21.2. His is the opposite case to hers — his budget runs out exactly as his move ends, so he pushes the whole time (4 ticks of drive, then 51 of pushing, and 440 px at his capped 8 px/tick is 55), where she spends hers in twelve and waits out the recovery. The reorder cost one lesson of its own: the reset does not restore the facing, so the scenario that ran last inherited a leftward one and kicked a thousand pixels away from the rock, on both sides at once and therefore in perfect agreement. Every sidekick scenario now taps its direction for two ticks first. Shooting during Lori's kick | Resolved once both probes logged the ammo. The original spends a round at tick 43, four ticks into the kick and still accelerating through the ramp (45 → 44 on sp_lori_side_fire); we spent none. The cause is one early return: Jazz2::Actors::Player::OnHandleMovement() bails out whenever the player is not controllable, and that return sits before HandleWeaponFire() — so losing control, which is exactly what a special move does, silently disarmed the whole kick. A non-Reforged sidekick is now excused from it, with _controllableExternal still honoured so a cutscene or a warp goes on blocking the shot. Note the measurement was impossible until the two probes agreed what objx meant for that scenario: this engine's range was open-ended and the original's stopped short, so one side was logging ammo and the other a pushable. Warp control delay | Resolved, and it was the first of these three the level could not hold until it was extended. The original lands, shows one frame of its warp-out pose, and is walking on the next tick — two ticks in all. Ours waited for the whole TransitionWarpOut animation, 38 ticks. Control no longer waits on the animation; it is now 5 against 3, the rest being frame quantisation. See A warp hands control back almost at once. A special move started on top of a monitor drops the player | Resolved. Reported for both characters — Spaz's sidekick from atop a powerup, and Jazz's uppercut — as the player falling to the floor before the move takes effect. The cause is one predicate: Jazz2::Dropping off a vine accelerates too slowly | Resolved, and it is not an acceleration difference at all. ob_vine_drop was added for it — grab the low vine, hang until it has certainly settled, let go with Down — and both games then fall at exactly the same rate. What differs is where the fall starts: the original assigns 4 px/tick and lets the ordinary gravity take over, reading 4.125 two ticks after the release and adding one Jazz2::CompareTraces.ps1 now compares them, and the first full cross-game pass says 71 of 355 scenarios pass through a materially different number of poses. Two corrections were needed before that number meant anything: what this engine draws is the transition when one is playing and the base animation otherwise, so comparing our anim against the original's curAnim undercounts every pose the stop chain draws over the base (g_dash_rel reads 4 against 7 that way and 7 against 7 with the transition folded in) and poses shorter than four ticks have to be dropped, because the two traces do not have the same number of rows for the same scenario. With both applied the plain scenarios agree exactly (g_walk 1/1, g_dash 3/3, a_jump 7/7) so the 72 are real. The largest are the float-up columns (fu_col_none, fu_col_rel, 46 against 75) the sucker tubes (tb_gap5 22/7, tb_up 42/29) and Spaz's sidekick with Run (sp_side_run 8/25) none of which is a trajectory problem — all three match on travel. Reading which poses differ means going through the two traces by hand, one family at a time, the way the stop chain was done. Reported versus travelled speed | Three places report a speed that does not match how far the player actually moves (the uppercut by a constant 0.84, the RF blast fired against a wall by 1.6875, and a pole launch by 4.5%) and no single mechanism explains them. All three are matched on travel, which is what a player sees; the pole one is the clearest case that the two can be measured separately, since its trajectory agrees to 0.15% while its reported launch is a full point apart. Double jump out of a horizontal spring | Resolved. Isolated by elimination — a spring launch with no jump holds its speed on both sides, and a dashing double jump with Run held holds 16 on both, so only a spring launch then a double jump diverged: we rebuilt the discarded speed at the ordinary air acceleration and stopped at the walk cap of 4, where the original rebuilds faster and runs to 16. Three springs gave the rule, and the third tested a prediction rather than adding a point: red launches at 16 and rebuilds at 0.50 px/tick², green at 24 at 0.75 — both the launch over Jazz2::A horizontal spring's launch speed is never clamped | Resolved, and it is the same clamp three other carries already end with. The original holds the spring's own figure — 16, 24, 32 for red, green and blue — for exactly four ticks and then clamps to Jazz2::Leaving a horizontal pole | Resolved, and it was two faults rather than the one this entry predicted. Everything about the timing was already exact — ob_spring_hpole grabs on tick 55 against 53, holds seventy ticks against seventy, releases at 125 against 123, carries for seventy more and ends by snapping to the walk cap, all within the usual two-tick input offset. What differed is what the release does. The launch is travelled in full: the original reports 20 px/tick and moves 20, where this engine held it to Jazz2::v + 8 capped at 20, not 3v clamped between 8 and 20. See the callout under Poles obey two different laws. Invisible until now because every pole scenario fast enough to expose it ends against the same wall in both games.
The changes most likely to feel wrong despite measuring right, worth knowing before playtesting:
- the dash's actual ground speed is halved — the rest of it is momentum, not motion
- Lori's kick is a 13-tick ramp rather than a flat burst
- the buttstomp drift is much slower than 3.8.0's, and its descent no longer accelerates, so a stomp entered from a long fall now arrives 20% slower than it used to
- bouncing off a stomped enemy is more than twice as strong (−910 px/s against 3.8.0's −420) which is the largest single change in how a manoeuvre reads
- a spring always sends the player its full distance now, whatever they did on the way in, where before a released jump beforehand quietly cost a third of it
- the camera leads far less — 28 px at a walk against Reforged's 80 — and it arrives on a straight ramp and stops, rather than easing in for two seconds. This is the single most visible change in the whole page and it is exact; expect it to read as "the camera stopped working" before it reads as right
- nothing that carries speed the player did not ask for pans the view any more, the sidekick most of all
How this was measured
A trajectory probe drives the player through a fixed matrix of ~145 scenarios with scripted input and logs position, speed and input every tick, in both games, so runs can be compared row for row. The whole harness lives in Sources/Jazz2/Tests — both probes, the test level, captured runs of each game, and the scripts that extract and compare them — and its README.md is the practical entry point.
- Original JJ2:
Tests/Level/_pt.j2as, a JJ2+ AngelScript.jjPLAYER.keyLeft/keyRight/keyRun/keyJump/keyFireare writable,onPlayerInputruns before movement, andjjSTREAM::write+savewrites the log to disk. - This engine: native C++, Jazz2::
Tests:: PhysicsProbe, gated behind the WITH_PHYSICS_PROBEbuild option (on in the Debug configurations ofSources/Jazz2.vcxproj) and enabled at run time with/physics-probe, logging one CSV line per tick. Its tick counter runs at the original's 70 Hz, so every scenario step lands at the same real time at any frame rate — which is what makes a 24 FPS run comparable to a 144 FPS one.
The scenario matrix covers each input alone and in combination — held, released early, released late, released and re-pressed, reversed, reversed and released, reversed back — on the ground and in the air, with and without Run, plus every object and special move. Both probes carry a millisecond column, because the two tick rates differ and comparisons must be made on real time, never on row numbers.