Hardcut Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A bolt-on ESP32 box that adds a two-step launch limiter and a configurable main rev limiter to a 1995 Geo Tracker 1.6 16v, by suppressing individual ignition events at the ECM→igniter IGt line.
Architecture: All decision logic is pure C++ in lib/core/, unit-tested natively with no hardware. The ESP32 layer is a thin ISR that captures IGt edges and writes a D flip-flop’s data input; the flip-flop clocks on IGt’s falling edge, so hardware — not firmware — guarantees the gate can never change mid-dwell. A second ESP32 emulates the ECM and igniter so every path is proven on the bench before the car is involved.
Tech Stack: PlatformIO, Arduino-ESP32 framework, Unity test framework (native env), ESP32-S3-WROOM-1.
Design: 2026-07-29-hardcut-design
Read esp32 before flashing anything. It carries the cross-project ESP32 rules that will otherwise cost a session here: the S3 bootloader goes at 0x0 (not 0x1000), only the CH343 UART port (1a86:55d3) can flash a board already running firmware, uvx --from platformio needs --with pip, LEDC caps at 14-bit on the S3, and ADC2 is dead whenever WiFi is on.
Global Constraints
- Project root is
/Users/levander/vitara/hardcut/. - No comments, no docstrings, no inline annotations in any source file. Code must be self-explanatory.
- Do not run
git commit. Staging is fine; committing is the user’s call. - DRY — extract shared logic rather than duplicating it.
MAX_CONSECUTIVE_CUTS = 5is inviolable. No mode, ratio, config value or web request may produce a 6th consecutive cut. It is enforced in exactly one place, after the pattern generator.- The gate GPIO is written only by
ign.cpp. No other translation unit touches it. - Radio off whenever armed. No NVS/LittleFS write and no OTA while armed.
- ISR is
IRAM_ATTR, allocatedESP_INTR_FLAG_LEVEL3 | ESP_INTR_FLAG_IRAM, and writesGPIO.out_w1ts/out_w1tcdirectly — nevergpio_set_level(). - No floating point in the ISR path.
- Ignition events are 2 per crank revolution. Spark-to-spark is 10 ms at 3000 rpm, 5 ms at 6000, 4.29 ms at 7000.
- Firmware-clamped ceiling: no configured limit above 6600 rpm.
Task 1: Project scaffold and native test harness
Files:
- Create:
/Users/levander/vitara/hardcut/platformio.ini - Create:
/Users/levander/vitara/hardcut/lib/core/version.h - Create:
/Users/levander/vitara/hardcut/test/test_scaffold/test_scaffold.cpp
Interfaces:
-
Consumes: nothing.
-
Produces: a
nativePlatformIO env that compileslib/core/and runs Unity tests; anesp32s3env for firmware. -
Step 1: Create
platformio.ini
[env:native]
platform = native
test_framework = unity
build_flags = -std=gnu++17 -Wall -Wextra
[env:esp32s3]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200
build_flags = -std=gnu++17 -DCORE_DEBUG_LEVEL=0- Step 2: Create
lib/core/version.h
#pragma once
#define HARDCUT_VERSION "0.1.0"- Step 3: Write a scaffold test
#include <unity.h>
#include "version.h"
void test_scaffold_builds(void) {
TEST_ASSERT_EQUAL_STRING("0.1.0", HARDCUT_VERSION);
}
int main(int, char **) {
UNITY_BEGIN();
RUN_TEST(test_scaffold_builds);
return UNITY_END();
}- Step 4: Run it
Run: pio test -e native
Expected: 1 test, PASS.
- Step 5: Initialise version control (no commit)
cd /Users/levander/vitara/hardcut && git init && printf '.pio/\n.vscode/\n' > .gitignore && git add -ALeave the work staged. Do not commit.
Task 2: RPM derivation and plausibility filter
Files:
- Create:
/Users/levander/vitara/hardcut/lib/core/rpm.h - Create:
/Users/levander/vitara/hardcut/lib/core/rpm.cpp - Test:
/Users/levander/vitara/hardcut/test/test_rpm/test_rpm.cpp
Interfaces:
-
Consumes: nothing.
-
Produces:
uint16_t rpmFromPeriodUs(uint32_t periodUs)andclass PeriodFilterwithvoid reset(),bool accept(uint32_t periodUs),uint32_t period() const. -
Step 1: Write the failing test
#include <unity.h>
#include "rpm.h"
void test_rpm_from_period(void) {
TEST_ASSERT_EQUAL_UINT16(6000, rpmFromPeriodUs(5000));
TEST_ASSERT_EQUAL_UINT16(3000, rpmFromPeriodUs(10000));
TEST_ASSERT_EQUAL_UINT16(7000, rpmFromPeriodUs(4285));
}
void test_rpm_rejects_implausible(void) {
TEST_ASSERT_EQUAL_UINT16(0, rpmFromPeriodUs(0));
TEST_ASSERT_EQUAL_UINT16(0, rpmFromPeriodUs(1000));
TEST_ASSERT_EQUAL_UINT16(0, rpmFromPeriodUs(500000));
}
void test_filter_first_sample_accepted(void) {
PeriodFilter f;
f.reset();
TEST_ASSERT_TRUE(f.accept(10000));
TEST_ASSERT_EQUAL_UINT32(10000, f.period());
}
void test_filter_rejects_jumps(void) {
PeriodFilter f;
f.reset();
f.accept(10000);
TEST_ASSERT_FALSE(f.accept(40000));
TEST_ASSERT_FALSE(f.accept(3500));
TEST_ASSERT_EQUAL_UINT32(10000, f.period());
}
void test_filter_accepts_gradual(void) {
PeriodFilter f;
f.reset();
f.accept(10000);
TEST_ASSERT_TRUE(f.accept(9000));
TEST_ASSERT_TRUE(f.accept(8200));
TEST_ASSERT_EQUAL_UINT32(8200, f.period());
}
int main(int, char **) {
UNITY_BEGIN();
RUN_TEST(test_rpm_from_period);
RUN_TEST(test_rpm_rejects_implausible);
RUN_TEST(test_filter_first_sample_accepted);
RUN_TEST(test_filter_rejects_jumps);
RUN_TEST(test_filter_accepts_gradual);
return UNITY_END();
}- Step 2: Run to verify it fails
Run: pio test -e native -f test_rpm
Expected: FAIL — rpm.h not found.
- Step 3: Write
rpm.h
#pragma once
#include <stdint.h>
static const uint32_t PERIOD_MIN_US = 3000;
static const uint32_t PERIOD_MAX_US = 200000;
uint16_t rpmFromPeriodUs(uint32_t periodUs);
class PeriodFilter {
public:
void reset();
bool accept(uint32_t periodUs);
uint32_t period() const;
private:
uint32_t last_;
bool have_;
};- Step 4: Write
rpm.cpp
#include "rpm.h"
uint16_t rpmFromPeriodUs(uint32_t periodUs) {
if (periodUs < PERIOD_MIN_US || periodUs > PERIOD_MAX_US) return 0;
return (uint16_t)(30000000UL / periodUs);
}
void PeriodFilter::reset() {
last_ = 0;
have_ = false;
}
bool PeriodFilter::accept(uint32_t periodUs) {
if (periodUs < PERIOD_MIN_US || periodUs > PERIOD_MAX_US) return false;
if (!have_) {
last_ = periodUs;
have_ = true;
return true;
}
if (periodUs > last_ * 2 || periodUs * 2 < last_) return false;
last_ = periodUs;
return true;
}
uint32_t PeriodFilter::period() const {
return last_;
}- Step 5: Run to verify it passes
Run: pio test -e native -f test_rpm
Expected: 5 tests, PASS.
Task 3: Cut patterns and the run cap
This is the safety-critical module. The run cap is what keeps the ECM feeding fuel.
Files:
- Create:
/Users/levander/vitara/hardcut/lib/core/pattern.h - Create:
/Users/levander/vitara/hardcut/lib/core/pattern.cpp - Test:
/Users/levander/vitara/hardcut/test/test_pattern/test_pattern.cpp
Interfaces:
-
Consumes: nothing.
-
Produces:
enum PatternMode { PATTERN_OFF, PATTERN_CLUSTER, PATTERN_SPREAD }andclass CutPatternwithvoid reset(),void configure(PatternMode m, uint8_t clusterN, uint8_t ratioPercent),bool nextEventIsCut(),uint8_t run() const, andstatic const uint8_t MAX_CONSECUTIVE_CUTS = 5. -
Step 1: Write the failing test
#include <unity.h>
#include "pattern.h"
void test_off_never_cuts(void) {
CutPattern p;
p.reset();
p.configure(PATTERN_OFF, 5, 100);
for (int i = 0; i < 200; i++) TEST_ASSERT_FALSE(p.nextEventIsCut());
}
void test_cluster_five_then_pass(void) {
CutPattern p;
p.reset();
p.configure(PATTERN_CLUSTER, 5, 0);
for (int g = 0; g < 20; g++) {
for (int i = 0; i < 5; i++) TEST_ASSERT_TRUE(p.nextEventIsCut());
TEST_ASSERT_FALSE(p.nextEventIsCut());
}
}
void test_cluster_three_then_pass(void) {
CutPattern p;
p.reset();
p.configure(PATTERN_CLUSTER, 3, 0);
for (int g = 0; g < 20; g++) {
for (int i = 0; i < 3; i++) TEST_ASSERT_TRUE(p.nextEventIsCut());
TEST_ASSERT_FALSE(p.nextEventIsCut());
}
}
void test_spread_ratio_zero_never_cuts(void) {
CutPattern p;
p.reset();
p.configure(PATTERN_SPREAD, 0, 0);
for (int i = 0; i < 200; i++) TEST_ASSERT_FALSE(p.nextEventIsCut());
}
void test_spread_ratio_fifty_alternates(void) {
CutPattern p;
p.reset();
p.configure(PATTERN_SPREAD, 0, 50);
int cuts = 0;
for (int i = 0; i < 1000; i++) if (p.nextEventIsCut()) cuts++;
TEST_ASSERT_INT_WITHIN(2, 500, cuts);
}
void test_spread_ratio_is_approximately_honoured(void) {
for (uint8_t ratio = 10; ratio <= 80; ratio += 10) {
CutPattern p;
p.reset();
p.configure(PATTERN_SPREAD, 0, ratio);
int cuts = 0;
for (int i = 0; i < 1000; i++) if (p.nextEventIsCut()) cuts++;
TEST_ASSERT_INT_WITHIN(15, ratio * 10, cuts);
}
}
void test_run_cap_never_exceeded_any_config(void) {
for (uint8_t mode = 0; mode <= 2; mode++) {
for (uint8_t n = 0; n <= 20; n++) {
for (uint16_t ratio = 0; ratio <= 100; ratio += 5) {
CutPattern p;
p.reset();
p.configure((PatternMode)mode, n, (uint8_t)ratio);
uint8_t run = 0;
for (int i = 0; i < 3000; i++) {
if (p.nextEventIsCut()) {
run++;
TEST_ASSERT_LESS_OR_EQUAL_UINT8(CutPattern::MAX_CONSECUTIVE_CUTS, run);
} else {
run = 0;
}
}
}
}
}
}
int main(int, char **) {
UNITY_BEGIN();
RUN_TEST(test_off_never_cuts);
RUN_TEST(test_cluster_five_then_pass);
RUN_TEST(test_cluster_three_then_pass);
RUN_TEST(test_spread_ratio_zero_never_cuts);
RUN_TEST(test_spread_ratio_fifty_alternates);
RUN_TEST(test_spread_ratio_is_approximately_honoured);
RUN_TEST(test_run_cap_never_exceeded_any_config);
return UNITY_END();
}- Step 2: Run to verify it fails
Run: pio test -e native -f test_pattern
Expected: FAIL — pattern.h not found.
- Step 3: Write
pattern.h
#pragma once
#include <stdint.h>
enum PatternMode {
PATTERN_OFF = 0,
PATTERN_CLUSTER = 1,
PATTERN_SPREAD = 2
};
class CutPattern {
public:
static const uint8_t MAX_CONSECUTIVE_CUTS = 5;
void reset();
void configure(PatternMode mode, uint8_t clusterN, uint8_t ratioPercent);
bool nextEventIsCut();
uint8_t run() const;
private:
PatternMode mode_;
uint8_t clusterN_;
uint8_t ratio_;
uint8_t run_;
int16_t acc_;
};- Step 4: Write
pattern.cpp
#include "pattern.h"
void CutPattern::reset() {
mode_ = PATTERN_OFF;
clusterN_ = 0;
ratio_ = 0;
run_ = 0;
acc_ = 0;
}
void CutPattern::configure(PatternMode mode, uint8_t clusterN, uint8_t ratioPercent) {
if (mode_ != mode) acc_ = 0;
mode_ = mode;
clusterN_ = clusterN;
ratio_ = ratioPercent > 100 ? 100 : ratioPercent;
}
bool CutPattern::nextEventIsCut() {
bool want = false;
switch (mode_) {
case PATTERN_OFF:
want = false;
break;
case PATTERN_CLUSTER:
want = (run_ < clusterN_);
break;
case PATTERN_SPREAD:
acc_ += ratio_;
if (acc_ >= 100) {
acc_ -= 100;
want = true;
}
break;
}
if (want && run_ >= MAX_CONSECUTIVE_CUTS) want = false;
run_ = want ? (uint8_t)(run_ + 1) : 0;
return want;
}
uint8_t CutPattern::run() const {
return run_;
}- Step 5: Run to verify it passes
Run: pio test -e native -f test_pattern
Expected: 7 tests, PASS. test_run_cap_never_exceeded_any_config sweeps 3 modes × 21 cluster values × 21 ratios × 3000 events and is the single most important test in the project.
Task 4: Limiter control law
Files:
- Create:
/Users/levander/vitara/hardcut/lib/core/limiter.h - Create:
/Users/levander/vitara/hardcut/lib/core/limiter.cpp - Test:
/Users/levander/vitara/hardcut/test/test_limiter/test_limiter.cpp
Interfaces:
-
Consumes:
PatternModefrompattern.h. -
Produces:
struct LimiterConfig,struct LimiterOutput, andclass Limiterwithvoid reset(),void setConfig(const LimiterConfig&),LimiterOutput update(uint16_t rpm, bool launchArmed). -
Step 1: Write the failing test
#include <unity.h>
#include "limiter.h"
static LimiterConfig cfg() {
LimiterConfig c;
c.launchRpm = 2500;
c.launchHystRpm = 80;
c.launchClusterN = 5;
c.mainRpm = 6300;
c.mainHystRpm = 150;
c.softBandRpm = 250;
c.maxRatioPercent = 83;
return c;
}
void test_below_limits_no_cut(void) {
Limiter l;
l.reset();
l.setConfig(cfg());
LimiterOutput o = l.update(3000, false);
TEST_ASSERT_EQUAL_INT(PATTERN_OFF, o.mode);
}
void test_main_limit_engages_spread(void) {
Limiter l;
l.reset();
l.setConfig(cfg());
LimiterOutput o = l.update(6350, false);
TEST_ASSERT_EQUAL_INT(PATTERN_SPREAD, o.mode);
TEST_ASSERT_EQUAL_UINT8(83, o.ratio);
}
void test_main_hysteresis_holds_then_releases(void) {
Limiter l;
l.reset();
l.setConfig(cfg());
l.update(6350, false);
TEST_ASSERT_EQUAL_INT(PATTERN_SPREAD, l.update(6200, false).mode);
TEST_ASSERT_EQUAL_INT(PATTERN_OFF, l.update(6100, false).mode);
}
void test_launch_uses_cluster(void) {
Limiter l;
l.reset();
l.setConfig(cfg());
LimiterOutput o = l.update(2600, true);
TEST_ASSERT_EQUAL_INT(PATTERN_CLUSTER, o.mode);
TEST_ASSERT_EQUAL_UINT8(5, o.clusterN);
}
void test_launch_hysteresis_is_tighter(void) {
Limiter l;
l.reset();
l.setConfig(cfg());
l.update(2600, true);
TEST_ASSERT_EQUAL_INT(PATTERN_CLUSTER, l.update(2450, true).mode);
TEST_ASSERT_EQUAL_INT(PATTERN_OFF, l.update(2400, true).mode);
}
void test_soft_band_ramps(void) {
Limiter l;
l.reset();
l.setConfig(cfg());
l.update(6350, false);
TEST_ASSERT_EQUAL_UINT8(0, l.update(6050, false).ratio);
uint8_t mid = l.update(6175, false).ratio;
TEST_ASSERT_INT_WITHIN(6, 41, mid);
}
void test_ratio_never_exceeds_max(void) {
Limiter l;
l.reset();
l.setConfig(cfg());
for (uint16_t rpm = 6000; rpm <= 9000; rpm += 25) {
LimiterOutput o = l.update(rpm, false);
TEST_ASSERT_LESS_OR_EQUAL_UINT8(83, o.ratio);
}
}
int main(int, char **) {
UNITY_BEGIN();
RUN_TEST(test_below_limits_no_cut);
RUN_TEST(test_main_limit_engages_spread);
RUN_TEST(test_main_hysteresis_holds_then_releases);
RUN_TEST(test_launch_uses_cluster);
RUN_TEST(test_launch_hysteresis_is_tighter);
RUN_TEST(test_soft_band_ramps);
RUN_TEST(test_ratio_never_exceeds_max);
return UNITY_END();
}- Step 2: Run to verify it fails
Run: pio test -e native -f test_limiter
Expected: FAIL — limiter.h not found.
- Step 3: Write
limiter.h
#pragma once
#include <stdint.h>
#include "pattern.h"
struct LimiterConfig {
uint16_t launchRpm;
uint16_t launchHystRpm;
uint8_t launchClusterN;
uint16_t mainRpm;
uint16_t mainHystRpm;
uint16_t softBandRpm;
uint8_t maxRatioPercent;
};
struct LimiterOutput {
PatternMode mode;
uint8_t ratio;
uint8_t clusterN;
};
class Limiter {
public:
void reset();
void setConfig(const LimiterConfig& c);
LimiterOutput update(uint16_t rpm, bool launchArmed);
private:
LimiterConfig cfg_;
bool active_;
};- Step 4: Write
limiter.cpp
#include "limiter.h"
void Limiter::reset() {
active_ = false;
}
void Limiter::setConfig(const LimiterConfig& c) {
cfg_ = c;
}
LimiterOutput Limiter::update(uint16_t rpm, bool launchArmed) {
uint16_t limit = launchArmed ? cfg_.launchRpm : cfg_.mainRpm;
uint16_t hyst = launchArmed ? cfg_.launchHystRpm : cfg_.mainHystRpm;
uint16_t release = (limit > hyst) ? (uint16_t)(limit - hyst) : 0;
if (rpm >= limit) active_ = true;
else if (rpm < release) active_ = false;
LimiterOutput o;
o.mode = PATTERN_OFF;
o.ratio = 0;
o.clusterN = 0;
if (!active_) return o;
if (launchArmed) {
o.mode = PATTERN_CLUSTER;
o.clusterN = cfg_.launchClusterN;
return o;
}
uint16_t band = cfg_.softBandRpm ? cfg_.softBandRpm : 1;
uint16_t start = (limit > band) ? (uint16_t)(limit - band) : 0;
uint32_t ratio = 0;
if (rpm > start) {
ratio = ((uint32_t)(rpm - start) * cfg_.maxRatioPercent) / band;
if (ratio > cfg_.maxRatioPercent) ratio = cfg_.maxRatioPercent;
}
o.mode = PATTERN_SPREAD;
o.ratio = (uint8_t)ratio;
return o;
}- Step 5: Run to verify it passes
Run: pio test -e native -f test_limiter
Expected: 7 tests, PASS.
Task 5: Arming interlocks, hold cap and cooldown
Files:
- Create:
/Users/levander/vitara/hardcut/lib/core/arming.h - Create:
/Users/levander/vitara/hardcut/lib/core/arming.cpp - Test:
/Users/levander/vitara/hardcut/test/test_arming/test_arming.cpp
Interfaces:
- Consumes: nothing.
- Produces:
struct ArmingInputs { bool brake; bool vssMoving; uint16_t rpm; bool igfHealthy; },struct ArmingLimits { uint32_t holdCapMs; uint32_t cooldownMs; uint16_t minRpm; }, andclass Armingwithvoid reset(),void setLimits(const ArmingLimits&),bool update(const ArmingInputs&, uint32_t nowMs),bool coolingDown(uint32_t nowMs) const.
Time is injected as nowMs so the module is testable without hardware.
- Step 1: Write the failing test
#include <unity.h>
#include "arming.h"
static ArmingLimits lim() {
ArmingLimits l;
l.holdCapMs = 5000;
l.cooldownMs = 60000;
l.minRpm = 1200;
return l;
}
static ArmingInputs good() {
ArmingInputs i;
i.brake = true;
i.vssMoving = false;
i.rpm = 2000;
i.igfHealthy = true;
return i;
}
void test_arms_when_all_conditions_met(void) {
Arming a;
a.reset();
a.setLimits(lim());
TEST_ASSERT_TRUE(a.update(good(), 1000));
}
void test_will_not_arm_without_brake(void) {
Arming a;
a.reset();
a.setLimits(lim());
ArmingInputs i = good();
i.brake = false;
TEST_ASSERT_FALSE(a.update(i, 1000));
}
void test_will_not_arm_while_moving(void) {
Arming a;
a.reset();
a.setLimits(lim());
ArmingInputs i = good();
i.vssMoving = true;
TEST_ASSERT_FALSE(a.update(i, 1000));
}
void test_will_not_arm_without_igf(void) {
Arming a;
a.reset();
a.setLimits(lim());
ArmingInputs i = good();
i.igfHealthy = false;
TEST_ASSERT_FALSE(a.update(i, 1000));
}
void test_will_not_arm_below_min_rpm(void) {
Arming a;
a.reset();
a.setLimits(lim());
ArmingInputs i = good();
i.rpm = 900;
TEST_ASSERT_FALSE(a.update(i, 1000));
}
void test_disarms_when_brake_released(void) {
Arming a;
a.reset();
a.setLimits(lim());
a.update(good(), 1000);
ArmingInputs i = good();
i.brake = false;
TEST_ASSERT_FALSE(a.update(i, 1500));
}
void test_disarms_when_vehicle_starts_moving(void) {
Arming a;
a.reset();
a.setLimits(lim());
a.update(good(), 1000);
ArmingInputs i = good();
i.vssMoving = true;
TEST_ASSERT_FALSE(a.update(i, 1500));
}
void test_hold_cap_releases_at_five_seconds(void) {
Arming a;
a.reset();
a.setLimits(lim());
a.update(good(), 1000);
TEST_ASSERT_TRUE(a.update(good(), 5900));
TEST_ASSERT_FALSE(a.update(good(), 6100));
}
void test_cooldown_blocks_rearm(void) {
Arming a;
a.reset();
a.setLimits(lim());
a.update(good(), 1000);
a.update(good(), 6100);
TEST_ASSERT_TRUE(a.coolingDown(30000));
TEST_ASSERT_FALSE(a.update(good(), 30000));
TEST_ASSERT_FALSE(a.coolingDown(70000));
TEST_ASSERT_TRUE(a.update(good(), 70000));
}
int main(int, char **) {
UNITY_BEGIN();
RUN_TEST(test_arms_when_all_conditions_met);
RUN_TEST(test_will_not_arm_without_brake);
RUN_TEST(test_will_not_arm_while_moving);
RUN_TEST(test_will_not_arm_without_igf);
RUN_TEST(test_will_not_arm_below_min_rpm);
RUN_TEST(test_disarms_when_brake_released);
RUN_TEST(test_disarms_when_vehicle_starts_moving);
RUN_TEST(test_hold_cap_releases_at_five_seconds);
RUN_TEST(test_cooldown_blocks_rearm);
return UNITY_END();
}- Step 2: Run to verify it fails
Run: pio test -e native -f test_arming
Expected: FAIL — arming.h not found.
- Step 3: Write
arming.h
#pragma once
#include <stdint.h>
struct ArmingInputs {
bool brake;
bool vssMoving;
uint16_t rpm;
bool igfHealthy;
};
struct ArmingLimits {
uint32_t holdCapMs;
uint32_t cooldownMs;
uint16_t minRpm;
};
class Arming {
public:
void reset();
void setLimits(const ArmingLimits& l);
bool update(const ArmingInputs& in, uint32_t nowMs);
bool coolingDown(uint32_t nowMs) const;
private:
ArmingLimits lim_;
bool armed_;
uint32_t armedAtMs_;
uint32_t cooldownUntilMs_;
bool cooldownActive_;
};- Step 4: Write
arming.cpp
#include "arming.h"
void Arming::reset() {
armed_ = false;
armedAtMs_ = 0;
cooldownUntilMs_ = 0;
cooldownActive_ = false;
}
void Arming::setLimits(const ArmingLimits& l) {
lim_ = l;
}
bool Arming::coolingDown(uint32_t nowMs) const {
if (!cooldownActive_) return false;
return (int32_t)(cooldownUntilMs_ - nowMs) > 0;
}
bool Arming::update(const ArmingInputs& in, uint32_t nowMs) {
bool permitted = in.brake && !in.vssMoving && in.igfHealthy && in.rpm >= lim_.minRpm;
if (armed_) {
if (!permitted) {
armed_ = false;
return false;
}
if ((uint32_t)(nowMs - armedAtMs_) >= lim_.holdCapMs) {
armed_ = false;
cooldownUntilMs_ = nowMs + lim_.cooldownMs;
cooldownActive_ = true;
return false;
}
return true;
}
if (!permitted || coolingDown(nowMs)) return false;
armed_ = true;
armedAtMs_ = nowMs;
cooldownActive_ = false;
return true;
}- Step 5: Run to verify it passes
Run: pio test -e native -f test_arming
Expected: 9 tests, PASS.
- Step 6: Run the whole native suite
Run: pio test -e native
Expected: 28 tests total, all PASS.
Task 6: Bench emulator firmware
A second ESP32 that pretends to be the ECM and the igniter, so every firmware path is exercised without the car.
Files:
- Create:
/Users/levander/vitara/hardcut/bench/emulator/platformio.ini - Create:
/Users/levander/vitara/hardcut/bench/emulator/src/main.cpp
Interfaces:
- Consumes: nothing.
- Produces: on GPIO 4, an IGt square wave with 3 ms dwell at a serial-settable RPM; on GPIO 5, an IGf pulse 200 µs after each observed IGt falling edge, suppressible by serial command.
Serial commands at 115200: r <rpm> sets RPM, s <0|1> suppresses/enables IGf, w <from> <to> <ms> sweeps RPM.
- Step 1: Create
bench/emulator/platformio.ini
[env:emulator]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200- Step 2: Write
bench/emulator/src/main.cpp
#include <Arduino.h>
static const int PIN_IGT = 4;
static const int PIN_IGF = 5;
static const uint32_t DWELL_US = 3000;
static volatile uint16_t g_rpm = 1000;
static volatile bool g_suppressIgf = false;
static uint32_t g_sweepEnd = 0;
static uint16_t g_sweepFrom = 0, g_sweepTo = 0;
static uint32_t g_sweepStart = 0, g_sweepMs = 0;
static uint32_t periodUsForRpm(uint16_t rpm) {
if (rpm < 100) rpm = 100;
return 30000000UL / rpm;
}
static void emitEvent() {
uint32_t period = periodUsForRpm(g_rpm);
uint32_t dwell = DWELL_US;
if (dwell > period / 2) dwell = period / 2;
digitalWrite(PIN_IGT, HIGH);
delayMicroseconds(dwell);
digitalWrite(PIN_IGT, LOW);
if (!g_suppressIgf) {
delayMicroseconds(200);
digitalWrite(PIN_IGF, LOW);
delayMicroseconds(100);
digitalWrite(PIN_IGF, HIGH);
delayMicroseconds(period - dwell - 300);
} else {
delayMicroseconds(period - dwell);
}
}
static void handleSerial() {
if (!Serial.available()) return;
String line = Serial.readStringUntil('\n');
line.trim();
if (line.startsWith("r ")) {
g_rpm = line.substring(2).toInt();
Serial.printf("rpm=%u\n", g_rpm);
} else if (line.startsWith("s ")) {
g_suppressIgf = line.substring(2).toInt() != 0;
Serial.printf("suppressIgf=%d\n", g_suppressIgf ? 1 : 0);
} else if (line.startsWith("w ")) {
int a = line.indexOf(' ', 2);
int b = line.indexOf(' ', a + 1);
g_sweepFrom = line.substring(2, a).toInt();
g_sweepTo = line.substring(a + 1, b).toInt();
g_sweepMs = line.substring(b + 1).toInt();
g_sweepStart = millis();
g_sweepEnd = g_sweepStart + g_sweepMs;
Serial.printf("sweep %u->%u over %ums\n", g_sweepFrom, g_sweepTo, g_sweepMs);
}
}
void setup() {
Serial.begin(115200);
pinMode(PIN_IGT, OUTPUT);
pinMode(PIN_IGF, OUTPUT);
digitalWrite(PIN_IGT, LOW);
digitalWrite(PIN_IGF, HIGH);
}
void loop() {
handleSerial();
if (g_sweepEnd && millis() < g_sweepEnd) {
uint32_t elapsed = millis() - g_sweepStart;
g_rpm = g_sweepFrom + (int32_t)(g_sweepTo - g_sweepFrom) * (int32_t)elapsed / (int32_t)g_sweepMs;
}
emitEvent();
}- Step 3: Flash and verify with the logic analyzer
Run: cd /Users/levander/vitara/hardcut/bench/emulator && pio run -t upload -t monitor
Type r 3000. Put the logic analyzer on GPIO 4 and 5.
Expected: IGt period 10.0 ms ±1%, dwell 3 ms, IGf pulsing low 200 µs after each IGt falling edge.
Type s 1. Expected: IGf stops, IGt continues.
Task 7: Gate engine on the ESP32
Files:
- Create:
/Users/levander/vitara/hardcut/src/ign.h - Create:
/Users/levander/vitara/hardcut/src/ign.cpp - Create:
/Users/levander/vitara/hardcut/src/main.cpp
Interfaces:
- Consumes:
rpmFromPeriodUs,PeriodFilter,CutPattern,Limiter,Arming. - Produces:
void ignBegin(int pinIgtIn, int pinIgfIn, int pinGateD),uint16_t ignRpm(),bool ignIgfHealthy(),void ignSetOutput(const LimiterOutput&),uint32_t ignEventCount(),uint32_t ignCutCount().
Pin map: IGt input GPIO 6, IGf input GPIO 7, flip-flop D output GPIO 15.
- Step 1: Write
src/ign.h
#pragma once
#include <stdint.h>
#include "limiter.h"
void ignBegin(int pinIgtIn, int pinIgfIn, int pinGateD);
uint16_t ignRpm();
bool ignIgfHealthy();
void ignSetOutput(const LimiterOutput& o);
uint32_t ignEventCount();
uint32_t ignCutCount();- Step 2: Write
src/ign.cpp
#include <Arduino.h>
#include "ign.h"
#include "rpm.h"
#include "pattern.h"
static int s_pinIgt = -1, s_pinIgf = -1, s_pinGate = -1;
static volatile uint32_t s_lastEdgeUs = 0;
static volatile uint32_t s_periodUs = 0;
static volatile uint32_t s_events = 0;
static volatile uint32_t s_cuts = 0;
static volatile uint32_t s_lastIgfUs = 0;
static PeriodFilter s_filter;
static CutPattern s_pattern;
static volatile bool s_nextIsCut = false;
static void IRAM_ATTR onIgtFall() {
uint32_t now = micros();
uint32_t dt = now - s_lastEdgeUs;
s_lastEdgeUs = now;
s_events++;
if (s_filter.accept(dt)) s_periodUs = dt;
bool cut = s_pattern.nextEventIsCut();
s_nextIsCut = cut;
if (cut) s_cuts++;
if (cut) GPIO.out_w1ts = (1UL << s_pinGate);
else GPIO.out_w1tc = (1UL << s_pinGate);
}
static void IRAM_ATTR onIgfFall() {
s_lastIgfUs = micros();
}
void ignBegin(int pinIgtIn, int pinIgfIn, int pinGateD) {
s_pinIgt = pinIgtIn;
s_pinIgf = pinIgfIn;
s_pinGate = pinGateD;
pinMode(s_pinIgt, INPUT);
pinMode(s_pinIgf, INPUT);
pinMode(s_pinGate, OUTPUT);
GPIO.out_w1tc = (1UL << s_pinGate);
s_filter.reset();
s_pattern.reset();
attachInterrupt(digitalPinToInterrupt(s_pinIgt), onIgtFall, FALLING);
attachInterrupt(digitalPinToInterrupt(s_pinIgf), onIgfFall, FALLING);
}
uint16_t ignRpm() {
uint32_t p = s_periodUs;
if (p == 0) return 0;
if ((uint32_t)(micros() - s_lastEdgeUs) > 500000UL) return 0;
return rpmFromPeriodUs(p);
}
bool ignIgfHealthy() {
if (s_lastIgfUs == 0) return false;
return (uint32_t)(micros() - s_lastIgfUs) < 500000UL;
}
void ignSetOutput(const LimiterOutput& o) {
s_pattern.configure(o.mode, o.clusterN, o.ratio);
}
uint32_t ignEventCount() { return s_events; }
uint32_t ignCutCount() { return s_cuts; }- Step 3: Write
src/main.cpp
#include <Arduino.h>
#include <WiFi.h>
#include "ign.h"
#include "limiter.h"
#include "arming.h"
static const int PIN_IGT_IN = 6;
static const int PIN_IGF_IN = 7;
static const int PIN_GATE_D = 15;
static const int PIN_BRAKE = 16;
static const int PIN_VSS = 17;
static Limiter s_limiter;
static Arming s_arming;
static volatile uint32_t s_lastVssMs = 0;
static void IRAM_ATTR onVss() {
s_lastVssMs = millis();
}
static LimiterConfig defaultConfig() {
LimiterConfig c;
c.launchRpm = 2500;
c.launchHystRpm = 80;
c.launchClusterN = 5;
c.mainRpm = 6300;
c.mainHystRpm = 150;
c.softBandRpm = 250;
c.maxRatioPercent = 83;
return c;
}
static ArmingLimits defaultLimits() {
ArmingLimits l;
l.holdCapMs = 5000;
l.cooldownMs = 60000;
l.minRpm = 1200;
return l;
}
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_OFF);
pinMode(PIN_BRAKE, INPUT_PULLUP);
pinMode(PIN_VSS, INPUT);
attachInterrupt(digitalPinToInterrupt(PIN_VSS), onVss, FALLING);
s_limiter.reset();
s_limiter.setConfig(defaultConfig());
s_arming.reset();
s_arming.setLimits(defaultLimits());
ignBegin(PIN_IGT_IN, PIN_IGF_IN, PIN_GATE_D);
}
void loop() {
uint32_t now = millis();
ArmingInputs in;
in.brake = digitalRead(PIN_BRAKE) == LOW;
in.vssMoving = (uint32_t)(now - s_lastVssMs) < 2000;
in.rpm = ignRpm();
in.igfHealthy = ignIgfHealthy();
bool armed = s_arming.update(in, now);
LimiterOutput o = s_limiter.update(in.rpm, armed);
ignSetOutput(o);
static uint32_t lastLog = 0;
if (now - lastLog >= 500) {
lastLog = now;
Serial.printf("rpm=%u armed=%d mode=%d ratio=%u ev=%lu cut=%lu igf=%d\n",
in.rpm, armed ? 1 : 0, (int)o.mode, o.ratio,
(unsigned long)ignEventCount(), (unsigned long)ignCutCount(),
in.igfHealthy ? 1 : 0);
}
delay(2);
}- Step 4: Build
Run: cd /Users/levander/vitara/hardcut && pio run -e esp32s3
Expected: build succeeds.
Task 8: Breadboard the front end, flip-flop and shunt
Files:
- Create:
/Users/levander/vitara/hardcut/hardware/schematic.md
Build on the BB-102 breadboard. Power everything from 3.3 V off the ESP32 dev board for this task; the igniter comes later.
- Step 1: Build the IGt input conditioner
Emulator GPIO 4 → 2.2 kΩ → node A. BAT54S from node A to 3V3 and GND. 470 pF from node A to GND. Node A → 74HC14 input (pin 1). 74HC14 pin 2 (inverted output) is IGT_N.
74HC14 VCC (pin 14) to 3V3, GND (pin 7) to GND, 100 nF decoupling across them. Tie all unused 74HC14 inputs (pins 3, 5, 9, 11, 13) to GND.
- Step 2: Build the IGf input conditioner
Emulator GPIO 5 → 2.2 kΩ → node B. BAT54S from node B to 3V3 and GND. 100 pF from node B to GND. Node B → 74HC14 pin 3, output pin 4 → ESP32 GPIO 7. Untie pin 3 from GND to do this.
Note the inversion: IGf is active-low and the 74HC14 inverts, so GPIO 7 sees a rising edge where IGf falls. Change attachInterrupt(..., onIgfFall, FALLING) to RISING in ign.cpp if you use the inverted output, or take IGf through two inverter stages to preserve polarity. Two stages is cleaner — use 74HC14 pins 3/4 then 5/6, and leave the ISR on FALLING.
- Step 3: Build the flip-flop gate
74HC74 (DIP14): VCC pin 14 to 3V3, GND pin 7 to GND, 100 nF decoupling.
-
1D(pin 2) ← ESP32 GPIO 15 -
1CLK(pin 3) ←IGT_Nfrom 74HC14 pin 2 -
1PRE(pin 4) → 3V3 -
1CLR(pin 1) → 3V3 through 10 kΩ, and to GND through a 100 nF cap so it clears on power-up -
1Q(pin 5) → gate of IRLZ44N through 100 Ω, with 100 kΩ from gate to GND -
IRLZ44N source → GND, drain → 22 Ω → node A (the IGt line)
-
Step 4: Verify the interlock on the logic analyzer
Probe: emulator IGt (GPIO 4), ESP32 GPIO 15 (D), 74HC74 pin 5 (Q), node A.
Set emulator r 3000.
Expected: Q only ever changes state coincident with IGt’s falling edge. D may change at any time; Q must not follow until the next falling edge.
- Step 5: Verify unpowered pass-through
Remove 3V3 from the ESP32 and the logic ICs, leaving the emulator running. Expected: node A follows emulator GPIO 4 unchanged. The 100 kΩ gate pull-down holds the MOSFET off, so the shunt is open and the signal passes.
Task 9: Hardware-in-the-loop verification
- Step 1: Pass-through with limiter disabled
Emulator r 1000. Serial log should show rpm=1000 mode=0, cut=0 not incrementing.
Logic analyzer: node A identical to emulator IGt.
- Step 2: RPM accuracy across the range
Emulator w 500 7000 30000. Watch the serial log.
Expected: reported rpm tracks the commanded sweep within 1% at every point.
- Step 3: Main limiter engages
Emulator r 6400.
Expected: mode=2, ratio=83, cut incrementing. On the analyzer, node A shows suppressed events.
- Step 4: The run-cap assertion, on real hardware
Capture 10 seconds at r 6400 on the logic analyzer. Export and count consecutive suppressed events.
Expected: no run of suppressed events longer than 5. This is the test that protects the fuel supply; if it fails, stop and fix before going further.
- Step 5: Hysteresis
r 6400 then r 6200 then r 6100.
Expected: cutting continues at 6200, stops at 6100.
- Step 6: IGf health gate
Emulator s 1 to suppress IGf. Wait 1 second.
Expected: igf=0, and arming is refused even with brake and stationary asserted.
- Step 7: Arming interlocks
Pull GPIO 16 low (brake), leave GPIO 17 with no pulses (stationary), emulator r 2600, s 0.
Expected: armed=1, mode=1 (cluster), and the analyzer shows groups of 5 cuts separated by single passes. After 5 seconds armed=0; re-arming refused until 60 s have elapsed.
Task 10: Validate against the real igniter
Bench only. Use the scrapyard igniter and coil, powered from a car battery, with a spark gap. Keep the coil away from everything and do not touch it while running.
- Step 1: Wire the igniter
Igniter +B to battery positive through a 10 A fuse. Igniter IG to coil primary negative, coil primary positive to battery positive. Igniter ground to battery negative. Common the battery negative with the breadboard GND.
Emulator GPIO 4 → 150 Ω → igniter IGt, as per the factory bench-fire procedure.
- Step 2: Confirm a real spark
Emulator r 600.
Expected: regular spark at the gap. If not, stop — the emulator’s 3.3 V through 150 Ω may not deliver the ~11 mA the igniter gate needs. Substitute a 150 Ω to +12 V driven by a level-shifting transistor from GPIO 4.
- Step 3: Measure the real IGt→IGf delay
Probe igniter IGt and IGf on the logic analyzer.
Record the delay and the IGf low duration. Note them in hardware/schematic.md — they are the reference if the forgery path is ever enabled.
- Step 4: Confirm the shunt suppresses a real spark
Connect the MOSFET drain through 22 Ω to the igniter’s IGt node. Run the limiter at r 6400.
Expected: visible misfire pattern at the gap, matching the cut ratio. Measure the IGt node voltage while shunted — it must sit below 1.4 V.
- Step 5: Confirm the run cap on a real igniter
Capture IGt, IGf and the gap. Expected: no more than 5 suppressed sparks in a row, and IGf present on every fired event.
Task 11: Config persistence with clamped bounds
Files:
- Create:
/Users/levander/vitara/hardcut/lib/core/bounds.h - Create:
/Users/levander/vitara/hardcut/lib/core/bounds.cpp - Create:
/Users/levander/vitara/hardcut/src/config.h - Create:
/Users/levander/vitara/hardcut/src/config.cpp - Test:
/Users/levander/vitara/hardcut/test/test_bounds/test_bounds.cpp
Interfaces:
-
Produces:
LimiterConfig clampConfig(LimiterConfig c)inbounds.h;void configBegin(),LimiterConfig configGet(),void configSet(const LimiterConfig&)inconfig.h. -
Step 1: Write the failing bounds test
#include <unity.h>
#include "bounds.h"
void test_clamps_main_limit_to_ceiling(void) {
LimiterConfig c = {};
c.mainRpm = 9000;
TEST_ASSERT_EQUAL_UINT16(6600, clampConfig(c).mainRpm);
}
void test_clamps_launch_limit(void) {
LimiterConfig c = {};
c.launchRpm = 9000;
TEST_ASSERT_EQUAL_UINT16(6600, clampConfig(c).launchRpm);
}
void test_clamps_cluster_to_run_cap(void) {
LimiterConfig c = {};
c.launchClusterN = 40;
TEST_ASSERT_EQUAL_UINT8(5, clampConfig(c).launchClusterN);
}
void test_clamps_ratio(void) {
LimiterConfig c = {};
c.maxRatioPercent = 200;
TEST_ASSERT_EQUAL_UINT8(83, clampConfig(c).maxRatioPercent);
}
int main(int, char **) {
UNITY_BEGIN();
RUN_TEST(test_clamps_main_limit_to_ceiling);
RUN_TEST(test_clamps_launch_limit);
RUN_TEST(test_clamps_cluster_to_run_cap);
RUN_TEST(test_clamps_ratio);
return UNITY_END();
}- Step 2: Run to verify it fails
Run: pio test -e native -f test_bounds
Expected: FAIL.
- Step 3: Write
bounds.handbounds.cpp
#pragma once
#include "limiter.h"
#include "pattern.h"
static const uint16_t RPM_CEILING = 6600;
static const uint8_t RATIO_CEILING = 83;
LimiterConfig clampConfig(LimiterConfig c);#include "bounds.h"
LimiterConfig clampConfig(LimiterConfig c) {
if (c.mainRpm > RPM_CEILING) c.mainRpm = RPM_CEILING;
if (c.launchRpm > RPM_CEILING) c.launchRpm = RPM_CEILING;
if (c.launchClusterN > CutPattern::MAX_CONSECUTIVE_CUTS)
c.launchClusterN = CutPattern::MAX_CONSECUTIVE_CUTS;
if (c.maxRatioPercent > RATIO_CEILING) c.maxRatioPercent = RATIO_CEILING;
return c;
}- Step 4: Run to verify it passes
Run: pio test -e native -f test_bounds
Expected: 4 tests, PASS.
- Step 5: Write
src/config.cppusing Preferences
#include <Preferences.h>
#include "config.h"
#include "bounds.h"
static Preferences s_prefs;
static LimiterConfig s_cfg;
void configBegin() {
s_prefs.begin("hardcut", false);
s_cfg.launchRpm = s_prefs.getUShort("lrpm", 2500);
s_cfg.launchHystRpm = s_prefs.getUShort("lhys", 80);
s_cfg.launchClusterN = s_prefs.getUChar("lcl", 5);
s_cfg.mainRpm = s_prefs.getUShort("mrpm", 6300);
s_cfg.mainHystRpm = s_prefs.getUShort("mhys", 150);
s_cfg.softBandRpm = s_prefs.getUShort("band", 250);
s_cfg.maxRatioPercent = s_prefs.getUChar("ratio", 83);
s_cfg = clampConfig(s_cfg);
}
LimiterConfig configGet() {
return s_cfg;
}
void configSet(const LimiterConfig& c) {
s_cfg = clampConfig(c);
s_prefs.putUShort("lrpm", s_cfg.launchRpm);
s_prefs.putUShort("lhys", s_cfg.launchHystRpm);
s_prefs.putUChar("lcl", s_cfg.launchClusterN);
s_prefs.putUShort("mrpm", s_cfg.mainRpm);
s_prefs.putUShort("mhys", s_cfg.mainHystRpm);
s_prefs.putUShort("band", s_cfg.softBandRpm);
s_prefs.putUChar("ratio", s_cfg.maxRatioPercent);
}- Step 6: Guard writes while armed
In main.cpp, refuse configSet when armed is true. NVS writes disable the flash cache for milliseconds and will drop ignition events.
Task 12: WiFi config page
Files:
- Create:
/Users/levander/vitara/hardcut/src/web.h - Create:
/Users/levander/vitara/hardcut/src/web.cpp
Single inline HTML string, JSON API, vanilla JS, dark theme, phone-responsive — matching the house pattern. AP mode only, started on demand and stopped whenever armed.
Interfaces:
-
Produces:
void webBegin(),void webStop(),void webLoop(),bool webRunning(). -
Endpoints:
GET /serves the page;GET /api/statereturns{rpm, armed, mode, ratio, events, cuts, igf};POST /api/configaccepts theLimiterConfigfields as form-encoded values. -
Step 1: Implement AP + server with the page as a
const char*constant
Serve at 192.168.4.1. SSID hardcut, WPA2, password set in config.cpp.
- Step 2: Poll
/api/statefrom the page with a 500 ms debounced fetch
Show RPM, armed state, mode, cut ratio and IGf health. Dark theme, large readable numerals, no framework, no build step.
- Step 3: Enforce the radio rule in
main.cpp
if (armed && webRunning()) webStop();- Step 4: Verify
Connect a phone to the hardcut AP, load 192.168.4.1, confirm live RPM from the emulator. Assert the brake input low and confirm the AP drops within one loop iteration.
Task 13: Car measurement gate
Read-only. Nothing is cut. Engine off unless a step says otherwise.
- Step 1: Identity check
Count the cavities on both ECM couplers. Expected: 22-pin “A” and 26-pin “B”. Read the VIN’s 8th digit and confirm the 16-valve code. Confirm four injectors and an air flow meter rather than a throttle-body injector and a MAP sensor. If the cavity count differs, stop. Every pin number in the design rests on this.
- Step 2: Continuity, igniter to ECM
Meter between the igniter connector’s IGt terminal and ECM A4, then IGf and B5. Confirm Orange and Blue/Green respectively.
Expected: continuity on both. This promotes the pinout from a Vitara manual to a measurement on this car.
- Step 3: IGt levels
Key on, engine cranking. Scope or meter on the igniter IGt terminal to engine ground.
Expected: pulses within 0–3.5 V. Note the resting level with ignition on — expected near 0 V.
- Step 4: IGf pull-up
Unplug the igniter. Key on. Measure the IGf wire to ground.
Expected: ~5 V, confirming the pull-up is inside the ECM.
- Step 5: The source-impedance test — the go/no-go
Key on, engine off, nothing unplugged. Measure IGt high level = V1. Add a known 220 Ω from IGt to ground, measure again = V2.
Compute R_out = (V1 - V2) / (V2 / 220).
Choose R_sh ≤ R_out / 6.
If R_out < 60 Ω, abandon the parallel shunt and switch to the documented series-interrupt fallback. At that impedance the shunt current is limited only by R_sh and would damage the ECM’s driver.
- Step 6: Record everything
Write the measured values into hardware/schematic.md and jot the tap points into the pipeline harness format.
Task 14: Car commissioning
- Step 1: Install read-only
Tap IGt and IGf with the box’s inputs only — leave the MOSFET drains disconnected. Ground the box to ECM pin B14. Run the engine and confirm RPM tracks the tachometer and igf=1.
- Step 2: Fit the brake microswitch
Mount on the pedal bracket so it makes well into the stroke, not at the top. Confirm on the serial log that it reads pressed only when the pedal is genuinely applied.
- Step 3: Measure actual stall speed
Brake fully applied, “D”, full throttle. Read the rpm when it stabilises. Release immediately — 5 seconds maximum, then 60 seconds at idle before repeating.
Set launchRpm just above the measured figure.
- Step 4: Main limiter first
Set mainRpm to 5000 for the first test — well below the factory 6720 and below peak power. Engine at temperature, in neutral. Brief excursion to the limiter.
Expected: it holds. Then read DTCs at the 6-pin DLC (jumper 2↔4) and confirm no code 41. Raise toward 6300 only once clean.
- Step 5: Two-step
Brake-torque to the launch limit. Confirm bangs, confirm release on brake lift, confirm the 5 s cap fires and the 60 s cooldown blocks re-arming.
- Step 6: Post-session checks
Read DTCs. Check ATF colour and smell. Inspect the exhaust manifold at the runner junctions. Expect muffler packing to degrade over time — it is a consumable.
Self-review notes
Spec coverage:
- Two-step launch limiter → Tasks 4, 5, 9, 14
- Configurable main limiter → Tasks 4, 11, 14
- Hard (clustered) and soft (spread) patterns → Task 3
- Consecutive-cut cap of 5 → Task 3 (unit), Task 9 step 4 (HIL), Task 10 step 5 (real igniter), Task 11 (config clamp)
- IGt shunt at 22 Ω → Tasks 8, 10, 13 step 5
- IGf forgery hardware fitted but idle → Task 8 fits the input path; the drive FET is unpopulated in v1 by design
- Gate cannot change mid-dwell → Task 8 step 3 (flip-flop), Task 8 step 4 (verified)
- Fail-safe when unpowered → Task 8 step 5
- Decide at edge N, apply at N+1 → inherent in the flip-flop; verified Task 8 step 4
- RPM from IGt falling edges → Task 2, Task 9 step 2
- VSS + brake interlocks → Task 5, Task 9 step 7, Task 14 step 2
- 5 s hold cap, 60 s cooldown → Task 5, Task 9 step 7
- Radio off while armed → Task 12 step 3
- No NVS write while armed → Task 11 step 6
- Firmware-clamped ceiling → Task 11
- WiFi config UI → Task 12
- Bench emulator → Task 6
- Measurement gate with go/no-go → Task 13 step 5
- Stall measurement replaces the placeholder launch RPM → Task 14 step 3
Known gaps, deliberately left:
- Spark retard mode is a future lever, not planned here.
- The IGf forgery drive FET is fitted on the board but has no firmware path in v1. If Task 10 or 14 shows the run cap is insufficient, that becomes a new task.
hardware/schematic.mdis written incrementally across Tasks 8, 10 and 13 rather than up front, because half its content is measured rather than designed.