Two mcp-debugger instances run simultaneously:
- Port 3001 -- Docker container (
docker.io/debugmcp/mcp-debugger:latest),--network host, workspace mounted at/workspace. Provides Python, JavaScript, and Java/JDI adapters. - Port 3002 -- Host npm package (
@debugmcp/mcp-debugger). Provides Python adapter using the host's Python environment (where py_ballisticcalc is installed from.ai/sources/).
Java adapter requires the Docker image because the npm package ships
without a compiled JDI bridge (JdiDapServer.class). Building from
source (pnpm --filter @debugmcp/adapter-java run build:adapter) also
works but Docker is simpler.
Both use Streamable HTTP transport. Communication is via curl:
# Helper script: .ai/scripts/mcp.sh <port> <session_id> <json_rpc_body>
curl -s -X POST "http://localhost:${PORT}/mcp" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
-d "$BODY" | grep '^data:' | sed 's/^data: //'Script: .ai/scripts/debug_py_step.py -- calls calc.fire() with
hardcoded fixture inputs (seed=42, fixture 0: .223 Rem, G7, BC=0.209).
This exercises the full production path: set_weapon_zero (zero-finding)
then engine.integrate (trajectory).
Breakpoints use conditions to distinguish zero-finding from trajectory:
filter_flags == 0-- zero-finding callsfilter_flags != 0-- trajectory callsintegration_step_count == N-- specific step number
Class: core/src/jvmTest/kotlin/.../DebugOneStep.kt -- constructs
BallisticInput with the same fixture parameters, calls
solver.findZeroAngle() then solver.computeTrajectory().
Launched with JDWP:
noglob java \
"-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5005" \
-cp "$CP" org.openballistics.engine.DebugOneStepmcp-debugger attaches via create_debug_session with
host=localhost, port=5005.
Breakpoints use FQCN org.openballistics.engine.TrajectorySolver and
line numbers. To separate zero-finding from trajectory, breakpoints
are placed at lines unique to each code path:
- Line 271 (
simulateTrajectory) -- zero-finding - Line 142 (
integrate) -- trajectory
Breakpoints: Python at rk4.py:185 (after initial state setup, trajectory phase), Kotlin at TrajectorySolver.kt:136 (same logical point).
| Value | Python (metric) | Kotlin (metric) | Delta |
|---|---|---|---|
| pos.x | 0 | 0 | 0 |
| pos.y | -0.08999122716083738 | -0.08999122716083738 | 0 |
| pos.z | -0.00125659623052308 | -0.00125659623052307 | ~1e-17 |
| vel.x | 750.528517415018 | 750.528513793092 | 3.6e-06 |
| vel.y | 48.593784592316 | 48.593822839031 | -3.8e-05 |
| vel.z | 0.019115455799 | 0.019115990798 | -5.3e-07 |
| barrel_elev | 0.064655840669 | 0.064655891727 | -5.1e-08 |
| barrel_az | 0.000025469326 | 0.000025470039 | -7.1e-10 |
| cant_cos | 0.999902524009 | 0.999902524009 | 0 |
| cant_sin | 0.013962180339 | 0.013962180339 | ~1e-17 |
| look/slope_rad | 0.062831853072 | 0.062831853072 | ~1e-16 |
| sight_height | 0.09 | 0.09 | 0 |
| bc | 0.209 | 0.209 | 0 |
Conclusion: Velocity diverges because barrel elevation differs by 5.1e-08 rad. barrel_elevation = slope + cos(cant) * zeroAngle. Since slope and cant match, the zero angles differ by 5.1e-08 rad.
Changed Kotlin test to use fixture.zero_angle_rad (with full
precision, not rounded) instead of solver.findZeroAngle().
Results: errors dropped from ~1e-2 cm to ~1e-6 cm. Only drop still failed (velocity and windage passed). Remaining drop error at 2200m was 3.2e-05 cm (31.8x over 1e-6 tolerance).
Conclusion: zero angle difference is the primary error source (~99.9%). There is a residual per-step drop error that compounds over distance.
Important: zero_angle_rad is an output, not an input. The
Kotlin engine must compute its own zero angle and match py's value.
The experiment was diagnostic only to separate zero-angle error from
RK4-step error.
To eliminate the zero-angle difference as a confound, compared one RK4 step during zero-finding (both engines start with barrelElevation=0, no wind, no coriolis, ICAO atmosphere).
Breakpoints: Python at rk4.py:235 (before velocity update, step 2), Kotlin at TrajectorySolver.kt:314 (return statement, step 2).
| Value | Python (metric) | Kotlin (metric) | Delta |
|---|---|---|---|
| x | 1.87859722387 | 1.87859722102 | 2.9e-09 |
| y | -0.09003062783 | -0.09003062783 | ~3e-17 |
| vx | 750.778551573 | 750.778551433 | 1.1e-06 |
| vy | -0.024495088099 | -0.024495088099 | ~3e-14 |
| density_ratio | 1.0004255875331 | 1.0004255875331 | 0 |
| km | ~equivalent | ~equivalent | ~1e-15 |
Key observation: vx diverges by 1.1e-06 but vy matches to 1e-14. Position x diverges by 2.9e-09 (accumulated from vx), position y matches perfectly. This pattern -- x diverges, y does not -- is unexpected because both components use the same formula.
| Value | Python (metric) | Kotlin (metric) | Delta |
|---|---|---|---|
| a1.vx | -528.0651954 | -528.0651938 | -1.6e-06 |
| a1.vy | -9.78942165338 | -9.78942165340 | 2.5e-11 |
| a2.vx | -527.1370583 | -527.1370567 | -1.6e-06 |
| a2.vy | -9.78083754764 | -9.78083754768 | 3.8e-11 |
| a3.vx | -527.1386889 | -527.1386873 | -1.6e-06 |
| a3.vy | -9.78084504821 | -9.78084504824 | 3.7e-11 |
| a4.vx | -526.2129903 | -526.2129888 | -1.6e-06 |
| a4.vy | -9.77228352435 | -9.77228352440 | 4.9e-11 |
The x-acceleration diverges by ~1.6e-06 consistently across k1-k4. The y-acceleration matches to ~1e-11.
Evidence: scripted analysis showed max floating-point difference from unit conversion is ~1e-14. Imperial RK4 rewrite proved the unit system is not the cause (made things 1e+8 worse). The Mach number computed via Rankine vs Kelvin constants matches to 1.5e-14.
Evidence: Kotlin DRAG_CONSTANT = 0.3048 / 2.08551e-04 = 1461.513....
Python 2.08551e-04. Both use the same underlying value. The km
values match to ~1e-15 when unit-converted.
Evidence: Python -32.17405 ft/s^2. Kotlin 32.17405 * 0.3048 = 9.80665 m/s^2.
Both standard g. Acceleration in y matches to ~1e-11, confirming
gravity is identical.
Evidence: cd_from_table values match to 1e-13 (from earlier
instrumentation experiment, dimensionless values).
Evidence: Both use 7.2921159e-5 rad/s. sinLat, cosLat, sinAz,
cosAz all match. Coriolis is disabled during zero-finding anyway.
Evidence: 49.0223 * sqrt(R) (imperial) and 20.0467 * sqrt(K)
(metric) differ by ~4.6 ppm. However, this affects BOTH Python and
Kotlin equally (both have the same base/altitude inconsistency).
Mach number computed via either path matches to 1.5e-14 when properly
unit-converted. Changing Kotlin to use 20.0467 * sqrt(K) made
things WORSE (1.6e-03 m/s error) because Python uses the Rankine
formula for base speed of sound.
Evidence: During zero-finding, wind is zero. For trajectory, the decomposition was verified: clock 4 -> headwind=-0.15 m/s, crosswind=0.2598 m/s in both engines.
Evidence: py /12 (inches to feet), kt *0.0254 (inches to meters).
Both convert from inches, producing equivalent physical results.
Evidence: density_ratio matches exactly (0.0 delta) between
engines, confirming both compute identical air density.
Python's Velocity unit uses 3.2808399 for fps-to-mps conversion,
NOT the exact 1/0.3048 = 3.2808398950131.... Delta: 4.99e-09.
Evidence:
>>> Velocity.MPS(1.0) >> Velocity.FPS
3.2808399
>>> 1/0.3048
3.2808398950131This means v0_fps = 752.1 * 3.2808399 = 2467.519688790 in Python,
while 752.1 / 0.3048 = 2467.519685039. Delta: 3.75e-06 fps.
This 3.75e-06 fps offset in v0 propagates through:
- The drag computation
k_m * v * |v|→ 1.6e-06 acceleration offset - Zero-finding convergence → 5.1e-08 zero angle difference
- Every RK4 step → cumulative position/velocity drift
The DRAG_CONSTANT converts py's imperial drag constant to metric.
Original: 0.3048 / 2.08551e-04 = 1461.5130112059 (uses exact 0.3048).
Should be: 1 / (2.08551e-04 * 3.2808399) = 1461.5130089843922
(uses py's constant).
Fix applied but test results unchanged (1047 failures). The
DRAG_CONSTANT affects km by 2.2e-06 relative, but the velocity
offset through v0_fps affects the entire initial state setup.
With barrelElevation=0: vx = v0 (large), vy = 0. The drag force
is km * vAir * |vAir| where vAir ≈ (v0, 0, 0). So ax ≈ km*v0^2
(sensitive to v0 errors) while ay ≈ 0 - G (insensitive, dominated
by gravity). Oracle consultation confirmed this analysis.
- py_ballisticcalc uses
3.2808399(not1/0.3048) as its ft/m - ALL imperial values in py carry a
~5e-9relative offset - Kotlin must either: a. Use the same constant everywhere, OR b. Work entirely in imperial like py does
- Currently:
Atmosphere.ktuses3.2808399butTrajectorySolver.ktuses3.2808398950131-- inconsistent
-
Changed DRAG_CONSTANT from
1461.5130112059(0.3048-based) to1461.5130089843922(3.2808399-based). Result: test failures unchanged (1047). The constant alone is not enough. -
Changed M_TO_FT from
3.2808398950131to3.2808399. Result: made things worse (same failure count but different delta profile). The M_TO_FT constant is used for altitude/pressure computations where the exact value matters. -
Changed gravity from
32.17405 * 0.3048to32.17405 / 3.2808399. Result: didn't compile as const val; when hardcoded, negligible impact since gravity affects y-component which already matches.
All changes reverted. Working tree clean.
The problem is systemic: Python does ALL arithmetic in imperial using
3.2808399 for conversions. The intermediate results (v_fps, mach_fps,
k_m, acceleration_fps2) all carry the ~5e-9 relative bias from this
constant. These biases are self-consistent within the imperial system.
Kotlin works in metric. It converts to feet only for atmospheric lookups. The metric intermediate values are computed with exact constants (0.3048, 1/0.3048). There's no single constant to fix -- the issue is the unit system boundary itself.
The clearest path to parity:
-
Step 0 verification: Capture the complete state at t=0 (before ANY RK4 step) from both debuggers and verify they're identical. If they match exactly, the per-step error is ~1.6e-06/528 ≈ 3e-9 relative, which over 1000+ steps compounds to the observed 1e-6 to 1e-2 cm errors.
-
Candidate approach: Make the Kotlin engine's drag computation replicate Python's arithmetic exactly. Compute drag in imperial (using py's constants) then convert the result to metric. This isolates the conversion to one place rather than having it spread across multiple constants.
Concretely: inside
rk4Step, compute km asdensityRatio * cd * 2.08551e-04 / bc(same as py), use it with velocities converted to fps (v * 3.2808399), compute acceleration in fps^2, then convert back to m/s^2 (/ 3.2808399). -
Alternative: Accept that metric vs imperial will always produce ~1e-9 relative per-step error, and tighten the zero-finding algorithm to converge to the same value as py's (which absorbs the systematic drag error into the zero angle). This might achieve 1e-6 parity without rewriting the drag computation.
Kotlin's zero-finding used linear interpolation at the target
distance. Python's _integrate uses PCHIP (3-point cubic Hermite)
interpolation. This caused the zero angle to converge to a different
value by ~5.1e-08 radians.
Fix: Added prevPrevState tracking in simulateTrajectory and
call pchipInterpolateAtDistance when 3 points are available.
Result: barrel elevation difference dropped from 5.1e-08 to 1.5e-12 radians. Test failures: 1027 -> 253.
Kotlin's DRAG_CONSTANT used 0.3048 / 2.08551e-04. Python uses
2.08551e-04 directly with velocities in fps (using 3.2808399
as ft/m, not exact 1/0.3048). Gravity used 32.17405 * 0.3048
but should be 32.17405 / 3.2808399 to match py's arithmetic.
Fix: Compute km = density * cd * 2.08551e-04 * 3.2808399 / bc
(matching py's conversion path). Gravity: G = 32.17405 / 3.2808399.
Result: Marginal improvement (~1.5 ppb per step reduction).
All failures are drop-only, scaling with distance:
- Max delta: 4.6e-5 cm at 2200m (.338 Lapua Mag)
- Typical: 1-5e-6 cm at 600-800m
py_ballisticcalc uses different conversion constants for different unit types:
| Unit type | py constant | Kotlin constant | Match? |
|---|---|---|---|
| Velocity (fps/mps) | 3.2808399 | N/A (metric) | N/A |
| Distance (ft/m) | 39.37007874 in/m (exact) | 3.2808398950131 | exact |
| SOS (fps) | 49.0223 * sqrt(R) | 49.0223 * sqrt(R) / 3.2808399 | yes |
| SOS (m/s) altitude | 20.0467 * sqrt(K) | 20.0467 * sqrt(K) | yes |
| Drag magic | 2.08551e-04 | 2.08551e-04 * 3.2808399 | yes |
| Gravity | 32.17405 fps^2 | 32.17405 / 3.2808399 m/s^2 | yes |
| Coriolis | omega * v_fps | omega * v_mps | equivalent |
Debugger comparison at step 2 of trajectory (after PCHIP fix):
| Value | py (metric) | kt (metric) | delta |
|---|---|---|---|
| x | 1.874704473715650e+00 | 1.874704473715473e+00 | 1.8e-13 |
| y | 3.135813158212049e-02 | 3.135813144808181e-02 | 1.3e-10 |
| z | -1.209663359953115e-03 | -1.209663361824947e-03 | 1.9e-12 |
| vx | 7.492358061240687e+02 | 7.492358061239977e+02 | 7.1e-11 |
| vy | 4.848575743791208e+01 | 4.848575743901033e+01 | -1.1e-09 |
| vz | 1.843123267766896e-02 | 1.843123269293303e-02 | -1.5e-11 |
The y-position has the largest delta (1.3e-10), which compounds over ~1000 steps to the observed 1e-5 to 5e-5 cm errors.
Root cause: TBD. Need to compare k1-k4 within this step to find which substep introduces the y error.
Both mcp-debugger instances are still running:
- Port 3001: Docker (Java/JDI)
- Port 3002: Host npm (Python/debugpy)
Scripts in .ai/scripts/:
mcp.sh-- generic MCP HTTP call helperdebug_py_step.py-- Python debug target (callscalc.fire())dual_debug.py-- dual-debugger comparison driver
JVM debug target: DebugOneStep.kt (untracked, in jvmTest)