diff --git a/docs/hardware-porting-plan.md b/docs/hardware-porting-plan.md
index 6ad6e6e..0844a98 100644
--- a/docs/hardware-porting-plan.md
+++ b/docs/hardware-porting-plan.md
@@ -297,7 +297,7 @@ commit `aab1925`.
|---|---|---|
| 0 | Remote-flash question researched — two viable paths, design rule derived (§2) | **done, and now measured on the board: neither works** (§2). First flash needs the MicroPython REPL or BOOTSEL; remote *update* is unsolved pending a `BOOTSEL` command |
| 2 | **Phase 2 gate: RP2040 + TMC5160-BOB at 3.3 V** | **PASSED** on tower 121, 2026-09-15: `IOIN Y=0x3000001E version=0x30`, encoder reading (`X_ENC`), one axis, `speed=3000` |
-| 3 | Yaw homing on the real drivetrain | **BLOCKED — no endstop found.** See below |
+| 3 | Yaw homing on the real drivetrain | **done** — the endstop was being sheared through at full torque; homing at 189 mA holds. See below |
### Phase 3, first attempt — 2026-09-16
@@ -314,17 +314,49 @@ direction without the encoder deviation ever exceeding ~100 counts** against the
1500 that marks a jam. The motor met no resistance at any speed.
Homing detects an endstop by the shaft falling behind the commanded position, so
-it cannot work if nothing stops the shaft. Two candidates, indistinguishable
-over the wire because the encoder sits on the motor side:
+it cannot work if nothing stops the shaft. Two candidates were considered,
+indistinguishable over the wire because the encoder sits on the motor side: no
+hard stop at all, or an output not following the motor.
-1. **No hard stop on this yaw** — it rotates continuously, and stall-based homing
- is the wrong strategy for this rig.
-2. **The output is not following the motor** — a slipping coupling or a loose
- grub screw. The encoder would track the motor perfectly either way.
+### RESOLVED, 2026-09-17 — the stop was there, and losing
+
+Neither. **The endstop is a screw head meeting another screw head, and the raised
+gear ratio gave the motor enough torque to shear one past the other.** The stop
+was resisting; it simply lost, every time, and a stop that yields is invisible to
+a detector watching for the shaft to fall behind.
+
+The fix is to make the search weak enough that the screw wins:
+
+| | |
+|---|---|
+| Homing current | **189 mA** (IRUN 7 at GLOBALSCALER 80, 75 mΩ sense resistor) |
+| Run current | 756 mA — unchanged, because the gear ratio exists for wind stability |
+| Measured travel | **1,210,778 counts**, repeatable to 0.27 % across three homes |
+| Free-running encoder gap | ~200 counts against the 1500 latch, and it does **not** scale with speed |
+| Full homing | ~2 min at VMAX 30000 |
+
+Declared per tower in `hardware.ini` as `[Motor.yaw] homing_current_ma`, converted
+to registers by the host and sent as `CURRENT` before every home — so retuning it
+is a restart, not a reflash. See [configuration.md](configuration.md) for the keys
+and `firmware/docs/hardware-notes.md` for the mechanism.
+
+Three things this exposed, all since fixed:
+
+- **`steps_per_rev` was inert**, still carrying the Delta's 177,000 against a real
+ turn of ~1.2 M, so it passed at 14 % of a turn. It is the only guard against a
+ *phantom* endstop, which reducing the current makes reachable.
+- **`STOP` did not end a homing** — it halted the motor but left the state machine
+ waiting for a stall that could no longer come, until the timeout.
+- **The host abandoned homing after a hard-coded 65 s**, which this drivetrain
+ needs more than. Now `[Motor] homing_timeout_s`.
+
+What the investigation looked like before it was resolved
**Resolving it needs a physical observation**: did the gimbal itself rotate ~1.8
turns, or did the motor spin while the platform stayed put? Check any cabling
through that joint before moving the axis further.
+
+
| 2 | `src/platform.h` — reset cause and free RAM behind one interface per MCU family | **done** |
| 2 | `Serial.begin()` moved ahead of all hardware init, so a wedged image stays flashable | **done** |
| 2 | `[env:iota_rp2040]` PlatformIO target (arduino-pico core, 8 MB flash) | **done and built** on tower 121, 2026-09-15: 49452 B flash (0.6 % of 8 MB), 9068 B RAM. `leonardo` still builds (19254 B) and the 22 native tests still pass |
diff --git a/docs/modules-reference.md b/docs/modules-reference.md
index d555cd2..3b764ad 100644
--- a/docs/modules-reference.md
+++ b/docs/modules-reference.md
@@ -14,6 +14,7 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [include/fgc/TelemetryParser.h](../include/fgc/TelemetryParser.h), [src/core/TelemetryParser.cpp](../src/core/TelemetryParser.cpp) | `parseTelemetryLine` (firmware `ST` line) → `std::optional` |
| [include/fgc/CommandParser.h](../include/fgc/CommandParser.h), [src/core/CommandParser.cpp](../src/core/CommandParser.cpp) | `parseCommand` whitespace tokenizer → `Command` |
| [include/fgc/HelpText.h](../include/fgc/HelpText.h), [src/core/HelpText.cpp](../src/core/HelpText.cpp) | Operator command catalog (`helpCatalog`) + `renderHelp` (console & TUI help) |
+| [include/fgc/TmcCurrent.h](../include/fgc/TmcCurrent.h), [src/core/TmcCurrent.cpp](../src/core/TmcCurrent.cpp) | Milliamps → TMC5160 `GLOBALSCALER`/`IRUN` register fields, using the sense resistor from `[Motor.]`. Pure arithmetic, so it is unit-tested; the host owns the conversion because only it knows the resistor |
| [include/fgc/DumpParser.h](../include/fgc/DumpParser.h), [src/core/DumpParser.cpp](../src/core/DumpParser.cpp) | `parseDump` firmware `DUMP` block → `DumpData` (per-axis state + decoded TMC registers/flags); `formatDump` |
| [include/fgc/DiagParser.h](../include/fgc/DiagParser.h), [src/core/DiagParser.cpp](../src/core/DiagParser.cpp) | `parseDiag` firmware `DG` self-test stream → `DiagResult` (per-axis tests, PASS/FAIL); `formatDiag` |
| [include/fgc/Calibration.h](../include/fgc/Calibration.h), [src/core/Calibration.cpp](../src/core/Calibration.cpp) | `linearFit` (least-squares, R²) + `circularMeanDeg` for the IMU-referenced calibration |
diff --git a/docs/tower-installation.md b/docs/tower-installation.md
index 930a50c..cf721c8 100644
--- a/docs/tower-installation.md
+++ b/docs/tower-installation.md
@@ -228,6 +228,53 @@ Then prove the program finds it **with nothing in the environment** - no
> required` - a message that points at the camera rather than at the file that
> disappeared. Verified on tower 121 by rebooting it.
+### The drivetrain block
+
+`install.sh` does not write this one - no probe can read a sense resistor off a
+board - so add it by hand, one `[Motor.]` block per axis:
+
+```ini
+[Motor.yaw]
+r_sense_ohm = 0.075 ; the resistor fitted to THIS driver board
+run_current_ma = 756 ; normal motion
+homing_current_ma = 200 ; the endstop search only
+travel_deg = 340 ; mechanical angle between the hard stops
+```
+
+**`homing_current_ma` is a safety setting, not a tuning one.** Homing finds a
+stop by driving into it until the shaft falls behind; that only works while the
+stop is stronger than the motor. A high gear ratio can shear a screw-head stop
+straight past its mate - on tower 121 the axis ran 1.8 turns past the stop with
+the encoder never falling behind, at full torque. Declare a reduced search
+current wherever the stop cannot survive one.
+
+Getting it wrong in either direction is visible: too strong and the stop yields
+again; too weak and the motor stalls on ordinary friction mid-sweep, trips the
+same latch, and records a phantom endstop. The firmware's `steps_per_rev` guard
+is what catches the second case, so it has to be set from the real travel.
+
+**`travel_deg` is a nameplate fact**, and the host divides the measured homing
+travel by it to derive `[Motor] _counts_per_deg` into `config.ini`. A rough
+angle gives a proportionally rough degree scale - 1 % out here is 1 % out on every
+bearing - until `gimbal heading setup` supersedes it with a landmark fit.
+
+The startup log reports what the registers **actually** deliver, which is not
+always what was asked for:
+
+```
+yaw motor current: run 756 mA (GLOBALSCALER 80, IRUN 31), homing 189 mA (IRUN 7)
+ -- R_sense 0.075 ohm from hardware.ini [Motor.yaw]
+```
+
+Omit the block entirely on a tower whose endstops hold against full torque: no
+`CURRENT` is sent, the firmware keeps its compile-time defaults, and nothing
+changes.
+
+> **If an axis declares a reduced homing current and the firmware has not adopted
+> it, the host refuses to home** rather than driving into a stop at full torque.
+> The message names the skew; the fix is to flash the firmware, not to override
+> it.
+
> `[Serial] device` must be a `/dev/serial/by-id/` path, never `/dev/ttyACM0`.
> Tower 121 has a 5G modem that claims three `ttyUSB` nodes, and enumeration
> order is not stable across reboots.
@@ -686,7 +733,9 @@ gimbal pitch setup # needs an IMU; skip on a rig without one
| Driver answers over SPI | boot banner shows `IOIN Y=0x… version=0x30` | ✅ |
| Remote re-flash | `BOOTSEL`, then `./deploy.sh` - no hands on the board | ✅ |
| Declared hardware matches | startup logs no presence warnings | ✅ no shadowed-key warnings |
-| Homing repeatable | `gimbal home` several times, endstop counts within `home_spread_max_counts` | ❌ **not attempted** - unknown drivetrain |
+| Homing repeatable | `gimbal home` several times, endstop counts within `home_spread_max_counts` | ✅ three homes within 0.27 % (1,210,778 / 1,208,358 / 1,207,514 counts) |
+| Endstop survives homing | inspect the stop after a home; the axis must stall against it, not push through | ✅ at 189 mA, after failing at full torque |
+| Degree scale derived | startup logs `counts_per_deg` agreeing with the homed travel | ✅ 3552.87, saved with `;prev` history |
| Images land | `.jxl` files appear under the configured output dir | ❌ needs a capture run |
| Survives a disconnect | `scripts/fgc start`, close ssh, reconnect, `scripts/fgc status` | ❌ not attempted |
| Reports to a broker | topics arrive at the configured broker | ❌ no broker on this tower yet |