Fix out-of-bounds reads in the multi-extruder flush-volume matrix

flush_volumes_matrix stores one (filaments x filaments) block per nozzle,
but set_extruder, WipeTower2::extract_wipe_volumes and the PresetBundle
rebuild indexed each block with filament_colour.size() as the row stride.
When the stored matrix does not match that assumption (a legacy project, or
a just-switched printer -- e.g. a single 2x2 block while nozzle_diameter has
2 entries) the index runs past the sliced block and reads out of bounds.

The read is undefined, so its value depends on the C++ std-lib/allocator
layout: identical across Linux architectures but different on macOS/Windows.
That surfaced as the "Toolchange temperature commands are unchanged when the
wipe tower wait is off" regression failing on Linux CI while passing on
macOS/Windows, and crashing hardened (-O0, _GLIBCXX_ASSERTIONS) builds.

- Centralize the block-dimension derivation in get_flush_volumes_matrix_dims
  (sqrt(size / nozzles) with a filaments^2 * nozzles == size check and a
  single-block fallback) and use it wherever the matrix is sliced/indexed;
  bounds-guard the reads as defense in depth. It weighs both options that
  claim to say how many blocks are stored, since either can be stale:
  flush_multiplier, written with the matrix in the project config, and
  nozzle_diameter, which changes the moment a printer is selected.
- PresetBundle::update_multi_material_filament_presets: rebuild the matrix on
  a nozzle-count-only change too (the old per-block gate missed those), and
  seed a brand-new nozzle from the first nozzle's tuned block.
- is_flush_config_modified: stride the stored matrix by its own dimension
  rather than the current filament count, and bound the nozzle loop by the
  printer's extruder count, which CalcFlushingVolumes indexes as well.
- is_flushing_matrix_error: the same derivation, which additionally divided
  by zero on an empty flush_multiplier.
- update_slice_warnings: guard nozzle_hrc_lists, which is sized by the
  nullable nozzle_type option and can be shorter than the extruder count.

With the read fixed the emitted trace is deterministic across builds and
platforms, so regenerate the golden from it and stop comparing the rounded
"time: <n>s" preheat comment (the tolerant lead time already carries that
timing). Add unit tests for the flush-matrix rebuild and dimension logic.
This commit is contained in:
SoftFever
2026-08-07 11:31:43 +08:00
parent 18ca06ec6b
commit 3b445905d1
11 changed files with 308 additions and 116 deletions

View File

@@ -9087,12 +9087,21 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo
int old_filament_id = -1;
int old_extruder_id = -1;
if (m_writer.filament() != nullptr || m_start_gcode_filament != -1) {
std::vector<float> flush_matrix(cast<float>(get_flush_volumes_matrix(m_config.flush_volumes_matrix.values, new_extruder_id, m_config.nozzle_diameter.values.size())));
const unsigned int number_of_extruders = (unsigned int) (m_config.filament_colour.values.size()); // if is multi_extruder only use the fist extruder matrix
// The row stride of the per-nozzle flush matrix block is its validated dimension,
// NOT filament_colour.size() (see get_flush_volumes_matrix_dims).
const std::vector<double>& raw_flush_matrix = m_config.flush_volumes_matrix.values;
const FlushVolumesMatrixDims flush_dims = get_flush_volumes_matrix_dims(raw_flush_matrix.size(),
m_config.flush_multiplier.values.size(), m_config.nozzle_diameter.values.size());
std::vector<float> flush_matrix(cast<float>(get_flush_volumes_matrix(raw_flush_matrix, new_extruder_id, flush_dims.nozzle_nums)));
// Guard the read in case the stored matrix is smaller than the filament ids in play.
auto flush_volume_at = [&](size_t old_id, size_t new_id) -> float {
const size_t idx = old_id * flush_dims.filament_nums + new_id;
return idx < flush_matrix.size() ? flush_matrix[idx] : 0.f;
};
if (m_writer.filament() != nullptr)
assert(m_writer.filament()->id() < number_of_extruders);
assert(m_writer.filament()->id() < m_config.filament_colour.values.size());
else
assert(m_start_gcode_filament < number_of_extruders);
assert(m_start_gcode_filament < (int) m_config.filament_colour.values.size());
old_filament_id = m_writer.filament() != nullptr ? m_writer.filament()->id() : m_start_gcode_filament;
old_extruder_id = m_writer.filament() != nullptr ? m_writer.filament()->extruder_id() : get_extruder_id(m_start_gcode_filament);
@@ -9112,12 +9121,12 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo
if (old_filament_id_in_new_extruder == -1)
wipe_volume = 0;
else {
wipe_volume = flush_matrix[old_filament_id_in_new_extruder * number_of_extruders + new_filament_id];
wipe_volume = flush_volume_at(old_filament_id_in_new_extruder, new_filament_id);
wipe_volume *= m_config.flush_multiplier.get_at(new_extruder_id);
}
}
else {
wipe_volume = flush_matrix[old_filament_id * number_of_extruders + new_filament_id];
wipe_volume = flush_volume_at(old_filament_id, new_filament_id);
wipe_volume *= m_config.flush_multiplier.get_at(new_extruder_id); // if is multi_extruder only use the fist extruder matrix
}
wipe_volume = std::max(0.f, wipe_volume-grab_purge_volume);

View File

@@ -7479,7 +7479,10 @@ void GCodeProcessor::update_slice_warnings()
filament_hrc = m_result.required_nozzle_HRC[used_filaments[idx]];
int filament_extruder_id = m_filament_maps[used_filaments[idx]];
int extruder_hrc = nozzle_hrc_lists[filament_extruder_id];
// nozzle_type is a nullable option that can be shorter than the extruder count;
// out-of-range extruders get hrc 0, which skips the check below.
int extruder_hrc = (filament_extruder_id >= 0 && filament_extruder_id < (int) nozzle_hrc_lists.size())
? nozzle_hrc_lists[filament_extruder_id] : 0;
BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": Check HRC: filament:%1%, hrc=%2%, extruder:%3%, hrc:%4%") % used_filaments[idx] % filament_hrc % filament_extruder_id % extruder_hrc;

View File

@@ -2134,21 +2134,17 @@ std::pair<double, double> WipeTower2::get_wipe_tower_cone_base(double width, dou
// DynamicPrintConfig directly instead of materializing a full PrintConfig per call.
std::vector<std::vector<float>> WipeTower2::extract_wipe_volumes(const ConfigBase& config)
{
// flush_volumes_matrix holds one filaments x filaments block per nozzle (written by
// PresetBundle::update_multi_material_filament_presets), so the filament count is
// sqrt(size / nozzles). One tower serves every nozzle and the filament to nozzle assignment is
// only decided later by ToolOrdering, so fold the blocks with std::max: the depth reserved here
// has to cover the worst nozzle. With a single nozzle the fold has one term.
const std::vector<double> &raw_matrix = config.option<ConfigOptionFloats>("flush_volumes_matrix")->values;
const auto *nozzle_diameter = config.option<ConfigOptionFloats>("nozzle_diameter");
size_t nozzle_nums = (nozzle_diameter == nullptr || nozzle_diameter->values.empty()) ? 1 : nozzle_diameter->values.size();
unsigned int number_of_extruders = (unsigned int)(sqrt(raw_matrix.size() / nozzle_nums) + EPSILON);
if (size_t(number_of_extruders) * number_of_extruders * nozzle_nums != raw_matrix.size()) {
// Saved for a different nozzle count (older project, or the printer was just switched):
// fall back to reading the whole option as one block, as this did before.
nozzle_nums = 1;
number_of_extruders = (unsigned int)(sqrt(raw_matrix.size()) + EPSILON);
}
// One tower serves every nozzle and the filament to nozzle assignment is only decided later
// by ToolOrdering, so fold the per-nozzle blocks with std::max: the depth reserved here has
// to cover the worst nozzle. With a single nozzle the fold has one term.
const std::vector<double> &raw_matrix = config.option<ConfigOptionFloats>("flush_volumes_matrix")->values;
const auto *nozzle_diameter = config.option<ConfigOptionFloats>("nozzle_diameter");
const auto *flush_multiplier = config.option<ConfigOptionFloats>("flush_multiplier");
const FlushVolumesMatrixDims dims = get_flush_volumes_matrix_dims(raw_matrix.size(),
flush_multiplier == nullptr ? 0 : flush_multiplier->values.size(),
nozzle_diameter == nullptr ? 0 : nozzle_diameter->values.size());
const size_t nozzle_nums = dims.nozzle_nums;
const unsigned int number_of_extruders = dims.filament_nums;
// The values shall only be used when SEMM is enabled. The purging for other printers
// is determined by filament_minimal_purge_on_wipe_tower.

View File

@@ -5370,15 +5370,23 @@ void PresetBundle::update_multi_material_filament_presets(size_t to_delete_filam
// Now verify if flush_volumes_matrix has proper size (it is used to deduce number of extruders in wipe tower generator):
std::vector<double> old_matrix = this->project_config.option<ConfigOptionFloats>("flush_volumes_matrix")->values;
size_t old_nozzle_nums = this->project_config.option<ConfigOptionFloats>("flush_multiplier")->values.size();
size_t old_number_of_filaments = size_t(sqrt(old_matrix.size() / old_nozzle_nums) + EPSILON);
// Infer the stored layout from the matrix itself rather than trusting flush_multiplier's
// length (stale in older projects), falling back to a single block when they disagree.
const FlushVolumesMatrixDims old_dims = get_flush_volumes_matrix_dims(old_matrix.size(),
this->project_config.option<ConfigOptionFloats>("flush_multiplier")->values.size());
size_t old_nozzle_nums = old_dims.nozzle_nums;
size_t old_number_of_filaments = old_dims.filament_nums;
size_t nozzle_nums = get_printer_extruder_count();
if (old_nozzle_nums != nozzle_nums) {
{
std::vector<double>& f_multiplier = this->project_config.option<ConfigOptionFloats>("flush_multiplier")->values;
f_multiplier.resize(nozzle_nums, 1.f);
if (f_multiplier.size() != nozzle_nums)
f_multiplier.resize(nozzle_nums, 1.f);
}
if ( (num_filaments * num_filaments) != size_t(old_matrix.size() / old_nozzle_nums) ) {
// Rebuild whenever the stored size breaks the (filaments x filaments) block-per-nozzle
// invariant, including a nozzle-count-only change (the old per-block gate missed those,
// leaving a matrix short or over by whole blocks).
if (old_matrix.size() != num_filaments * num_filaments * nozzle_nums) {
// First verify if purging volumes presets for each extruder matches number of extruders
std::vector<double>& filaments = this->project_config.option<ConfigOptionFloats>("flush_volumes_vector")->values;
while (filaments.size() < 2* num_filaments) {
@@ -5399,14 +5407,11 @@ void PresetBundle::update_multi_material_filament_presets(size_t to_delete_filam
unsigned int old_i = i >= to_delete_filament_id ? i + 1 : i;
unsigned int old_j = j >= to_delete_filament_id ? j + 1 : j;
for (size_t nozzle_id = 0; nozzle_id < nozzle_nums; ++nozzle_id) {
// Orca: only copy from old_matrix when the old layout actually has data
// for this nozzle slot; otherwise initialize from the per-filament
// flush volumes the same way the (i,j) out-of-range branch does.
if (nozzle_id < old_nozzle_nums) {
new_matrix[i * num_filaments + j + new_matrix_size * nozzle_id] = old_matrix[old_i * old_number_of_filaments + old_j + old_matrix_size * nozzle_id];
} else {
new_matrix[i * num_filaments + j + new_matrix_size * nozzle_id] = (i == j ? 0. : filaments[2 * i] + filaments[2 * j + 1]);
}
// Orca: a nozzle with stored data keeps its own block; a brand-new nozzle
// replicates the first nozzle's tuned values (the same grow policy as
// Plater::update_flush_volume_matrix) rather than resetting to defaults.
size_t src_nozzle_id = nozzle_id < old_nozzle_nums ? nozzle_id : 0;
new_matrix[i * num_filaments + j + new_matrix_size * nozzle_id] = old_matrix[old_i * old_number_of_filaments + old_j + old_matrix_size * src_nozzle_id];
}
} else {
for (size_t nozzle_id = 0; nozzle_id < nozzle_nums; ++nozzle_id) {

View File

@@ -2367,6 +2367,33 @@ private:
static uint64_t s_last_timestamp;
};
// A flush_volumes_matrix option holds one (filament_nums x filament_nums) block per nozzle
// (see PresetBundle::update_multi_material_filament_presets). Validate that partitioning
// against the stored size; when it does not match (older project, or the printer was just
// switched) fall back to treating the whole option as a single block — slicing or indexing
// with a mismatched row stride would read out of bounds.
struct FlushVolumesMatrixDims
{
size_t nozzle_nums; // number of per-nozzle blocks actually stored
unsigned int filament_nums; // dimension (row stride) of each block
};
// Two options claim to say how many blocks are stored and either can be stale: flush_multiplier
// lives in the project config and is written with the matrix, while nozzle_diameter comes from the
// printer preset and changes the moment a printer is selected. Pass both where both are at hand,
// preferred one first; the first candidate that squares up with the stored size wins.
inline FlushVolumesMatrixDims get_flush_volumes_matrix_dims(size_t matrix_size, size_t nozzle_nums, size_t alt_nozzle_nums = 0)
{
for (size_t candidate : { nozzle_nums, alt_nozzle_nums, size_t(1) }) {
if (candidate == 0)
continue;
const unsigned int filament_nums = (unsigned int) (std::sqrt(double(matrix_size) / candidate) + EPSILON);
if (size_t(filament_nums) * filament_nums * candidate == matrix_size)
return { candidate, filament_nums };
}
// Nothing partitions cleanly: read the whole option as one block, as this did before.
return { 1, (unsigned int) (std::sqrt(double(matrix_size)) + EPSILON) };
}
// const std::vector<double> &fv_matrix: origin matrix from json
// size_t extruder_id: -1 means single-nozzle for old file, 0 means the 1st extruder, 1 means the 2nd extruder
template<class T>

View File

@@ -10746,14 +10746,19 @@ bool GLCanvas3D::is_flushing_matrix_error() {
if (multiplier == 0) return true;
}
int matrix_len = config_matrix.size() / config_multiplier.size();
int row_len = std::sqrt(matrix_len);
for (int i = 0; i < config_matrix.size(); i++)
// Orca: derive the block layout from the stored matrix instead of dividing by the multiplier
// count outright. A stale multiplier gives the wrong row stride, so cells are tested against
// the wrong diagonal, and an empty one divides by zero.
const FlushVolumesMatrixDims dims = get_flush_volumes_matrix_dims(config_matrix.size(), config_multiplier.size(),
wxGetApp().preset_bundle->get_printer_extruder_count());
if (dims.filament_nums == 0)
return false;
const size_t block_len = size_t(dims.filament_nums) * dims.filament_nums;
for (size_t i = 0; i < config_matrix.size(); i++)
{
int relative_id = i % matrix_len;
int row_id = relative_id / row_len;
int col_id = relative_id % row_len;
if (row_id != col_id && config_matrix[i] == 0) return true;
const size_t relative_id = i % block_len;
if (relative_id / dims.filament_nums != relative_id % dims.filament_nums && config_matrix[i] == 0)
return true;
}
return false;
}

View File

@@ -205,27 +205,29 @@ bool is_flush_config_modified()
const std::vector<double> &config_matrix = (project_config.option<ConfigOptionFloats>("flush_volumes_matrix"))->values;
const std::vector<double> &config_multiplier = (project_config.option<ConfigOptionFloats>("flush_multiplier"))->values;
bool has_modify = false;
for (int i = 0; i < config_multiplier.size(); i++) {
if (config_multiplier[i] != 1) {
has_modify = true;
break;
}
std::vector<std::vector<double>> default_matrix = WipingDialog::CalcFlushingVolumes(i);
int len = default_matrix.size();
for (int m = 0; m < len; m++) {
for (int n = 0; n < len; n++) {
int idx = i * len * len + m * len + n;
if (config_matrix[idx] != default_matrix[m][n] * config_multiplier[i]) {
has_modify = true;
break;
}
}
if (has_modify) break;
}
if (has_modify) break;
for (double multiplier : config_multiplier)
if (multiplier != 1)
return true;
// Orca: take the row stride and the block count from the stored matrix, not from the current
// filament count. The two disagree until update_multi_material_filament_presets rebuilds the
// matrix (a project saved with fewer filaments, or a printer just switched), and indexing a
// stale matrix with the current stride reads past the option. Clamp to the printer's extruder
// count as well, since CalcFlushingVolumes indexes per-extruder options with the same id.
const size_t extruder_count = wxGetApp().preset_bundle->get_printer_extruder_count();
const FlushVolumesMatrixDims dims = get_flush_volumes_matrix_dims(config_matrix.size(), config_multiplier.size(), extruder_count);
const size_t nozzle_nums = std::min(dims.nozzle_nums, extruder_count);
for (size_t i = 0; i < nozzle_nums; i++) {
std::vector<std::vector<double>> default_matrix = WipingDialog::CalcFlushingVolumes(int(i));
if (default_matrix.size() != dims.filament_nums)
return false; // Stored matrix predates the current filament count; it is about to be rebuilt.
// Every multiplier is 1 here, so the stored block has to equal the defaults outright.
for (size_t m = 0; m < dims.filament_nums; m++)
for (size_t n = 0; n < dims.filament_nums; n++)
if (config_matrix[(i * dims.filament_nums + m) * dims.filament_nums + n] != default_matrix[m][n])
return true;
}
return has_modify;
return false;
}
void open_flushing_dialog(wxEvtHandler *parent, const wxEvent &event)