Skip to content

Instantly share code, notes, and snippets.

@Williangalvani
Last active August 26, 2026 19:54
Show Gist options
  • Select an option

  • Save Williangalvani/5cb721ea5f06c2700b906865cd5372f9 to your computer and use it in GitHub Desktop.

Select an option

Save Williangalvani/5cb721ea5f06c2700b906865cd5372f9 to your computer and use it in GitHub Desktop.
AP_Motors6DOF mixer equivalence proof for merge of Sub output functions
#!/usr/bin/env python3
"""Extract old/new AP_Motors6DOF mixer functions from git and emit a C++ comparison harness."""
from __future__ import annotations
import hashlib
import os
import subprocess
import textwrap
REPO = "/home/will/git/ardupilot"
OUT_DIR = "/tmp/sub-mixer-equivalence"
SRC_PATH = "libraries/AP_Motors/AP_Motors6DOF.cpp"
def git(*args: str) -> str:
return subprocess.check_output(["git", "-C", REPO, *args], text=True)
def extract_function(src: str, qualname: str) -> str:
needle = f"void {qualname}("
start = src.find(needle)
if start < 0:
raise SystemExit(f"could not find {qualname}")
# include preceding comment block if present
comment = src.rfind("\n//", 0, start)
if comment >= 0 and src[comment:start].count("\n") <= 8:
# only keep comments immediately above the function
header = src[comment + 1 : start]
if all(line.startswith("//") or line.strip() == "" for line in header.splitlines()):
start = comment + 1
i = src.find("{", start)
depth = 0
for j in range(i, len(src)):
if src[j] == "{":
depth += 1
elif src[j] == "}":
depth -= 1
if depth == 0:
return src[start : j + 1]
raise SystemExit(f"unbalanced braces for {qualname}")
def sha256(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
def rewrite_methods(body: str) -> str:
return body.replace("void AP_Motors6DOF::", "void ")
OLD_SRC = git("show", f"HEAD^:{SRC_PATH}")
NEW_SRC = git("show", f"HEAD:{SRC_PATH}")
OLD_FUNCS = [
extract_function(OLD_SRC, "AP_Motors6DOF::output_armed_stabilizing"),
extract_function(OLD_SRC, "AP_Motors6DOF::output_armed_stabilizing_vectored"),
extract_function(OLD_SRC, "AP_Motors6DOF::output_armed_stabilizing_vectored_6dof"),
]
NEW_FUNC = extract_function(NEW_SRC, "AP_Motors6DOF::output_armed_stabilizing")
HEAD = git("rev-parse", "HEAD").strip()
PARENT = git("rev-parse", "HEAD^").strip()
HEAD_SUBJECT = git("log", "-1", "--format=%s", "HEAD").strip()
old_text = "\n\n".join(OLD_FUNCS) + "\n"
new_text = NEW_FUNC + "\n"
hashes = {
"old_output_armed_stabilizing": sha256(OLD_FUNCS[0]),
"old_vectored": sha256(OLD_FUNCS[1]),
"old_vectored_6dof": sha256(OLD_FUNCS[2]),
"new_output_armed_stabilizing": sha256(NEW_FUNC),
}
with open(os.path.join(OUT_DIR, "extracted_old.cpp"), "w") as f:
f.write(old_text)
with open(os.path.join(OUT_DIR, "extracted_new.cpp"), "w") as f:
f.write(new_text)
with open(os.path.join(OUT_DIR, "source_hashes.txt"), "w") as f:
f.write(f"HEAD {HEAD} {HEAD_SUBJECT}\n")
f.write(f"PARENT {PARENT}\n")
for k, v in hashes.items():
f.write(f"{k} {v}\n")
old_methods = rewrite_methods(old_text)
new_methods = rewrite_methods(new_text)
cpp = f'''// Auto-generated mixer equivalence proof.
// Old functions extracted from {PARENT}:{SRC_PATH}
// New function extracted from {HEAD}:{SRC_PATH}
//
// SHA256 of extracted text:
// old output_armed_stabilizing {hashes["old_output_armed_stabilizing"]}
// old output_armed_stabilizing_vectored {hashes["old_vectored"]}
// old output_armed_stabilizing_vectored_6dof {hashes["old_vectored_6dof"]}
// new output_armed_stabilizing {hashes["new_output_armed_stabilizing"]}
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <random>
#include <string>
#include <vector>
#define AP_MOTORS_MAX_NUM_MOTORS 12
#define AP_BATTERY_ENABLED 0
static inline bool is_zero(const float x) {{
return fabsf(x) < 1.1920928955078125e-07f; // FLT_EPSILON
}}
static inline float constrain_float(float amt, float low, float high) {{
if (std::isnan(amt)) {{
return (low + high) / 2.0f;
}}
if (amt < low) {{
return low;
}}
if (amt > high) {{
return high;
}}
return amt;
}}
enum sub_frame_t {{
SUB_FRAME_BLUEROV1 = 0,
SUB_FRAME_VECTORED = 1,
SUB_FRAME_VECTORED_6DOF = 2,
SUB_FRAME_VECTORED_6DOF_90DEG = 3,
SUB_FRAME_SIMPLEROV_3 = 4,
SUB_FRAME_SIMPLEROV_4 = 5,
SUB_FRAME_SIMPLEROV_5 = 6,
SUB_FRAME_CUSTOM = 7
}};
struct Limits {{
bool roll, pitch, yaw, throttle_lower, throttle_upper;
void set_all(bool flag) {{
roll = pitch = yaw = throttle_lower = throttle_upper = flag;
}}
bool operator==(const Limits &o) const {{
return roll == o.roll && pitch == o.pitch && yaw == o.yaw &&
throttle_lower == o.throttle_lower && throttle_upper == o.throttle_upper;
}}
}};
struct MixerState {{
float _roll_in, _roll_in_ff;
float _pitch_in, _pitch_in_ff;
float _yaw_in, _yaw_in_ff;
float _forward_in;
float _lateral_in;
float _throttle_bidir;
float _throttle_thrust_max;
float _forwardVerticalCouplingFactor;
float _max_throttle;
float _output_limited;
int _active_frame_class;
bool motor_enabled[AP_MOTORS_MAX_NUM_MOTORS];
float _roll_factor[AP_MOTORS_MAX_NUM_MOTORS];
float _pitch_factor[AP_MOTORS_MAX_NUM_MOTORS];
float _yaw_factor[AP_MOTORS_MAX_NUM_MOTORS];
float _throttle_factor[AP_MOTORS_MAX_NUM_MOTORS];
float _forward_factor[AP_MOTORS_MAX_NUM_MOTORS];
float _lateral_factor[AP_MOTORS_MAX_NUM_MOTORS];
int8_t _motor_reverse[AP_MOTORS_MAX_NUM_MOTORS];
float _thrust_rpyt_out[AP_MOTORS_MAX_NUM_MOTORS];
Limits limit;
float get_throttle_bidirectional() const {{
return constrain_float(_throttle_bidir, -1.0f, 1.0f);
}}
}};
struct OldMixer : MixerState {{
{textwrap.indent(old_methods, " ")}
}};
struct NewMixer : MixerState {{
{textwrap.indent(new_methods, " ")}
}};
static_assert(sizeof(OldMixer) == sizeof(NewMixer), "mixer state layout mismatch");
struct MotorDef {{
int num;
float roll, pitch, yaw, thr, fwd, lat;
}};
static void apply_layout(MixerState &s, const MotorDef *defs, int n) {{
for (int i = 0; i < AP_MOTORS_MAX_NUM_MOTORS; i++) {{
s.motor_enabled[i] = false;
s._roll_factor[i] = s._pitch_factor[i] = s._yaw_factor[i] = 0;
s._throttle_factor[i] = s._forward_factor[i] = s._lateral_factor[i] = 0;
s._motor_reverse[i] = 1;
s._thrust_rpyt_out[i] = 0;
}}
for (int i = 0; i < n; i++) {{
const int m = defs[i].num;
s.motor_enabled[m] = true;
s._roll_factor[m] = defs[i].roll;
s._pitch_factor[m] = defs[i].pitch;
s._yaw_factor[m] = defs[i].yaw;
s._throttle_factor[m] = defs[i].thr;
s._forward_factor[m] = defs[i].fwd;
s._lateral_factor[m] = defs[i].lat;
s._motor_reverse[m] = 1;
}}
}}
// Layouts copied from AP_Motors6DOF::setup_motors()
static const MotorDef LAYOUT_BLUEROV1[] = {{
{{0, 0, 0, -1, 0, 1, 0}},
{{1, 0, 0, 1, 0, 1, 0}},
{{2, -0.5f, 0.5f, 0, 0.45f, 0, 0}},
{{3, 0.5f, 0.5f, 0, 0.45f, 0, 0}},
{{4, 0, -1, 0, 1, 0, 0}},
{{5, -0.25f, 0, 0, 0, 0, 1}},
}};
static const MotorDef LAYOUT_VECTORED[] = {{
{{0, 0, 0, 1, 0, -1, 1}},
{{1, 0, 0, -1, 0, -1, -1}},
{{2, 0, 0, -1, 0, 1, 1}},
{{3, 0, 0, 1, 0, 1, -1}},
{{4, 1, 0, 0, -1, 0, 0}},
{{5, -1, 0, 0, -1, 0, 0}},
}};
static const MotorDef LAYOUT_VECTORED_6DOF[] = {{
{{0, 0, 0, 1, 0, -1, 1}},
{{1, 0, 0, -1, 0, -1, -1}},
{{2, 0, 0, -1, 0, 1, 1}},
{{3, 0, 0, 1, 0, 1, -1}},
{{4, 1, -1, 0, -1, 0, 0}},
{{5, -1, -1, 0, -1, 0, 0}},
{{6, 1, 1, 0, -1, 0, 0}},
{{7, -1, 1, 0, -1, 0, 0}},
}};
static const MotorDef LAYOUT_VECTORED_6DOF_90DEG[] = {{
{{0, 1, 1, 0, 1, 0, 0}},
{{1, 0, 0, 1, 0, 1, 0}},
{{2, 1, -1, 0, 1, 0, 0}},
{{3, 0, 0, 0, 0, 0, 1}},
{{4, 0, 0, 0, 0, 0, 1}},
{{5, -1, 1, 0, 1, 0, 0}},
{{6, 0, 0, -1, 0, 1, 0}},
{{7, -1, -1, 0, 1, 0, 0}},
}};
static const MotorDef LAYOUT_SIMPLEROV_3[] = {{
{{0, 0, 0, -1, 0, 1, 0}},
{{1, 0, 0, 1, 0, 1, 0}},
{{2, 0, 0, 0, -1, 0, 0}},
}};
static const MotorDef LAYOUT_DEFAULT[] = {{
{{0, 0, 0, -1, 0, 1, 0}},
{{1, 0, 0, 1, 0, 1, 0}},
{{2, 1, 0, 0, -1, 0, 0}},
{{3, -1, 0, 0, -1, 0, 0}},
{{4, 0, 0, 0, 0, 0, 1}},
}};
struct FrameCase {{
const char *name;
sub_frame_t frame;
const MotorDef *defs;
int n;
}};
static const FrameCase FRAMES[] = {{
{{"BLUEROV1", SUB_FRAME_BLUEROV1, LAYOUT_BLUEROV1, 6}},
{{"VECTORED", SUB_FRAME_VECTORED, LAYOUT_VECTORED, 6}},
{{"VECTORED_6DOF", SUB_FRAME_VECTORED_6DOF, LAYOUT_VECTORED_6DOF, 8}},
{{"VECTORED_6DOF_90DEG", SUB_FRAME_VECTORED_6DOF_90DEG, LAYOUT_VECTORED_6DOF_90DEG, 8}},
{{"SIMPLEROV_3", SUB_FRAME_SIMPLEROV_3, LAYOUT_SIMPLEROV_3, 3}},
{{"DEFAULT", SUB_FRAME_SIMPLEROV_4, LAYOUT_DEFAULT, 5}},
}};
static uint32_t fbits(float x) {{
uint32_t u;
static_assert(sizeof(float) == 4, "float must be 32-bit");
std::memcpy(&u, &x, 4);
return u;
}}
static void set_inputs(MixerState &s, float roll, float pitch, float yaw,
float throttle, float forward, float lateral,
float roll_ff = 0, float pitch_ff = 0, float yaw_ff = 0,
float thr_max = 1.0f, float fv_k = 1.0f,
float max_thr = 1.0f, float out_lim = 1.0f) {{
s._roll_in = roll; s._roll_in_ff = roll_ff;
s._pitch_in = pitch; s._pitch_in_ff = pitch_ff;
s._yaw_in = yaw; s._yaw_in_ff = yaw_ff;
s._throttle_bidir = throttle;
s._forward_in = forward;
s._lateral_in = lateral;
s._throttle_thrust_max = thr_max;
s._forwardVerticalCouplingFactor = fv_k;
s._max_throttle = max_thr;
s._output_limited = out_lim;
}}
static bool compare(const OldMixer &a, const NewMixer &b, std::string &why) {{
if (!(a.limit == b.limit)) {{
why = "limit flags differ";
return false;
}}
if (a._output_limited != b._output_limited &&
fbits(a._output_limited) != fbits(b._output_limited)) {{
why = "_output_limited differs";
return false;
}}
for (int i = 0; i < AP_MOTORS_MAX_NUM_MOTORS; i++) {{
if (fbits(a._thrust_rpyt_out[i]) != fbits(b._thrust_rpyt_out[i])) {{
char buf[256];
std::snprintf(buf, sizeof(buf),
"motor %d bits differ old=%08x (%a) new=%08x (%a)",
i, fbits(a._thrust_rpyt_out[i]), a._thrust_rpyt_out[i],
fbits(b._thrust_rpyt_out[i]), b._thrust_rpyt_out[i]);
why = buf;
return false;
}}
}}
return true;
}}
static void print_outputs(FILE *fp, const char *tag, const MixerState &s) {{
std::fprintf(fp, " %s motors:", tag);
for (int i = 0; i < AP_MOTORS_MAX_NUM_MOTORS; i++) {{
if (!s.motor_enabled[i]) {{
continue;
}}
std::fprintf(fp, " M%d=%a", i + 1, s._thrust_rpyt_out[i]);
}}
std::fprintf(fp, " lim_thr=[%d,%d]\\n",
(int)s.limit.throttle_lower, (int)s.limit.throttle_upper);
}}
int main() {{
std::fprintf(stdout, "AP_Motors6DOF mixer equivalence proof\\n");
std::fprintf(stdout, "old: %s\\n", "{PARENT}");
std::fprintf(stdout, "new: %s (%s)\\n", "{HEAD}", "{HEAD_SUBJECT}");
std::fprintf(stdout, "comparison: bit-identical IEEE-754 float32 bits of _thrust_rpyt_out[12]\\n");
std::fprintf(stdout, " plus throttle limit flags and _output_limited\\n\\n");
uint64_t total = 0;
uint64_t failed = 0;
std::string why;
// Deterministic unit-axis and saturation cases on every stock frame.
struct NamedInput {{
const char *name;
float roll, pitch, yaw, thr, fwd, lat;
float thr_max, fv_k, max_thr, out_lim;
}};
const NamedInput named[] = {{
{{"zeros", 0, 0, 0, 0, 0, 0, 1, 1, 1, 1}},
{{"roll+", 1, 0, 0, 0, 0, 0, 1, 1, 1, 1}},
{{"roll-", -1, 0, 0, 0, 0, 0, 1, 1, 1, 1}},
{{"pitch+", 0, 1, 0, 0, 0, 0, 1, 1, 1, 1}},
{{"yaw+", 0, 0, 1, 0, 0, 0, 1, 1, 1, 1}},
{{"throttle+", 0, 0, 0, 1, 0, 0, 1, 1, 1, 1}},
{{"throttle-", 0, 0, 0, -1, 0, 0, 1, 1, 1, 1}},
{{"forward+", 0, 0, 0, 0, 1, 0, 1, 1, 1, 1}},
{{"forward-", 0, 0, 0, 0, -1, 0, 1, 1, 1, 1}},
{{"lateral+", 0, 0, 0, 0, 0, 1, 1, 1, 1, 1}},
{{"fwd+thr coupling", 0, 0, 0, 0.8f, 0.8f, 0, 1, 1.2f, 1, 1}},
{{"fwd-thr coupling", 0, 0, 0, -0.8f, -0.8f, 0, 1, 1.2f, 1, 1}},
{{"all-axes saturate", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}},
{{"throttle over max", 0, 0, 0, 1.5f, 0, 0, 0.6f, 1, 1, 1}},
{{"throttle under min", 0, 0, 0, -1.5f, 0, 0, 0.6f, 1, 1, 1}},
{{"6dof max_throttle 0.5", 0, 0, 0, 1, 0, 0, 1, 1, 0.5f, 1}},
{{"output_limited 0.5", 0, 0, 0, 0.7f, 0.4f, 0.2f, 1, 1, 1, 0.5f}},
{{"near is_zero forward", 0, 0, 0, 0.5f, 1.0e-7f, 0, 1, 1.2f, 1, 1}},
{{"ff terms", 0.2f, -0.3f, 0.4f, 0.1f, -0.2f, 0.3f, 1, 1, 1, 1}},
}};
std::fprintf(stdout, "## Named cases (stock frames)\\n");
for (const auto &fr : FRAMES) {{
for (const auto &c : named) {{
OldMixer oldm{{}};
NewMixer newm{{}};
apply_layout(oldm, fr.defs, fr.n);
apply_layout(newm, fr.defs, fr.n);
oldm._active_frame_class = fr.frame;
newm._active_frame_class = fr.frame;
set_inputs(oldm, c.roll, c.pitch, c.yaw, c.thr, c.fwd, c.lat,
0, 0, 0, c.thr_max, c.fv_k, c.max_thr, c.out_lim);
set_inputs(newm, c.roll, c.pitch, c.yaw, c.thr, c.fwd, c.lat,
0, 0, 0, c.thr_max, c.fv_k, c.max_thr, c.out_lim);
// feed-forward for the ff case
if (std::strcmp(c.name, "ff terms") == 0) {{
oldm._roll_in_ff = 0.15f; newm._roll_in_ff = 0.15f;
oldm._pitch_in_ff = -0.10f; newm._pitch_in_ff = -0.10f;
oldm._yaw_in_ff = 0.05f; newm._yaw_in_ff = 0.05f;
}}
oldm.output_armed_stabilizing();
newm.output_armed_stabilizing();
total++;
if (!compare(oldm, newm, why)) {{
failed++;
std::fprintf(stdout, "FAIL %s / %s: %s\\n", fr.name, c.name, why.c_str());
}} else if (std::strcmp(c.name, "zeros") == 0 ||
std::strcmp(c.name, "all-axes saturate") == 0 ||
std::strcmp(c.name, "fwd+thr coupling") == 0) {{
std::fprintf(stdout, "OK %s / %s\\n", fr.name, c.name);
print_outputs(stdout, "old", oldm);
print_outputs(stdout, "new", newm);
}}
}}
}}
// Random fuzz: stock layouts + random inputs, plus random factor matrices.
std::mt19937 rng(0x6D697865); // 'mixe'
std::uniform_real_distribution<float> unit(-1.5f, 1.5f);
std::uniform_real_distribution<float> pos(0.05f, 1.0f);
std::uniform_real_distribution<float> kfac(0.0f, 1.5f);
std::uniform_int_distribution<int> coin(0, 1);
const int kFuzzPerFrame = 200000;
std::fprintf(stdout, "\\n## Random fuzz (%d cases per stock frame)\\n", kFuzzPerFrame);
for (const auto &fr : FRAMES) {{
uint64_t frame_fail = 0;
for (int n = 0; n < kFuzzPerFrame; n++) {{
OldMixer oldm{{}};
NewMixer newm{{}};
apply_layout(oldm, fr.defs, fr.n);
apply_layout(newm, fr.defs, fr.n);
oldm._active_frame_class = fr.frame;
newm._active_frame_class = fr.frame;
const float r = unit(rng), p = unit(rng), y = unit(rng);
const float t = unit(rng), f = unit(rng), l = unit(rng);
const float rff = unit(rng) * 0.3f, pff = unit(rng) * 0.3f, yff = unit(rng) * 0.3f;
const float tmax = pos(rng);
const float fv = kfac(rng);
const float mt = pos(rng);
const float ol = pos(rng);
set_inputs(oldm, r, p, y, t, f, l, rff, pff, yff, tmax, fv, mt, ol);
set_inputs(newm, r, p, y, t, f, l, rff, pff, yff, tmax, fv, mt, ol);
if (coin(rng)) {{
for (int i = 0; i < AP_MOTORS_MAX_NUM_MOTORS; i++) {{
if (oldm.motor_enabled[i]) {{
const int8_t rev = coin(rng) ? 1 : -1;
oldm._motor_reverse[i] = rev;
newm._motor_reverse[i] = rev;
}}
}}
}}
oldm.output_armed_stabilizing();
newm.output_armed_stabilizing();
total++;
if (!compare(oldm, newm, why)) {{
failed++;
frame_fail++;
if (frame_fail <= 3) {{
std::fprintf(stdout, "FAIL %s fuzz #%d: %s\\n", fr.name, n, why.c_str());
}}
}}
}}
std::fprintf(stdout, "%s: %d/%d bit-identical\\n",
fr.name, kFuzzPerFrame - (int)frame_fail, kFuzzPerFrame);
}}
const int kRandomLayout = 200000;
std::fprintf(stdout, "\\n## Random factor matrices (%d cases, all 3 mixer branches)\\n", kRandomLayout);
const sub_frame_t branches[] = {{
SUB_FRAME_BLUEROV1, SUB_FRAME_VECTORED, SUB_FRAME_VECTORED_6DOF
}};
const char *branch_names[] = {{"generic", "vectored", "vectored_6dof"}};
for (int b = 0; b < 3; b++) {{
uint64_t bf = 0;
for (int n = 0; n < kRandomLayout; n++) {{
OldMixer oldm{{}};
NewMixer newm{{}};
oldm._active_frame_class = branches[b];
newm._active_frame_class = branches[b];
for (int i = 0; i < AP_MOTORS_MAX_NUM_MOTORS; i++) {{
const bool en = (i < 8) ? true : (coin(rng) == 1);
oldm.motor_enabled[i] = newm.motor_enabled[i] = en;
const float rf = unit(rng), pf = unit(rng), yf = unit(rng);
const float tf = unit(rng), ff = unit(rng), lf = unit(rng);
const int8_t rev = coin(rng) ? 1 : -1;
oldm._roll_factor[i] = newm._roll_factor[i] = rf;
oldm._pitch_factor[i] = newm._pitch_factor[i] = pf;
oldm._yaw_factor[i] = newm._yaw_factor[i] = yf;
oldm._throttle_factor[i] = newm._throttle_factor[i] = tf;
oldm._forward_factor[i] = newm._forward_factor[i] = ff;
oldm._lateral_factor[i] = newm._lateral_factor[i] = lf;
oldm._motor_reverse[i] = newm._motor_reverse[i] = rev;
oldm._thrust_rpyt_out[i] = newm._thrust_rpyt_out[i] = 0;
}}
set_inputs(oldm, unit(rng), unit(rng), unit(rng), unit(rng), unit(rng), unit(rng),
unit(rng)*0.3f, unit(rng)*0.3f, unit(rng)*0.3f,
pos(rng), kfac(rng), pos(rng), pos(rng));
newm._roll_in = oldm._roll_in; newm._roll_in_ff = oldm._roll_in_ff;
newm._pitch_in = oldm._pitch_in; newm._pitch_in_ff = oldm._pitch_in_ff;
newm._yaw_in = oldm._yaw_in; newm._yaw_in_ff = oldm._yaw_in_ff;
newm._throttle_bidir = oldm._throttle_bidir;
newm._forward_in = oldm._forward_in;
newm._lateral_in = oldm._lateral_in;
newm._throttle_thrust_max = oldm._throttle_thrust_max;
newm._forwardVerticalCouplingFactor = oldm._forwardVerticalCouplingFactor;
newm._max_throttle = oldm._max_throttle;
newm._output_limited = oldm._output_limited;
oldm.output_armed_stabilizing();
newm.output_armed_stabilizing();
total++;
if (!compare(oldm, newm, why)) {{
failed++;
bf++;
if (bf <= 3) {{
std::fprintf(stdout, "FAIL %s random-layout #%d: %s\\n",
branch_names[b], n, why.c_str());
}}
}}
}}
std::fprintf(stdout, "%s: %d/%d bit-identical\\n",
branch_names[b], kRandomLayout - (int)bf, kRandomLayout);
}}
std::fprintf(stdout, "\\n## Result\\n");
std::fprintf(stdout, "compared %llu cases, failed %llu\\n",
(unsigned long long)total, (unsigned long long)failed);
if (failed == 0) {{
std::fprintf(stdout, "PASS: old and new mixers are bit-identical on every compared output.\\n");
return 0;
}}
std::fprintf(stdout, "FAIL: mixers diverged.\\n");
return 1;
}}
'''
out_cpp = os.path.join(OUT_DIR, "proof.cpp")
with open(out_cpp, "w") as f:
f.write(cpp)
print(f"wrote {out_cpp}")
for k, v in hashes.items():
print(f"{k} {v}")
print(f"HEAD {HEAD}")
print(f"PARENT {PARENT}")

AP_Motors6DOF mixer equivalence proof

Result: PASS. The merged output_armed_stabilizing() produces bit-identical _thrust_rpyt_out[] and the same throttle limit flags as the three pre-merge functions, for the same inputs.

What was compared

Commit Function(s)
Old f808f78ce5 (HEAD^ tree of libraries/AP_Motors/AP_Motors6DOF.cpp) output_armed_stabilizing() dispatcher + output_armed_stabilizing_vectored() + output_armed_stabilizing_vectored_6dof()
New 4e2f5ba108 AP_Motors: merge the three Sub's output functions merged output_armed_stabilizing()

The C++ bodies were extracted verbatim from those two git blobs (not rewritten). SHA-256 of the extracted text:

old output_armed_stabilizing                 716fa1fa0f6f18c5fe74ed79664e32a39b5465e6f41b13a89e799e4f88cbf4c7
old output_armed_stabilizing_vectored        a7fde1bec547b470d63ae65e2c8d11a02f6fdf08adad357b5724a3598104afe0
old output_armed_stabilizing_vectored_6dof   2a2c2ebb61441de54a493ce26faa82e6f48849f04133dbb5c654821cb28c54a1
new output_armed_stabilizing                 f7eb300236ffc99e035386efc48c6ec50a7fd470d7d5c09d5850328f53f414cd

Both copies were compiled into one process, given identical member state, then compared with memcmp-style IEEE-754 float32 bit equality (uint32_t overlay of each _thrust_rpyt_out[i]), plus limit.throttle_lower / limit.throttle_upper and _output_limited.

Helpers used by the mixer (is_zero, constrain_float) match ArduPilot: fabsf(x) < FLT_EPSILON and NaN-aware min/max. get_throttle_bidirectional() is the mixer's throttle input (already in [-1, 1]). Battery current tracking inside #if AP_BATTERY_ENABLED is byte-identical and still runs after mixing in both versions; it was compiled out here (AP_BATTERY_ENABLED 0) so the comparison is of the mixer itself. The post-mix _thrust_rpyt_out[i] *= _output_limited scale is included, with _output_limited randomized in [0.05, 1].

Motor layouts for stock frames were copied from AP_Motors6DOF::setup_motors().

Results

Compiler: g++ (GCC) 16.2.1 20260810, x86_64. Ran at -O0 and -O2. Both: 1,800,114 cases, 0 mismatches.

Suite Cases Bit-identical
Named inputs × 6 stock frames (zeros, ±unit axes, coupling, saturation, throttle clamp, is_zero boundary, feed-forward, _output_limited=0.5, _max_throttle=0.5) 114 114/114
Random inputs on stock layouts (seed 0x6D697865) 200,000 × 6 frames 1,200,000/1,200,000
Random factor matrices on the three mixer branches (generic / vectored / vectored_6dof) 200,000 × 3 600,000/600,000
Total per optimization level 1,800,114 1,800,114/1,800,114

Stock frames covered: BLUEROV1, VECTORED, VECTORED_6DOF, VECTORED_6DOF_90DEG, SIMPLEROV_3, DEFAULT (SimpleROV 4/5).

Sample identical outputs (%a hex float)

VECTORED, forward=0.8, throttle=0.8, FV_CPLNG_K=1.2 (coupling path):

old: M1=-0x1.99999ap-1 M2=-0x1.99999ap-1 M3=0x1.47aep-5 M4=0x1.47aep-5 M5=-0x1.99999ap-1 M6=-0x1.99999ap-1
new: M1=-0x1.99999ap-1 M2=-0x1.99999ap-1 M3=0x1.47aep-5 M4=0x1.47aep-5 M5=-0x1.99999ap-1 M6=-0x1.99999ap-1

VECTORED_6DOF, all axes = 1 (normalization / saturation path):

old: M1=0x1.555556p-2 M2=-0x1p+0 M3=0x1.555556p-2 M4=0x1.555556p-2 M5=-0x1.555556p-2 M6=-0x1p+0 M7=0x1.555556p-2 M8=-0x1.555556p-2
new: M1=0x1.555556p-2 M2=-0x1p+0 M3=0x1.555556p-2 M4=0x1.555556p-2 M5=-0x1.555556p-2 M6=-0x1p+0 M7=0x1.555556p-2 M8=-0x1.555556p-2

SITL autotest (same commit)

./waf configure --board sitl && ./waf sub, then:

test.Sub.DiveManual
test.Sub.AltitudeHold
test.Sub.PositionHold
test.Sub.UpsideDown

All four passed. DiveManual / AltitudeHold / PositionHold exercise the default vectored mixer; UpsideDown exercises vectored_6dof including inverted attitude.

Reproduce

python3 /tmp/sub-mixer-equivalence/generate_proof.py   # extracts functions from git HEAD / HEAD^
g++ -O2 -std=c++17 -o proof /tmp/sub-mixer-equivalence/proof.cpp
./proof

Must be run with the merge commit checked out so HEAD / HEAD^ match the hashes above.

AP_Motors6DOF mixer equivalence proof
old: f808f78ce5a518ca96f2fb36420608b2b6254367
new: 4e2f5ba108b1c2a80da72dd76b264d44b6493791 (AP_Motors: merge the three Sub's output functions)
comparison: bit-identical IEEE-754 float32 bits of _thrust_rpyt_out[12]
plus throttle limit flags and _output_limited
## Named cases (stock frames)
OK BLUEROV1 / zeros
old motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 lim_thr=[0,0]
new motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 lim_thr=[0,0]
OK BLUEROV1 / fwd+thr coupling
old motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=0x1.70a3d6p-2 M4=0x1.70a3d6p-2 M5=0x1.99999ap-1 M6=0x0p+0 lim_thr=[0,0]
new motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=0x1.70a3d6p-2 M4=0x1.70a3d6p-2 M5=0x1.99999ap-1 M6=0x0p+0 lim_thr=[0,0]
OK BLUEROV1 / all-axes saturate
old motors: M1=0x0p+0 M2=0x1p+0 M3=0x1.ccccccp-2 M4=0x1p+0 M5=0x0p+0 M6=0x1.8p-1 lim_thr=[0,1]
new motors: M1=0x0p+0 M2=0x1p+0 M3=0x1.ccccccp-2 M4=0x1p+0 M5=0x0p+0 M6=0x1.8p-1 lim_thr=[0,1]
OK VECTORED / zeros
old motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 lim_thr=[0,0]
new motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 lim_thr=[0,0]
OK VECTORED / fwd+thr coupling
old motors: M1=-0x1.99999ap-1 M2=-0x1.99999ap-1 M3=0x1.47aep-5 M4=0x1.47aep-5 M5=-0x1.99999ap-1 M6=-0x1.99999ap-1 lim_thr=[0,0]
new motors: M1=-0x1.99999ap-1 M2=-0x1.99999ap-1 M3=0x1.47aep-5 M4=0x1.47aep-5 M5=-0x1.99999ap-1 M6=-0x1.99999ap-1 lim_thr=[0,0]
OK VECTORED / all-axes saturate
old motors: M1=0x1p+0 M2=-0x1p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=-0x1p+0 lim_thr=[0,1]
new motors: M1=0x1p+0 M2=-0x1p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=-0x1p+0 lim_thr=[0,1]
OK VECTORED_6DOF / zeros
old motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 M7=0x0p+0 M8=0x0p+0 lim_thr=[0,0]
new motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 M7=0x0p+0 M8=0x0p+0 lim_thr=[0,0]
OK VECTORED_6DOF / fwd+thr coupling
old motors: M1=-0x1.99999ap-1 M2=-0x1.99999ap-1 M3=0x1.99999ap-1 M4=0x1.99999ap-1 M5=-0x1.99999ap-1 M6=-0x1.99999ap-1 M7=-0x1.99999ap-1 M8=-0x1.99999ap-1 lim_thr=[0,0]
new motors: M1=-0x1.99999ap-1 M2=-0x1.99999ap-1 M3=0x1.99999ap-1 M4=0x1.99999ap-1 M5=-0x1.99999ap-1 M6=-0x1.99999ap-1 M7=-0x1.99999ap-1 M8=-0x1.99999ap-1 lim_thr=[0,0]
OK VECTORED_6DOF / all-axes saturate
old motors: M1=0x1.555556p-2 M2=-0x1p+0 M3=0x1.555556p-2 M4=0x1.555556p-2 M5=-0x1.555556p-2 M6=-0x1p+0 M7=0x1.555556p-2 M8=-0x1.555556p-2 lim_thr=[0,1]
new motors: M1=0x1.555556p-2 M2=-0x1p+0 M3=0x1.555556p-2 M4=0x1.555556p-2 M5=-0x1.555556p-2 M6=-0x1p+0 M7=0x1.555556p-2 M8=-0x1.555556p-2 lim_thr=[0,1]
OK VECTORED_6DOF_90DEG / zeros
old motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 M7=0x0p+0 M8=0x0p+0 lim_thr=[0,0]
new motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 M7=0x0p+0 M8=0x0p+0 lim_thr=[0,0]
OK VECTORED_6DOF_90DEG / fwd+thr coupling
old motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=0x1.99999ap-1 M4=0x0p+0 M5=0x0p+0 M6=0x1.99999ap-1 M7=0x1.99999ap-1 M8=0x1.99999ap-1 lim_thr=[0,0]
new motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=0x1.99999ap-1 M4=0x0p+0 M5=0x0p+0 M6=0x1.99999ap-1 M7=0x1.99999ap-1 M8=0x1.99999ap-1 lim_thr=[0,0]
OK VECTORED_6DOF_90DEG / all-axes saturate
old motors: M1=0x1p+0 M2=0x1p+0 M3=0x1p+0 M4=0x1p+0 M5=0x1p+0 M6=0x1p+0 M7=0x0p+0 M8=-0x1p+0 lim_thr=[0,1]
new motors: M1=0x1p+0 M2=0x1p+0 M3=0x1p+0 M4=0x1p+0 M5=0x1p+0 M6=0x1p+0 M7=0x0p+0 M8=-0x1p+0 lim_thr=[0,1]
OK SIMPLEROV_3 / zeros
old motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 lim_thr=[0,0]
new motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 lim_thr=[0,0]
OK SIMPLEROV_3 / fwd+thr coupling
old motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=-0x1.99999ap-1 lim_thr=[0,0]
new motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=-0x1.99999ap-1 lim_thr=[0,0]
OK SIMPLEROV_3 / all-axes saturate
old motors: M1=0x0p+0 M2=0x1p+0 M3=-0x1p+0 lim_thr=[0,1]
new motors: M1=0x0p+0 M2=0x1p+0 M3=-0x1p+0 lim_thr=[0,1]
OK DEFAULT / zeros
old motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 lim_thr=[0,0]
new motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 lim_thr=[0,0]
OK DEFAULT / fwd+thr coupling
old motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=-0x1.99999ap-1 M4=-0x1.99999ap-1 M5=0x0p+0 lim_thr=[0,0]
new motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=-0x1.99999ap-1 M4=-0x1.99999ap-1 M5=0x0p+0 lim_thr=[0,0]
OK DEFAULT / all-axes saturate
old motors: M1=0x0p+0 M2=0x1p+0 M3=0x0p+0 M4=-0x1p+0 M5=0x1p+0 lim_thr=[0,1]
new motors: M1=0x0p+0 M2=0x1p+0 M3=0x0p+0 M4=-0x1p+0 M5=0x1p+0 lim_thr=[0,1]
## Random fuzz (200000 cases per stock frame)
BLUEROV1: 200000/200000 bit-identical
VECTORED: 200000/200000 bit-identical
VECTORED_6DOF: 200000/200000 bit-identical
VECTORED_6DOF_90DEG: 200000/200000 bit-identical
SIMPLEROV_3: 200000/200000 bit-identical
DEFAULT: 200000/200000 bit-identical
## Random factor matrices (200000 cases, all 3 mixer branches)
generic: 200000/200000 bit-identical
vectored: 200000/200000 bit-identical
vectored_6dof: 200000/200000 bit-identical
## Result
compared 1800114 cases, failed 0
PASS: old and new mixers are bit-identical on every compared output.
AP_Motors6DOF mixer equivalence proof
old: f808f78ce5a518ca96f2fb36420608b2b6254367
new: 4e2f5ba108b1c2a80da72dd76b264d44b6493791 (AP_Motors: merge the three Sub's output functions)
comparison: bit-identical IEEE-754 float32 bits of _thrust_rpyt_out[12]
plus throttle limit flags and _output_limited
## Named cases (stock frames)
OK BLUEROV1 / zeros
old motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 lim_thr=[0,0]
new motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 lim_thr=[0,0]
OK BLUEROV1 / fwd+thr coupling
old motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=0x1.70a3d6p-2 M4=0x1.70a3d6p-2 M5=0x1.99999ap-1 M6=0x0p+0 lim_thr=[0,0]
new motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=0x1.70a3d6p-2 M4=0x1.70a3d6p-2 M5=0x1.99999ap-1 M6=0x0p+0 lim_thr=[0,0]
OK BLUEROV1 / all-axes saturate
old motors: M1=0x0p+0 M2=0x1p+0 M3=0x1.ccccccp-2 M4=0x1p+0 M5=0x0p+0 M6=0x1.8p-1 lim_thr=[0,1]
new motors: M1=0x0p+0 M2=0x1p+0 M3=0x1.ccccccp-2 M4=0x1p+0 M5=0x0p+0 M6=0x1.8p-1 lim_thr=[0,1]
OK VECTORED / zeros
old motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 lim_thr=[0,0]
new motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 lim_thr=[0,0]
OK VECTORED / fwd+thr coupling
old motors: M1=-0x1.99999ap-1 M2=-0x1.99999ap-1 M3=0x1.47aep-5 M4=0x1.47aep-5 M5=-0x1.99999ap-1 M6=-0x1.99999ap-1 lim_thr=[0,0]
new motors: M1=-0x1.99999ap-1 M2=-0x1.99999ap-1 M3=0x1.47aep-5 M4=0x1.47aep-5 M5=-0x1.99999ap-1 M6=-0x1.99999ap-1 lim_thr=[0,0]
OK VECTORED / all-axes saturate
old motors: M1=0x1p+0 M2=-0x1p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=-0x1p+0 lim_thr=[0,1]
new motors: M1=0x1p+0 M2=-0x1p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=-0x1p+0 lim_thr=[0,1]
OK VECTORED_6DOF / zeros
old motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 M7=0x0p+0 M8=0x0p+0 lim_thr=[0,0]
new motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 M7=0x0p+0 M8=0x0p+0 lim_thr=[0,0]
OK VECTORED_6DOF / fwd+thr coupling
old motors: M1=-0x1.99999ap-1 M2=-0x1.99999ap-1 M3=0x1.99999ap-1 M4=0x1.99999ap-1 M5=-0x1.99999ap-1 M6=-0x1.99999ap-1 M7=-0x1.99999ap-1 M8=-0x1.99999ap-1 lim_thr=[0,0]
new motors: M1=-0x1.99999ap-1 M2=-0x1.99999ap-1 M3=0x1.99999ap-1 M4=0x1.99999ap-1 M5=-0x1.99999ap-1 M6=-0x1.99999ap-1 M7=-0x1.99999ap-1 M8=-0x1.99999ap-1 lim_thr=[0,0]
OK VECTORED_6DOF / all-axes saturate
old motors: M1=0x1.555556p-2 M2=-0x1p+0 M3=0x1.555556p-2 M4=0x1.555556p-2 M5=-0x1.555556p-2 M6=-0x1p+0 M7=0x1.555556p-2 M8=-0x1.555556p-2 lim_thr=[0,1]
new motors: M1=0x1.555556p-2 M2=-0x1p+0 M3=0x1.555556p-2 M4=0x1.555556p-2 M5=-0x1.555556p-2 M6=-0x1p+0 M7=0x1.555556p-2 M8=-0x1.555556p-2 lim_thr=[0,1]
OK VECTORED_6DOF_90DEG / zeros
old motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 M7=0x0p+0 M8=0x0p+0 lim_thr=[0,0]
new motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 M6=0x0p+0 M7=0x0p+0 M8=0x0p+0 lim_thr=[0,0]
OK VECTORED_6DOF_90DEG / fwd+thr coupling
old motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=0x1.99999ap-1 M4=0x0p+0 M5=0x0p+0 M6=0x1.99999ap-1 M7=0x1.99999ap-1 M8=0x1.99999ap-1 lim_thr=[0,0]
new motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=0x1.99999ap-1 M4=0x0p+0 M5=0x0p+0 M6=0x1.99999ap-1 M7=0x1.99999ap-1 M8=0x1.99999ap-1 lim_thr=[0,0]
OK VECTORED_6DOF_90DEG / all-axes saturate
old motors: M1=0x1p+0 M2=0x1p+0 M3=0x1p+0 M4=0x1p+0 M5=0x1p+0 M6=0x1p+0 M7=0x0p+0 M8=-0x1p+0 lim_thr=[0,1]
new motors: M1=0x1p+0 M2=0x1p+0 M3=0x1p+0 M4=0x1p+0 M5=0x1p+0 M6=0x1p+0 M7=0x0p+0 M8=-0x1p+0 lim_thr=[0,1]
OK SIMPLEROV_3 / zeros
old motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 lim_thr=[0,0]
new motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 lim_thr=[0,0]
OK SIMPLEROV_3 / fwd+thr coupling
old motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=-0x1.99999ap-1 lim_thr=[0,0]
new motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=-0x1.99999ap-1 lim_thr=[0,0]
OK SIMPLEROV_3 / all-axes saturate
old motors: M1=0x0p+0 M2=0x1p+0 M3=-0x1p+0 lim_thr=[0,1]
new motors: M1=0x0p+0 M2=0x1p+0 M3=-0x1p+0 lim_thr=[0,1]
OK DEFAULT / zeros
old motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 lim_thr=[0,0]
new motors: M1=0x0p+0 M2=0x0p+0 M3=0x0p+0 M4=0x0p+0 M5=0x0p+0 lim_thr=[0,0]
OK DEFAULT / fwd+thr coupling
old motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=-0x1.99999ap-1 M4=-0x1.99999ap-1 M5=0x0p+0 lim_thr=[0,0]
new motors: M1=0x1.99999ap-1 M2=0x1.99999ap-1 M3=-0x1.99999ap-1 M4=-0x1.99999ap-1 M5=0x0p+0 lim_thr=[0,0]
OK DEFAULT / all-axes saturate
old motors: M1=0x0p+0 M2=0x1p+0 M3=0x0p+0 M4=-0x1p+0 M5=0x1p+0 lim_thr=[0,1]
new motors: M1=0x0p+0 M2=0x1p+0 M3=0x0p+0 M4=-0x1p+0 M5=0x1p+0 lim_thr=[0,1]
## Random fuzz (200000 cases per stock frame)
BLUEROV1: 200000/200000 bit-identical
VECTORED: 200000/200000 bit-identical
VECTORED_6DOF: 200000/200000 bit-identical
VECTORED_6DOF_90DEG: 200000/200000 bit-identical
SIMPLEROV_3: 200000/200000 bit-identical
DEFAULT: 200000/200000 bit-identical
## Random factor matrices (200000 cases, all 3 mixer branches)
generic: 200000/200000 bit-identical
vectored: 200000/200000 bit-identical
vectored_6dof: 200000/200000 bit-identical
## Result
compared 1800114 cases, failed 0
PASS: old and new mixers are bit-identical on every compared output.
HEAD 4e2f5ba108b1c2a80da72dd76b264d44b6493791 AP_Motors: merge the three Sub's output functions
PARENT f808f78ce5a518ca96f2fb36420608b2b6254367
old_output_armed_stabilizing 716fa1fa0f6f18c5fe74ed79664e32a39b5465e6f41b13a89e799e4f88cbf4c7
old_vectored a7fde1bec547b470d63ae65e2c8d11a02f6fdf08adad357b5724a3598104afe0
old_vectored_6dof 2a2c2ebb61441de54a493ce26faa82e6f48849f04133dbb5c654821cb28c54a1
new_output_armed_stabilizing f7eb300236ffc99e035386efc48c6ec50a7fd470d7d5c09d5850328f53f414cd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment