Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4d49517
Build the gear ladder the way Di2 shifts, and ask which gear the bike…
web-flow Aug 17, 2026
7cd6fa6
Ask for the parked gear in setup, and offer groupsets and ladders by …
web-flow Aug 17, 2026
7038c29
Document the parked gear, the synchro walk and the groupsets
web-flow Aug 17, 2026
f1e782c
Confirm the parked gear in the UI and refresh the screenshots
Aug 18, 2026
ef92d8a
Ship build 12 to TestFlight
Aug 18, 2026
42c57fc
Fix setup rows rendering all-blue instead of black text + blue checkmark
Aug 18, 2026
cfee9a9
Bump build number to 13 for TestFlight
Aug 18, 2026
364bfff
Make the Start button open Settings when the parked gear is missing
Aug 18, 2026
5991f95
Add setup guide re-entry, custom gear ladder, and a Bluetooth disconn…
Aug 18, 2026
ef6132b
Bump build number to 14 for TestFlight
Aug 18, 2026
f54f058
Ship build 14 to TestFlight
Aug 18, 2026
88a247e
Let the setup wizard pair a real groupset with a single sprocket
Aug 18, 2026
1bffc2a
Fix wizard's single-sprocket toggle state and rename escape hatch
Aug 18, 2026
e7764ab
Address remaining UX review findings in the setup guide and Settings
Aug 18, 2026
5b945f2
Add complete designed-state UX coverage
Aug 19, 2026
ce18304
Simplify setup and refresh UX story
Aug 19, 2026
d9f780a
Bump build number to 15 for TestFlight
Aug 20, 2026
e368190
Ship build 15 to TestFlight
Aug 20, 2026
839f23a
Simplify first-run bike setup
Aug 20, 2026
19cca42
Bump build number to 16 for TestFlight
Aug 21, 2026
3913479
Ship build 16 to TestFlight
Aug 21, 2026
a2a0631
Polish setup and startup UX
Aug 21, 2026
a71bb5d
Bump build number to 17 for TestFlight
Aug 21, 2026
2f4bd7b
Ship build 17 to TestFlight
Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -692,3 +692,59 @@ Some things that look duplicated are not, and should not be merged:
- The fan reconnects through `resumeSavedConnection` rather than
`retrieveAndConnect`. The two are equivalent today, but making them the
same call would be a behaviour change wearing a refactor's clothes.

## Why the gear ladder is walked rather than sorted

The first version of `Drivetrain.build` paired every chainring with every cog,
sorted the pile by ratio, pruned the cross-chained pairs and dropped exact
duplicates. That is not how a drivetrain works, and the difference was
measurable across the groupsets the app ships.

Running both algorithms over all **72 builds** of the shipped groupsets — real
chainring and cassette pairings only, no invented combinations:

| Over 72 real groupset builds | Sorted pile | Synchro walk |
|---|---|---|
| Builds with a shift too small to feel | 12 | **0** |
| Builds with a hole above 25% | 5 | **0** |
| Smallest step anywhere | 0.4% | **5.9%** |
| Largest step anywhere | 37% | 25% |
| Easiest and hardest gear kept | always | always |

Both defects disappear rather than being patched. A walked drivetrain cannot
invent a hole, because it only ever moves one cog at a time, and cannot produce
a step under the perception floor, because the ring transition refuses one.

The walk is the same idea as Shimano Synchronized Shift and SRAM AXS Sequential:
rings ascending, cogs descending, one cog per press, and at the end of a ring's
window a jump to the ring above landing on whichever cog gives a step closest to
the cassette step just taken. Research backing the constants: Di2 shift points
are a programmable table rather than a formula, a front shift is always paired
with a one-to-two cog compensating rear shift, and steps below roughly 5% cannot
be felt.

An earlier measurement across the old 616-combination catalogue produced far
uglier numbers, but its worst cases came from 8-speed and triple drivetrains that
have since been removed. Quoting them would have overstated the problem, so the
table above uses real parts only.

### Gear counts

The walk produces **cassette speeds + 3 to + 6** gears, with 60 of the 72 builds
landing on exactly +3 or +4. For 11-speed that is 14 to 16, which matches real
Di2. Wider ring gaps genuinely produce more distinct gears, so `RideabilityTests`
pins that band rather than forcing every build into 14 to 16.

### The parked gear

The bike never shifts, so what the rider feels is the parked ratio multiplied by
the circumference the app sets. The app previously assumed the parked ratio
equalled its own starting gear. `WheelCircumferenceScaler.effectiveCircumference`
already computed `W / referenceRatio x selectedRatio`, so the fix was to pass the
parked ratio in place of the reference. When the two are equal the behaviour is
byte-identical to before, which is why this never showed up as a regression.

The workable parked-ratio window is
`hardestRatio / scaleRange.upperBound ... easiestRatio / scaleRange.lowerBound`.
For the virtual ladder that is 2.011 to 2.50; for the default 105 drivetrain it
is 1.665 to 4.167.
29 changes: 21 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ to know anything about virtual shifting.
Leave the bike in a quiet, straight chain line and shift virtually instead.
Nothing moves on the bike, so shifting is silent and cannot drop the chain.

Because the bike stays in that one gear all ride, Virtual Gears asks you once
which gear it is. That ratio is what every virtual gear is scaled from, so the
app recommends the quietest gear that works and you confirm it in a tap.

**[Read the full documentation](https://sbroenne.github.io/VirtualGears/)**

## Why it exists
Expand Down Expand Up @@ -54,15 +58,20 @@ work but have not been tested.
1. Wake the KICKR by turning the pedals.
2. Open Virtual Gears on the iPhone. It finds the KICKR, connects and makes it
available to your riding app.
3. In your riding app, connect to the trainer named **Virtual Gears**. Some apps
3. Park the chain in a quiet, straight chain line and confirm the gear in
Settings. Virtual Gears names the gear it recommends, so this is usually one
tap. It is asked once and kept.
4. In your riding app, connect to the trainer named **Virtual Gears**. Some apps
may show the iPhone's name instead.
4. Tap **Start Shifting**, then shift with the large **Easier** and **Harder**
5. Tap **Start Shifting**, then shift with the large **Easier** and **Harder**
buttons.

There is no setup wizard. Virtual Gears is a transparent trainer connection as
soon as the KICKR is ready. **Start Shifting** engages the gears; **Stop Shifting**
removes them without disconnecting or stopping the ride in your riding app. If it
finds more than one trainer, it asks you to choose yours by name.
Virtual Gears is a transparent trainer connection as soon as the KICKR is ready.
The only thing it needs from you is the gear the bike is parked in, because
guessing it would quietly make every gear wrong. **Start Shifting** engages the
gears; **Stop Shifting** removes them without disconnecting or stopping the ride
in your riding app. If it finds more than one trainer, it asks you to choose
yours by name.

The iPhone screen stays awake while the trainer proxy is available. This keeps
Virtual Gears discoverable to riding apps on Windows and other computers before
Expand All @@ -80,10 +89,14 @@ with physical hardware.

## What it can do

- **24 ready-made virtual gears**, with extra range for climbing.
- **Ready-made virtual gears**, in ladders of 24 with extra range for climbing.
- **App-independent shifting** for FTMS riding apps that have no virtual gears
of their own.
- **Real-bike gearing**, built from your chainrings and cassette.
- **Real groupsets**, from Shimano, SRAM and Campagnolo, or your own chainrings
and cassette if your bike is not listed.
- **Gears that shift the way an electronic groupset shifts**, one cog at a time
with the front change folded in, so there are no dead shifts and no invented
gaps.
- **On-phone shifting** with large controls in portrait and landscape.
- **Accessible ride controls** with VoiceOver gear feedback, adjustable gear
control and support for larger text.
Expand Down
169 changes: 159 additions & 10 deletions Sources/VirtualGearsCore/AppConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,54 @@ public struct AppConfiguration: Codable, Equatable {
public var headwindUUID: String?
public var chainringID = DrivetrainCatalog.defaultChainringID
public var cassetteID = DrivetrainCatalog.defaultCassetteID
/// The gears Zwift and Wahoo hand out when the bike has none of its own.
/// It is the starting point because it needs no knowledge of the bike.
/// A made-up ladder of evenly spaced ratios rather than a copy of a real
/// groupset. It is the starting point because it needs no knowledge of the
/// bike.
public var usesVirtualGears = true
public var gearLadderID = GearLadderCatalog.defaultLadderID
/// What is physically on the trainer, including the one gear the bike is
/// parked in. Entirely separate from the gearing being simulated: a rider
/// on a single-sprocket Zwift Cog can simulate a twelve-speed groupset, and
/// most will.
public var physical = PhysicalSetup.default

/// Reading is deliberately forgiving: a key that is not there falls back to
/// the default rather than throwing the whole saved setup away. The app has
/// no riders yet so there is nothing to migrate today, but a setup a rider
/// has already made is the one thing worth never losing, so every new field
/// added from here on stays optional on the way in.
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
func string(_ key: CodingKeys, _ fallback: String) throws -> String {
try container.decodeIfPresent(String.self, forKey: key) ?? fallback
}
kickrName = try string(.kickrName, "")
kickrUUID = try string(.kickrUUID, "")
clickName = try string(.clickName, "")
clickUUID = try string(.clickUUID, "")
headwindName = try container.decodeIfPresent(
String.self, forKey: .headwindName
)
headwindUUID = try container.decodeIfPresent(
String.self, forKey: .headwindUUID
)
chainringID = try string(
.chainringID, DrivetrainCatalog.defaultChainringID
)
cassetteID = try string(.cassetteID, DrivetrainCatalog.defaultCassetteID)
usesVirtualGears = try container.decodeIfPresent(
Bool.self, forKey: .usesVirtualGears
) ?? true
gearLadderID = try string(
.gearLadderID, GearLadderCatalog.defaultLadderID
)
physical = try container.decodeIfPresent(
PhysicalSetup.self, forKey: .physical
) ?? .default
normalWheelCircumferenceMillimeters = try container.decodeIfPresent(
Int.self, forKey: .normalWheelCircumferenceMillimeters
)
}
/// Nil in configurations saved before this setting existed. The computed
/// value below turns that into the long-standing 2070 mm default.
public private(set) var normalWheelCircumferenceMillimeters: Int?
Expand Down Expand Up @@ -58,10 +103,61 @@ public struct AppConfiguration: Codable, Equatable {
?? DrivetrainCatalog.cassette(id: DrivetrainCatalog.defaultCassetteID)!
}

public var gearLadder: GearLadder {
GearLadderCatalog.ladder(id: gearLadderID)
?? GearLadderCatalog.defaultLadder
}

/// The named groupset the chosen parts belong to, when they belong to one.
public var groupset: Groupset? {
guard !usesVirtualGears else { return nil }
return GroupsetCatalog.groupset(
chainringID: chainringID,
cassetteID: cassetteID
)
}

/// The gear the rider confirmed their bike is parked in. Nil until they
/// have confirmed one, which is why setup is not finished without it.
public var parkedGear: ParkedGear? { physical.parkedGear }

/// The gear to recommend: the quietest one that still lets every simulated
/// gear reach the trainer.
public var suggestedParkedGear: ParkedGear? {
guard let drivetrain else { return nil }
return ParkedGearAdvice.suggestion(
for: physical,
simulating: drivetrain
)
}

/// Every parked ratio that keeps the whole ladder within the trainer's
/// reach, at every wheel size a riding app may ask for.
public var workableParkedRatios: ClosedRange<Double>? {
guard let drivetrain else { return nil }
return ParkedGearAdvice.workableRatios(for: drivetrain)
}

/// True when the confirmed parked gear leaves part of the ladder out of
/// reach, so the app can say so rather than fail mid-ride.
public var parkedGearPutsGearsOutOfReach: Bool {
guard let drivetrain, let parkedGear else { return false }
return !ParkedGearAdvice.isWorkable(parkedGear, simulating: drivetrain)
}

public mutating func park(in gear: ParkedGear) {
physical.park(in: gear)
}

/// Pre-selects the recommendation so confirming it is a single tap.
public mutating func parkInSuggestion() {
if let suggestedParkedGear { physical.park(in: suggestedParkedGear) }
}

/// Nil when the chosen parts cover a wider spread than the trainer can copy.
public var drivetrain: Drivetrain? {
if usesVirtualGears {
return try? Drivetrain.virtualLadder()
return try? gearLadder.drivetrain()
}
return try? Drivetrain.build(
chainrings: chainring.teeth,
Expand Down Expand Up @@ -91,13 +187,16 @@ public struct AppConfiguration: Codable, Equatable {

public var hasSafeCircumference: Bool {
guard let drivetrain else { return false }
return Self.isSafe(drivetrain)
return Self.isSafe(drivetrain, parkedGear: parkedGear)
}

/// Confirms every gear of a drivetrain can be built and encoded at both
/// ends of the wheel sizes a riding app may ask for. Nothing reaches the
/// KICKR without this.
public static func isSafe(_ drivetrain: Drivetrain) -> Bool {
public static func isSafe(
_ drivetrain: Drivetrain,
parkedGear: ParkedGear? = nil
) -> Bool {
// Building the gears is the check. The engine scales every gear and
// encodes every command, so anything it accepts can be staged.
//
Expand All @@ -106,17 +205,31 @@ public struct AppConfiguration: Codable, Equatable {
// put its hardest gear out of reach once a riding app asks for 2400 mm.
// Checking only the middle is how a drivetrain used to pass setup and
// then fail mid-ride.
// The parked gear is checked separately because encoding is not the
// only limit. Parked in the big ring on the smallest cog every gear
// still fits in the command, but the easiest one asks the trainer for a
// 238 mm wheel, and a riding app would draw that as a rider who has
// stopped. That is what the scale range exists to prevent.
if let parkedGear,
!ParkedGearAdvice.isWorkable(parkedGear, simulating: drivetrain) {
return false
}
let window = TrainerSafety.supportedRidingAppCircumferenceMillimeters
return [window.lowerBound, window.upperBound].allSatisfy { wheelSize in
(try? ConfirmedGearEngine(
drivetrain: drivetrain,
wheelSizeMillimeters: wheelSize
wheelSizeMillimeters: wheelSize,
parkedGear: parkedGear
)) != nil
}
}

/// Setup is not finished until the rider has said which gear the bike is
/// parked in. There is nothing to fall back to and nothing to preserve —
/// the app has never shipped — and guessing it quietly moves every gear the
/// rider feels, so it is asked rather than assumed.
public var canFinishSetup: Bool {
hasValidKickr && hasSafeCircumference
hasValidKickr && parkedGear != nil && hasSafeCircumference
}

/// Connecting to a trainer is not the same as choosing one. Every check
Expand Down Expand Up @@ -157,9 +270,13 @@ public struct AppConfiguration: Codable, Equatable {
public extension AppConfiguration {
var gearCount: Int { drivetrain?.gears.count ?? 0 }

/// What the rider chose, in the words printed on the parts.
/// What the rider chose, named the way the bike is named: the groupset if
/// the parts belong to one, otherwise the parts themselves.
var drivetrainName: String {
guard !usesVirtualGears else { return "Virtual gears" }
guard !usesVirtualGears else { return gearLadder.name }
if let groupset {
return "\(groupset.qualifiedName) · \(chainring.name) \(cassette.name)"
}
return "\(chainring.name) · \(cassette.name)"
}

Expand All @@ -169,7 +286,7 @@ public extension AppConfiguration {
return "Too wide a range for the trainer"
}
if usesVirtualGears {
return "\(gearCount) gears · extra-low climbing range"
return "\(gearCount) gears · \(gearLadder.note)"
}
return "\(gearCount) gears · \(rangeDescription)"
}
Expand Down Expand Up @@ -204,4 +321,36 @@ public extension AppConfiguration {
+ "hardest for speed. Every ride starts in gear "
+ "\(drivetrain.referenceIndex + 1)."
}

/// What to tell the rider to do with the chain before they start. The
/// bike never shifts, so this is a one-off: park it, confirm it, ride.
var parkedGearAdviceText: String {
guard let suggestedParkedGear else {
return "Leave the bike in a quiet, straight chain line, then tell "
+ "Virtual Gears which gear that is."
}
let cog = physical.isSingleSprocket
? "the \(suggestedParkedGear.cogTeeth) tooth sprocket"
: "the \(suggestedParkedGear.cogTeeth) tooth cog"
return "Park the chain on the \(suggestedParkedGear.chainringTeeth) "
+ "tooth ring and \(cog). Quiet, straight chain line — and the "
+ "bike stays there for the whole ride."
}

/// Says plainly what a confirmed gear costs, rather than only computing it.
var parkedGearWarning: String? {
guard parkedGear != nil, parkedGearPutsGearsOutOfReach,
let range = workableParkedRatios
else {
return nil
}
return String(
format: "Parked in that gear, some gears cannot reach the trainer. "
+ "It needs to be between %.2f and %.2f — around %d/%d.",
range.lowerBound,
range.upperBound,
suggestedParkedGear?.chainringTeeth ?? 34,
suggestedParkedGear?.cogTeeth ?? 15
)
}
}
Loading
Loading