From 1d0fa974d797f253406beecb97574fe961b19d37 Mon Sep 17 00:00:00 2001 From: BD <46272996+xbraindance@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:43:08 +0200 Subject: [PATCH 01/11] Add an optional tabbed editor layout The full panel is 2338 px wide, which is hard to fit on smaller screens. System > Tabbed Layout shows the panel as three tabs (Oscillator, Filter, Control) beside a fixed screen section, with the transport row, System Volume and the System menu always visible. The choice is remembered beside the panel-skin preference. Each tab is a list of original-SVG rectangles translated into a smaller view, so artwork, hit regions, LEDs and firmware wiring are reused unchanged; the classic layout is the identity mapping. Co-Authored-By: Claude Opus 5.5 --- Source/PluginEditor.cpp | 309 +++++++++++++++++++++++++------- Source/PluginEditor.h | 12 ++ Source/PluginProcessor.cpp | 15 ++ Source/PluginProcessor.h | 2 + Tests/WavePublicEditorTests.cpp | 22 +++ 5 files changed, 294 insertions(+), 66 deletions(-) diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 715d1d7..53c542b 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -51,6 +51,78 @@ constexpr auto resetZoomMenuItem = 0x470a; constexpr auto toggleKeyboardMenuItem = 0x470b; constexpr auto loadPanelSkinMenuItem = 0x470c; constexpr auto defaultPanelSkinMenuItem = 0x470d; +constexpr auto tabbedLayoutMenuItem = 0x470e; + +// The tabbed layout reuses the original artwork and wiring unchanged: each tab +// is a list of full-SVG rectangles translated into a smaller view. The screen +// block keeps the same view position on every tab. +struct LayoutBlock +{ + juce::Rectangle source; + juce::Point destination; + + [[nodiscard]] juce::Rectangle destinationBounds() const noexcept + { + return source.withPosition(destination); + } +}; + +constexpr auto tabStripHeight = 40.0f; +constexpr auto tabSectionWidth = 834.0f; +constexpr auto tabbedViewWidth = tabSectionWidth + 612.0f; +constexpr auto tabbedViewHeight = tabStripHeight + panelOnlyHeight; +const std::array tabNames { "OSCILLATOR", "FILTER", "CONTROL" }; +const auto tabHeaderColour = juce::Colour(0xffd1ae88); +const auto panelDividerColour = juce::Colour(0xff24232d); // screen frame lines + +juce::Rectangle tabBounds(int tab) noexcept +{ + return { 54.0f + static_cast(tab) * 168.0f, 8.0f, 160.0f, 26.0f }; +} + +const std::vector& layoutBlocksFor(bool tabbed, int tab) +{ + static const std::vector classic { + { { 0.0f, 0.0f, designWidth, designHeight }, {} } + }; + const LayoutBlock screen { { 900.0f, 0.0f, 612.0f, panelOnlyHeight }, + { tabSectionWidth, tabStripHeight } }; + // Transport row sits in the free space above the screen; Rewind's left + // edge lines up with the Mute button's. + const LayoutBlock transport { { 950.0f, 640.0f, 460.0f, 66.0f }, + { tabSectionWidth + 37.0f, 48.0f } }; + // System Volume follows the transport row, level with its buttons. + const LayoutBlock systemVolume { { 330.0f, 880.0f, 76.0f, 78.0f }, + { tabSectionWidth + 480.0f, 40.0f } }; + const juce::Point controlOrigin { + (tabSectionWidth - 380.0f) * 0.5f, + tabStripHeight + (panelOnlyHeight - 300.0f) * 0.5f + }; + static const std::array, 3> tabs { + std::vector { + { { panelHorizontalOffset, 0.0f, tabSectionWidth, panelOnlyHeight }, + { 0.0f, tabStripHeight } }, + screen, transport, systemVolume + }, + std::vector { + // Right-aligned so its content keeps the Oscillator tab's margin + // to the screen. + { { 1512.0f, 0.0f, 760.0f, panelOnlyHeight }, { 69.0f, tabStripHeight } }, + screen, transport, systemVolume + }, + std::vector { + // Wheels, Button 1/2, Glide and octave switches. + // Stops short of x = 446, where the runtime keybed begins. + { { panelHorizontalOffset, 682.0f, 372.0f, 300.0f }, controlOrigin }, + // Blank panel over System Volume's original spot, which has moved. + { { 318.0f, 794.0f, 80.0f, 80.0f }, + controlOrigin + juce::Point { 328.0f - panelHorizontalOffset, + 879.0f - 682.0f } }, + screen, transport, systemVolume + } + }; + return tabbed ? tabs[static_cast(juce::jlimit(0, 2, tab))] : classic; +} bool hitCircle(juce::Point point, float x, float y) noexcept { @@ -477,11 +549,11 @@ class WaveEmulationAudioProcessorEditor::SystemMenuButton final : public juce::B static_cast(getHeight())) * 0.68f; const auto icon = juce::Rectangle { side, side } .withCentre(getLocalBounds().toFloat().getCentre()); - auto colour = juce::Colour(0xffdce4ef); + auto colour = iconColour; if (pressed) - colour = juce::Colour(0xff8cb9e8); + colour = colour.darker(0.3f); else if (highlighted) - colour = juce::Colours::white; + colour = colour.brighter(0.4f); juce::Path body; body.startNewSubPath(icon.getX(), icon.getY()); @@ -505,6 +577,8 @@ class WaveEmulationAudioProcessorEditor::SystemMenuButton final : public juce::B .withTrimmedTop(side * 0.57f).withTrimmedBottom(side * 0.12f), side * 0.06f, stroke); } + + juce::Colour iconColour { 0xffdce4ef }; }; WaveEmulationAudioProcessorEditor::WaveEmulationAudioProcessorEditor( @@ -521,13 +595,7 @@ WaveEmulationAudioProcessorEditor::WaveEmulationAudioProcessorEditor( systemMenuButton->onClick = [this] { showSystemMenu(); }; addAndMakeVisible(*systemMenuButton); setResizable(true, true); - setResizeLimits( - juce::roundToInt(designWidth * minimumEditorScale), - juce::roundToInt(designHeight * minimumEditorScale), - juce::roundToInt(designWidth * maximumEditorScale), - juce::roundToInt(designHeight * maximumEditorScale)); - if (auto* constrainer = getConstrainer()) - constrainer->setFixedAspectRatio(static_cast(designWidth / designHeight)); + tabbedLayout = ownerProcessor.getRememberedTabbedLayout(); const auto svg = juce::String::fromUTF8(WaveAssets::WaldorfWaveUI_NOLOGO_svg, WaveAssets::WaldorfWaveUI_NOLOGO_svgSize); @@ -710,8 +778,7 @@ WaveEmulationAudioProcessorEditor::WaveEmulationAudioProcessorEditor( lcd->setPanelEmbedded(true); addAndMakeVisible(*lcd); - setSize(juce::roundToInt(designWidth * defaultEditorScale), - juce::roundToInt(designHeight * defaultEditorScale)); + setWindowScale(defaultEditorScale); startTimerHz(60); juce::MessageManager::callAsync([safe = juce::Component::SafePointer(this)] { if (safe != nullptr) @@ -771,7 +838,9 @@ juce::PopupMenu WaveEmulationAudioProcessorEditor::getMenuForIndex( menu.addItem(resetZoomMenuItem, "Actual Size (Cmd/Ctrl + 0)"); menu.addItem(toggleKeyboardMenuItem, keyboardVisible ? "Hide Lower Keyboard Area (Cmd/Ctrl + K)" - : "Show Lower Keyboard Area (Cmd/Ctrl + K)"); + : "Show Lower Keyboard Area (Cmd/Ctrl + K)", + !tabbedLayout); + menu.addItem(tabbedLayoutMenuItem, "Tabbed Layout", true, tabbedLayout); menu.addSeparator(); juce::PopupMenu skins; skins.addItem(defaultPanelSkinMenuItem, "Original", true, panelSkinFile == juce::File{}); @@ -807,9 +876,14 @@ void WaveEmulationAudioProcessorEditor::menuItemSelected(int menuItemId, int) useDefaultPanelSkin(); return; } + if (menuItemId == tabbedLayoutMenuItem) + { + setTabbedLayout(!tabbedLayout); + return; + } if (menuItemId == zoomInMenuItem || menuItemId == zoomOutMenuItem) { - const auto currentScale = static_cast(getWidth()) / designWidth; + const auto currentScale = viewScale(); const auto direction = menuItemId == zoomInMenuItem ? 1.0f : -1.0f; setWindowScale(std::round(currentScale * 10.0f + direction) / 10.0f); return; @@ -869,15 +943,75 @@ void WaveEmulationAudioProcessorEditor::menuItemSelected(int menuItemId, int) void WaveEmulationAudioProcessorEditor::setWindowScale(float scale) { scale = juce::jlimit(minimumEditorScale, maximumEditorScale, scale); - const auto activeHeight = keyboardVisible ? designHeight : panelOnlyHeight; + const auto view = viewSize(); if (auto* constrainer = getConstrainer()) - constrainer->setFixedAspectRatio(static_cast(designWidth / activeHeight)); - setResizeLimits(juce::roundToInt(designWidth * minimumEditorScale), - juce::roundToInt(activeHeight * minimumEditorScale), - juce::roundToInt(designWidth * maximumEditorScale), - juce::roundToInt(activeHeight * maximumEditorScale)); - setSize(juce::roundToInt(designWidth * scale), - juce::roundToInt(activeHeight * scale)); + constrainer->setFixedAspectRatio(static_cast(view.x / view.y)); + setResizeLimits(juce::roundToInt(view.x * minimumEditorScale), + juce::roundToInt(view.y * minimumEditorScale), + juce::roundToInt(view.x * maximumEditorScale), + juce::roundToInt(view.y * maximumEditorScale)); + setSize(juce::roundToInt(view.x * scale), juce::roundToInt(view.y * scale)); +} + +void WaveEmulationAudioProcessorEditor::setTabbedLayout(bool tabbed, bool remember) +{ + if (remember) + ownerProcessor.rememberTabbedLayout(tabbed); + if (tabbedLayout == tabbed) + return; + releaseActivePointerInteractions(false); + const auto currentScale = viewScale(); + tabbedLayout = tabbed; + setWindowScale(currentScale); + menuItemsChanged(); +} + +void WaveEmulationAudioProcessorEditor::selectTab(int tab) +{ + tab = juce::jlimit(0, static_cast(tabNames.size()) - 1, tab); + if (selectedTab == tab) + return; + releaseActivePointerInteractions(false); + selectedTab = tab; + layoutComponents(); + repaint(); +} + +float WaveEmulationAudioProcessorEditor::viewScale() const noexcept +{ + return static_cast(getWidth()) / viewSize().x; +} + +juce::Point WaveEmulationAudioProcessorEditor::viewSize() const noexcept +{ + if (tabbedLayout) + return { tabbedViewWidth, tabbedViewHeight }; + return { designWidth, keyboardVisible ? designHeight : panelOnlyHeight }; +} + +juce::Point WaveEmulationAudioProcessorEditor::viewToDesign( + juce::Point viewPoint) const noexcept +{ + if (getWidth() <= 0) + return { -1.0e6f, -1.0e6f }; + const auto point = viewPoint / viewScale(); + const auto& blocks = layoutBlocksFor(tabbedLayout, selectedTab); + for (auto block = blocks.rbegin(); block != blocks.rend(); ++block) + if (block->destinationBounds().contains(point)) + return block->source.getPosition() + (point - block->destination); + // Outside every block: a point far off the artwork that hits no control. + return { -1.0e6f, -1.0e6f }; +} + +std::optional> WaveEmulationAudioProcessorEditor::designToView( + juce::Rectangle designBounds) const noexcept +{ + const auto& blocks = layoutBlocksFor(tabbedLayout, selectedTab); + for (auto block = blocks.rbegin(); block != blocks.rend(); ++block) + if (block->source.contains(designBounds.getCentre())) + return ((designBounds + (block->destination - block->source.getPosition())) + * viewScale()).toNearestInt(); + return std::nullopt; } void WaveEmulationAudioProcessorEditor::setKeyboardVisible(bool visible) @@ -885,7 +1019,7 @@ void WaveEmulationAudioProcessorEditor::setKeyboardVisible(bool visible) if (keyboardVisible == visible) return; releaseActivePointerInteractions(false); - const auto currentScale = static_cast(getWidth()) / designWidth; + const auto currentScale = viewScale(); keyboardVisible = visible; setWindowScale(currentScale); menuItemsChanged(); @@ -1163,10 +1297,49 @@ void WaveEmulationAudioProcessorEditor::focusLost(FocusChangeType) void WaveEmulationAudioProcessorEditor::paint(juce::Graphics& graphics) { - graphics.fillAll(juce::Colours::black); - const auto scaleX = static_cast(getWidth()) / designWidth; + const auto scale = viewScale(); + if (!tabbedLayout) + { + graphics.fillAll(juce::Colours::black); + paintDesign(graphics, scale); + return; + } + + const auto panelColour = juce::Colour(0xff2e2d45); + const auto headerColour = tabHeaderColour; + graphics.fillAll(panelColour); + for (const auto& block : layoutBlocksFor(true, selectedTab)) + { + juce::Graphics::ScopedSaveState saved(graphics); + graphics.reduceClipRegion((block.destinationBounds() * scale).toNearestInt()); + const auto offset = (block.destination - block.source.getPosition()) * scale; + graphics.addTransform(juce::AffineTransform::translation(offset.x, offset.y)); + paintDesign(graphics, scale); + } + + // Same colour and 4 px weight as the frame lines rising from the screen. + graphics.setColour(panelDividerColour); + graphics.fillRect(juce::Rectangle { 0.0f, tabStripHeight, tabbedViewWidth, 4.0f } + * scale); + + graphics.setFont(juce::FontOptions(15.0f * scale, juce::Font::bold | juce::Font::italic)); + for (auto tab = 0; tab < static_cast(tabNames.size()); ++tab) + { + const auto bounds = tabBounds(tab) * scale; + const auto selected = tab == selectedTab; + graphics.setColour(selected ? headerColour : headerColour.withAlpha(0.18f)); + graphics.fillRoundedRectangle(bounds, 3.0f * scale); + graphics.setColour(selected ? panelColour : headerColour); + graphics.drawText(tabNames[static_cast(tab)], bounds, + juce::Justification::centred); + } +} + +void WaveEmulationAudioProcessorEditor::paintDesign(juce::Graphics& graphics, float scale) +{ + const auto scaleX = scale; const auto fullPanelBounds = juce::Rectangle { - 0.0f, 0.0f, static_cast(getWidth()), designHeight * scaleX + 0.0f, 0.0f, designWidth * scaleX, designHeight * scaleX }; if (panelImage.isValid()) { @@ -1348,8 +1521,8 @@ void WaveEmulationAudioProcessorEditor::paint(juce::Graphics& graphics) constexpr auto rotationExtent = juce::MathConstants::pi * 0.75f; for (size_t index = 0; index < knobs.size(); ++index) { - const auto bounds = knobs[index]->getBounds().toFloat(); - const auto centre = bounds.getCentre(); + const auto centre = knobDesignCentres[index].translated( + panelHorizontalOffset, 0.0f) * scaleX; auto* artwork = knobArtwork.get(); auto artworkSize = 34.0f; if (knobArtworkTypes[index] == KnobArtworkType::red) @@ -1396,10 +1569,14 @@ void WaveEmulationAudioProcessorEditor::paint(juce::Graphics& graphics) void WaveEmulationAudioProcessorEditor::mouseDown(const juce::MouseEvent& event) { - const auto designPoint = juce::Point { - event.position.x * designWidth / static_cast(getWidth()), - event.position.y * designWidth / static_cast(getWidth()) - }; + if (tabbedLayout) + for (auto tab = 0; tab < static_cast(tabNames.size()); ++tab) + if ((tabBounds(tab) * viewScale()).contains(event.position)) + { + selectTab(tab); + return; + } + const auto designPoint = viewToDesign(event.position); if (const auto midiNote = midiNoteAt(designPoint); midiNote >= 0) { midiKeyboardDragging = true; @@ -1418,7 +1595,7 @@ void WaveEmulationAudioProcessorEditor::mouseDown(const juce::MouseEvent& event) analogDragStartY = event.position.y; analogDragStartValue = performanceWheelValues[wheel]; analogDragRangePixels = performanceWheelSlots[wheel].getHeight() - * static_cast(getWidth()) / designWidth; + * viewScale(); return; } @@ -1461,8 +1638,7 @@ void WaveEmulationAudioProcessorEditor::mouseDown(const juce::MouseEvent& event) 1.0f, (region->faderTrackBottom - region->faderTrackTop) - * static_cast(getWidth()) - / designWidth) + * viewScale()) : 160.0f; if (region->fader) { @@ -1653,11 +1829,7 @@ void WaveEmulationAudioProcessorEditor::mouseDrag(const juce::MouseEvent& event) { if (midiKeyboardDragging) { - const auto designPoint = juce::Point { - event.position.x * designWidth / static_cast(getWidth()), - event.position.y * designWidth / static_cast(getWidth()) - }; - const auto midiNote = midiNoteAt(designPoint); + const auto midiNote = midiNoteAt(viewToDesign(event.position)); if (midiNote != activeMidiNote) { if (activeMidiNote >= 0) @@ -2043,27 +2215,33 @@ void WaveEmulationAudioProcessorEditor::timerCallback() void WaveEmulationAudioProcessorEditor::resized() { rebuildPanelImage(); - const auto scaleX = static_cast(getWidth()) / designWidth; - const auto scaleY = scaleX; - const auto scaled = [scaleX, scaleY](juce::Rectangle rectangle) { - return juce::Rectangle(rectangle.getX() * scaleX, rectangle.getY() * scaleY, - rectangle.getWidth() * scaleX, - rectangle.getHeight() * scaleY) - .toNearestInt(); + layoutComponents(); +} + +void WaveEmulationAudioProcessorEditor::layoutComponents() +{ + const auto place = [this](juce::Component& component, juce::Rectangle designBounds) { + const auto bounds = designToView(designBounds); + component.setVisible(bounds.has_value()); + if (bounds.has_value()) + component.setBounds(*bounds); }; if (systemMenuButton != nullptr) { - // The supplied artwork leaves this narrow chassis rail free of panel - // controls. Keeping the host-side menu here makes it available in AU, - // VST3, and standalone builds without moving or covering the Wave UI. - systemMenuButton->setBounds(scaled({ 16.0f, 4.0f, 36.0f, 28.0f })); + // Classic: the narrow chassis rail, free of panel controls. Tabbed: + // left of the tab buttons, on every tab. + const auto bounds = tabbedLayout + ? juce::Rectangle { 10.0f, 7.0f, 36.0f, 28.0f } + : juce::Rectangle { 16.0f, 4.0f, 36.0f, 28.0f }; + systemMenuButton->iconColour = tabbedLayout ? tabHeaderColour + : juce::Colour(0xffdce4ef); + systemMenuButton->setBounds((bounds * viewScale()).toNearestInt()); systemMenuButton->toFront(false); } if (lcd != nullptr) - lcd->setBounds(scaled({ 873.0f + panelHorizontalOffset, 237.0f, - 448.0f, 70.0f })); + place(*lcd, { 873.0f + panelHorizontalOffset, 237.0f, 448.0f, 70.0f }); if (knobDesignCentres.size() == knobs.size() && knobArtworkTypes.size() == knobs.size()) @@ -2072,10 +2250,10 @@ void WaveEmulationAudioProcessorEditor::resized() { const auto hitSize = knobArtworkTypes[index] == KnobArtworkType::largeRed ? 104.0f : 60.0f; - knobs[index]->setBounds(scaled( - juce::Rectangle(hitSize, hitSize) - .withCentre(knobDesignCentres[index].translated( - panelHorizontalOffset, 0.0f)))); + place(*knobs[index], + juce::Rectangle(hitSize, hitSize) + .withCentre(knobDesignCentres[index].translated( + panelHorizontalOffset, 0.0f))); } } @@ -2083,9 +2261,9 @@ void WaveEmulationAudioProcessorEditor::resized() { for (size_t index = 0; index < editButtons.size(); ++index) { - editButtons[index]->setBounds(scaled( - juce::Rectangle(65.0f, 65.0f) - .withCentre(editButtonDesignCentres[index]))); + place(*editButtons[index], + juce::Rectangle(65.0f, 65.0f) + .withCentre(editButtonDesignCentres[index])); // Parameter knobs are constructed later and would otherwise sit // above the small overlapping edge of a diagonal Edit switch. // Only the exact pill path accepts a click, so its transparent @@ -2093,7 +2271,6 @@ void WaveEmulationAudioProcessorEditor::resized() editButtons[index]->toFront(false); } } - } void WaveEmulationAudioProcessorEditor::rebuildPanelImage() @@ -2108,9 +2285,9 @@ void WaveEmulationAudioProcessorEditor::rebuildPanelImage() // at the current editor size avoids retracing thousands of vector paths on // every LED, fader, or LCD refresh. constexpr auto artworkScale = 2; - const auto fullArtworkHeight - = juce::roundToInt(designHeight * static_cast(getWidth()) / designWidth); - panelImage = juce::Image(juce::Image::RGB, getWidth() * artworkScale, + const auto fullArtworkWidth = juce::roundToInt(designWidth * viewScale()); + const auto fullArtworkHeight = juce::roundToInt(designHeight * viewScale()); + panelImage = juce::Image(juce::Image::RGB, fullArtworkWidth * artworkScale, fullArtworkHeight * artworkScale, true); juce::Graphics imageGraphics(panelImage); imageGraphics.fillAll(juce::Colours::black); @@ -2225,9 +2402,9 @@ juce::String WaveEmulationAudioProcessorEditor::tooltipAt(juce::Point poi if (getWidth() <= 0) return {}; for (const auto& knob : knobs) - if (knob->getBounds().toFloat().contains(point)) + if (knob->isVisible() && knob->getBounds().toFloat().contains(point)) return knob->getTooltip(); - const auto designPoint = point * (designWidth / static_cast(getWidth())); + const auto designPoint = viewToDesign(point); for (const auto& region : panelRegions) if (region.fader && region.faderIndex >= 0 && region.path.contains(designPoint.x, designPoint.y)) diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index 0c0fa49..7f2aa56 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -6,6 +6,7 @@ #include #include +#include #include class WaveEmulationAudioProcessorEditor final : public juce::AudioProcessorEditor, @@ -21,6 +22,8 @@ class WaveEmulationAudioProcessorEditor final : public juce::AudioProcessorEdito [[nodiscard]] juce::String tooltipAt(juce::Point editorPoint) const; juce::Result loadPanelSkin(const juce::File& file, bool remember = true); void useDefaultPanelSkin(); + void setTabbedLayout(bool tabbed, bool remember = true); + void selectTab(int tab); void paint(juce::Graphics&) override; void resized() override; @@ -77,6 +80,13 @@ class WaveEmulationAudioProcessorEditor final : public juce::AudioProcessorEdito void initialisePanelRegions(const juce::XmlElement& svg); void synchroniseFaderValues() noexcept; void rebuildPanelImage(); + void layoutComponents(); + void paintDesign(juce::Graphics&, float scale); + [[nodiscard]] float viewScale() const noexcept; + [[nodiscard]] juce::Point viewSize() const noexcept; + [[nodiscard]] juce::Point viewToDesign(juce::Point viewPoint) const noexcept; + [[nodiscard]] std::optional> designToView( + juce::Rectangle designBounds) const noexcept; bool handlePerformanceControl(juce::Point designPoint); void pressPanelButton(int hardwareId); void releaseActivePointerInteractions(bool showMidiFeedback); @@ -156,6 +166,8 @@ class WaveEmulationAudioProcessorEditor final : public juce::AudioProcessorEdito std::array keyboardNotes{}; bool computerShiftDown = false; bool keyboardVisible = true; + bool tabbedLayout = false; + int selectedTab = 0; bool performanceMode = true; int selectedLfo = 0; int waveEnvelopePage = 0; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index d24dd3b..a7e9619 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -5881,3 +5881,18 @@ juce::Result WaveEmulationAudioProcessor::rememberPanelSkin(const juce::File& fi return juce::Result::fail("Could not save the panel skin preference."); return juce::Result::ok(); } + +bool WaveEmulationAudioProcessor::getRememberedTabbedLayout() const +{ + return firmwarePreferenceFile.getSiblingFile( + firmwarePreferenceFile.getFileNameWithoutExtension() + "-layout.txt") + .loadFileAsString().trim() == "tabbed"; +} + +void WaveEmulationAudioProcessor::rememberTabbedLayout(bool tabbed) +{ + const auto preference = firmwarePreferenceFile.getSiblingFile( + firmwarePreferenceFile.getFileNameWithoutExtension() + "-layout.txt"); + if (preference.getParentDirectory().createDirectory().wasOk()) + preference.replaceWithText(tabbed ? "tabbed" : "classic"); +} diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h index 6e62f7d..31cbb82 100644 --- a/Source/PluginProcessor.h +++ b/Source/PluginProcessor.h @@ -201,6 +201,8 @@ class WaveEmulationAudioProcessor final : public juce::AudioProcessor, } [[nodiscard]] juce::File getRememberedPanelSkin() const; juce::Result rememberPanelSkin(const juce::File& file); + [[nodiscard]] bool getRememberedTabbedLayout() const; + void rememberTabbedLayout(bool tabbed); bool setPanelButton(int buttonId, bool pressed) noexcept; void setKeyboardControllerButton(uint8_t asciiCode, bool pressed) noexcept; [[nodiscard]] int getFirmwareOscillatorOctave(int oscillator) const noexcept; diff --git a/Tests/WavePublicEditorTests.cpp b/Tests/WavePublicEditorTests.cpp index 9588089..85e8367 100644 --- a/Tests/WavePublicEditorTests.cpp +++ b/Tests/WavePublicEditorTests.cpp @@ -74,6 +74,28 @@ int main() require(panelEditor->tooltipAt({ 10.0f, 600.0f }).isEmpty(), "Empty panel space should not have a control tooltip"); + // Tabbed layout: sections are translated beside a fixed screen block. + panelEditor->setTabbedLayout(true, false); + require(editor->getWidth() == 1446 && editor->getHeight() == 659, + "Unexpected tabbed editor size"); + snapshot(*editor, "editor-tab-oscillator"); + require(panelEditor->tooltipAt({ 156.0f, 121.0f }).contains("Wave 1 Detune"), + "Oscillator tab knob is not mapped"); + require(panelEditor->tooltipAt({ 969.0f - 66.0f, 440.0f }).startsWith("Fader "), + "Screen faders are not mapped"); + panelEditor->selectTab(1); + snapshot(*editor, "editor-tab-filter"); + require(panelEditor->tooltipAt({ 227.0f, 277.0f }).contains("Cutoff"), + "Filter tab knob is not mapped"); + require(panelEditor->tooltipAt({ 156.0f, 121.0f }).isEmpty(), + "Hidden oscillator knob still answers on the filter tab"); + panelEditor->selectTab(2); + snapshot(*editor, "editor-tab-control"); + panelEditor->selectTab(0); + panelEditor->setTabbedLayout(false, false); + require(editor->getWidth() == 2338 && editor->getHeight() == 1042, + "Classic layout did not restore its size"); + // A synthetic skin verifies visible replacement and fixed hit geometry. juce::TemporaryFile skin(".svg"); require(skin.getFile().replaceWithText( From 0e4cd39562984bc3113352c7cfb48a5d537ef6f2 Mon Sep 17 00:00:00 2001 From: BD <46272996+xbraindance@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:01:07 +0200 Subject: [PATCH 02/11] Ignore local Waldorf resources (Sounds, System, Tools and their zips) Keep the Waldorf-provided Sounds/System/Tools folders and their downloaded archives out of version control. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index 4b34084..7348bd6 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,11 @@ *.provisionprofile *.keychain *.keychain-db + +# Local Waldorf Wave resources +/Sounds/ +/System/ +/Tools/ +/Sounds.zip +/System.zip +/Tools.zip From 8f7ba4ab2768759e49a8d73cb43d83a934da6d16 Mon Sep 17 00:00:00 2001 From: BD <46272996+xbraindance@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:07:31 +0200 Subject: [PATCH 03/11] Rewrite firmware research note with the completed mapping results The 23 August note predated the firmware mapping. Replace it with the findings from mapping and running both OS 1.700 images (main OS and the WDV voice-board program) in a 68000 oracle: corrected hardware map, master-to-voice-board protocol, modulation-table facts, reproduced firmware bugs, remaining open questions and next steps. The full map, firmware and gates stay outside the repository. Co-Authored-By: Claude Sonnet 5.5 --- waldorf-wave-firmware-reverse-engineering.md | 457 +++++-------------- 1 file changed, 104 insertions(+), 353 deletions(-) diff --git a/waldorf-wave-firmware-reverse-engineering.md b/waldorf-wave-firmware-reverse-engineering.md index fa05aec..6eb156b 100644 --- a/waldorf-wave-firmware-reverse-engineering.md +++ b/waldorf-wave-firmware-reverse-engineering.md @@ -1,87 +1,35 @@ -# Waldorf Wave Firmware Availability and Its Relevance to Sample-Accurate Emulation +# Waldorf Wave Firmware: Reverse-Engineering Status and Findings -_Research status: 23 August 2026_ +_Status: 2 October 2026. Supersedes the 23 August 2026 research note, which was written before the firmware was mapped._ ## Executive summary -The Waldorf **Wave**, rather than the Microwave, is the target instrument. Its firmware is unusually promising for an emulation project because the original system is split across a main CPU board and as many as three 16-voice WDV digital voice boards. Each side runs 68000-family code, and the main system loads a separate program into the WDV boards during startup. +The Waldorf **Wave** runs two 68000-family programs, and **both public images have now been fully mapped**: -The two important files are: +- **`w2sys.bin`** (main OS, OS 1.700, 302,768 B, loaded at **0x1000**, cold entry 0x100C): user interface, MIDI/SysEx, disk and FAT12 file system, wavetable editor, voice allocation, and the loader that starts the voice boards. +- **`wdv.sys`** (12,840 B = a 32-byte a.out-style header + a 12,808-byte body): the program each WDV voice board runs. The board copies it into private RAM at address 0 (SP 0x8FFE, reset vector 0x400). -- `w2sys.bin`: the Wave's main operating system, executed by the main CPU and responsible for the user interface, storage, MIDI, voice allocation, system management, and WDV-board startup. -- `wdv.sys`: the executable downloaded to each WDV voice board. It is the most direct surviving software description of the boundary between high-level synthesis parameters and the Wave's oscillator ASICs, waveform memory, control-voltage path, and per-voice analogue hardware. +Every code unit in both images was read: **2,135 ledger rows** (w2sys 2,078, wdv 57), each with purpose, RAM, device addresses and callees. About 217 of the w2sys rows are Ghidra fragments of larger functions, not separate functions. The map was then run against the real images in a 68000 core (Musashi, the same core this repository links): **15 gate files, 760,050 checks, 0 failures**, each with a mutation self-test that proves it can fail. The oracle also reproduced about 30 real firmware bugs (§6). -Waldorf currently provides a public `System.zip` download from its official [Legacy Wave page](https://waldorfmusic.com/legacy-wave/). The archive contains Waldorf OS releases 1.668, 1.671, 1.680, and 1.700. Inspection of that archive shows that all four releases contain the **same 12,840-byte `wdv.sys`**, while `w2sys.bin` changes between versions. That is a high-value reverse-engineering result: changes such as the known 1.700 voice-allocation problem are probably on the main-OS side, while the public Waldorf voice-board program remained stable across these releases. +What this does and does not buy for emulation: -Public download does not automatically grant permission to redistribute the binaries. The emulator should therefore download or ask the user to supply the files, authenticate them by hash, and keep them out of the source repository. +- **Firmware is now a specification, not a mystery.** The memory maps, shared-RAM protocol, boot/load sequence, file and SysEx formats, and the voice-board write sequences are known and checked against the real code. +- **The analogue/ASIC side is still open.** The two Waldorf oscillator ASICs, the CV DACs, sample-and-hold stages, CEM3387 filters/VCAs and the output path are not in the ROM. The firmware only reveals the byte stream it writes to them; register *roles* beyond the write patterns are inference. Nothing here is hardware-verified (no real unit was measured). -Firmware is necessary but not sufficient for sample-accurate emulation. It can recover control algorithms, timing, tables, voice allocation, board communication, and ASIC/DAC writes. It cannot by itself reveal undocumented ASIC internals or the exact analogue response of the multiplexers, sample-and-hold stages, DACs, CEM3387 filters/VCAs, panning path, and component tolerances. - -## 1. Why the Wave firmware split matters - -The Wave is not simply a Microwave with a larger panel. The service documentation describes a main CPU board and separate WDV digital voice boards; diagnostics can reset, start, and communicate with WDV boards 1 through 3. A WDV board handles 16 voices, with two eight-voice Waldorf oscillator ASICs on the board. The system can therefore scale to 48 voices by installing three WDV boards. - -The effective control topology is: +The complete map, the per-routine table and the gates live in a separate research folder, deliberately **outside this repository** (it sits next to a local copy of the firmware, which must not be committed): ```text -MIDI / keyboard / panel / disk - | - v - Main 68000 CPU board - w2sys.bin - | - system bus / shared state - | - +------+------+------+ - | | | - WDV 1 WDV 2 WDV 3 - 16 voices 16 16 - | - WDV 68000 running wdv.sys - | - two 8-voice Waldorf ASICs - | - waveform conversion, CV DACs, - S/H, CEM3387 filter/VCA/panning - | - audio +/Users/click/Desktop/Move/ideas/Waldorf Wave firmware/ + spec/firmware-map.md whole-firmware synthesis (start here) + spec/README.md layers, specs, gates + map/firmware_map.csv one row per routine (2,135) + oracle/ 68000 oracle + 15 gates + tools/run_gates.sh runs everything ``` -The [Wave service manual](https://www.waldorf-wave.de/Waldorf-WAVE-Service-Manual.pdf) is the primary hardware map. Its schematics expose the WDV CPU, 32 MHz board oscillator and 16 MHz CPU clock, two ASIC interfaces, waveform RAM buses, S-bus interface, reset/status logic, and downstream voice circuitry. The [unofficial Wave hardware page](https://www.waldorf-wave.de/wavetech.html) independently summarizes the same main/WDV 68000 and 16-voice-board structure. - -The [official Waldorf Microwave 1 plug-in technical page](https://waldorfmusic.com/microwave/) explicitly states that the first-generation Microwave and the Wave use the same custom Waldorf ASIC, and identifies the original internal synthesis rate as 250 kHz. Microwave 1 service schematics and circuit measurements are therefore valuable independent evidence for the ASIC's external clocking, waveform-RAM/DAC interface, quantization and reconstruction path. They cannot replace Wave-specific WDV traces, since the surrounding voice count, control protocol and analogue board topology differ. - -The current behavioural proxy now clocks its oscillator lookup, signed eight-bit two-oscillator mixer, ASIC high-pass and reconstruction input at 250 kHz. The ES2 mix stage wraps rather than clips when its weighted signed output crosses the eight-bit boundary, matching Waldorf's documented “ASIC Mix Bug” test at combined oscillator levels above 128. The following VCF input has a separate mild saturation curve beginning near 70% mixer output. These are evidence-backed boundaries, not a claim that the unknown ASIC's internal multiply/accumulate gate ordering has been recovered; controlled Wave/Microwave captures remain necessary to tune the final bit-level hypothesis. - -This separation is useful because it gives the reverse-engineering work two comparatively clean targets: +## 1. Provenance and file identities -1. recover the main OS's loader, voice allocation, parameter calculation, and WDV protocol from `w2sys.bin`; and -2. recover the WDV scheduler and hardware-facing writes from `wdv.sys`. - -## 2. Current public availability - -### Official Waldorf archive - -As of the research date, Waldorf's [Legacy Wave page](https://waldorfmusic.com/legacy-wave/) exposes a **System** download. It resolves to a public Waldorf-hosted Nextcloud share named [`System.zip`](https://downloads.waldorfmusic.com/cloud/index.php/s/7zzTnmzK2pYY9Ks). The direct download used by this project is: - -```text -https://downloads.waldorfmusic.com/cloud/index.php/s/7zzTnmzK2pYY9Ks/download -``` - -The outer archive contains: - -```text -System/wave_sys1_700.zip -System/wave_old_systems.zip -``` - -The first archive contains OS 1.700. The old-systems archive contains 1.668, 1.671, and 1.680 in several period-appropriate packaging formats. The actual boot files are ordinary firmware images and must not be treated as archives merely because one is named `.bin`. The [unofficial Wave boot-disk instructions](https://unofficial.waldorf-wave.de/waveif.html) likewise identify `w2sys.bin` and `wdv.sys` as the two required system files. - -The project already provides `scripts/fetch-official-firmware.sh`, which fetches the Waldorf archive and extracts OS 1.700. Keeping acquisition in a script is preferable to committing the firmware. - -### Verified Waldorf releases and file identities - -The following values were measured from Waldorf's official archive on the research date: +The images come from Waldorf's public Legacy Wave page (`System.zip`: OS 1.668, 1.671, 1.680, 1.700). The `wdv.sys` image is byte-identical in all four releases; only `w2sys.bin` changes. | OS | `w2sys.bin` size | `w2sys.bin` SHA-256 | `wdv.sys` size | `wdv.sys` SHA-256 | |---|---:|---|---:|---| @@ -90,320 +38,123 @@ The following values were measured from Waldorf's official archive on the resear | 1.680 | 296,092 | `fa1d5ea2d090206246ca4df33cecfb812854e25bb72018b48e673f4552f24494` | 12,840 | `bdf379b07785af313068191961ee5ef408e267740c1b598290732477bf9d3f68` | | 1.700 | 302,768 | `4282457d9bf7d70da2e2aa4d6a1e69467c178d0d8d8be0a27f524ab8d62e3286` | 12,840 | `bdf379b07785af313068191961ee5ef408e267740c1b598290732477bf9d3f68` | -The identical `wdv.sys` hash is important enough to restate: the WDV image in 1.668, 1.671, 1.680, and 1.700 is byte-for-byte identical. The public version history is therefore mainly a differential-analysis corpus for `w2sys.bin`, not four revisions of the voice-board program. - -### Other known versions - -- The Wave System Exclusive description names **OS 1.400** as its target. This establishes an earlier software generation and is useful for interpreting parameter and dump structures, but a 1.400 firmware pair is not present in Waldorf's current `System.zip`. See the [Wave SysEx manual](https://unofficial.waldorf-wave.de/os/OS_1-900_WAVE_Sysex_manual.pdf). -- The unofficial support page recommends **1.680** for many original instruments and reports a voice-allocation bug in **1.700**. The later OS 1.8/1.9 documentation also says that 1.8 fixed the 1.700 missing-note/voice-allocation problem. This is a valuable hint that the affected code is in `w2sys.bin`, because the official 1.680 and 1.700 WDV images are identical. -- Later **1.8xx and 1.9xx** releases are community-developed successors rather than files in Waldorf's current official system archive. Their documentation describes personalization and key handling. For example, the [OS 1.911 manual](https://unofficial.waldorf-wave.de/os/OS_1-911.pdf) shows a larger 15,112-byte `WDV.SYS` and a keyed installation flow. Those images should be treated as a separate provenance and licensing case, not assumed to be interchangeable or freely redistributable. - -For the first emulation milestone, OS 1.700 is the sensible canonical target because it is the latest Waldorf-authored version in the current official download, has known hashes, and matches the project's existing firmware loader. OS 1.680 should be retained as the primary comparison build and possibly as a user-selectable compatibility target. +Only OS 1.700 has been mapped. 1.668, 1.671 and 1.680 have **not** been diffed yet. The later community 1.8xx/1.9xx releases are a separate provenance and licensing case and are not covered. -## 3. Roles of the two files +The firmware banner reads `Version No. 1.260-1.202-1.700`. The 1.260 and 1.202 are the kernel and voice-board code versions (a version page table in the OS shows them). The MIDI identity reply still reports "1400". -### `w2sys.bin`: main Wave operating system - -`w2sys.bin` is a raw, big-endian 68000-family program image for the main CPU board. The 1.700 image starts with absolute 68k jump instructions whose targets fall within the image, which gives a useful initial anchor for discovering the load base and startup path. - -Static strings in OS 1.700 show that the main program: - -- locates and reads `WDV.SYS` from the boot disk; -- rejects it if it is not a recognized executable object; -- resets, detects, starts, and checks multiple voice boards; -- reports old- versus new-style voice hardware; -- initializes oscillators and loads wavetables to voice boards; -- contains the system monitor and WDV diagnostic functions; and -- owns user-facing voice allocation and bad-voice handling. - -This makes `w2sys.bin` essential for faithful instrument behaviour. It is likely to contain or coordinate: - -- MIDI and keyboard event processing; -- panel editing and modulation parameter normalization; -- voice allocation, stealing, sustain, and performance layers; -- patch, wavetable, tuning, and machine-specific data management; -- envelope/LFO setup or control-rate generation, depending on which work is delegated to WDV; -- commands and data transfers to each WDV board; and -- boot-time transfer and launch of `wdv.sys`. - -It is not safe to assume that envelopes or LFOs run entirely on one CPU. Static and dynamic tracing must determine whether the main CPU sends high-level parameters, control-rate values, or a mixture of both. - -### `wdv.sys`: WDV voice-board executable - -`wdv.sys` is small enough to reverse engineer thoroughly and close enough to the sound-generating hardware to be exceptionally valuable. - -Preliminary binary inspection gives several strong anchors: - -- It is a 12,840-byte big-endian m68k object with a 32-byte a.out-style header. Common `file` implementations identify it as a SunOS m68k demand-paged executable. That label describes the object container; it is **not** evidence that the Wave contains a 68020. The WDV schematic and clocking point to a 68000-class CPU. -- The first four bytes are `01 02 01 0b`, and the header contains `0x00003228`, equal to 12,840 decimal. The precise a.out variant and Waldorf loader rules should be confirmed from the `w2sys.bin` loader rather than assumed from a host tool's guess. -- Immediately after the 32-byte header is a plausible 68000 vector table. Its first two longwords are a candidate initial stack pointer of `0x00008FFE` and reset/program counter of `0x00000400`. -- The remaining early longwords resemble exception-vector targets, giving a natural way to distinguish vectors, startup code, interrupt handlers, and data. - -The likely high-value contents are: - -- the WDV reset and interrupt handlers; -- the board's command loop or mailbox consumer; -- per-voice state updates and scheduling; -- ASIC command/register writes and waveform-RAM access; -- pitch-to-increment or tuning conversions; -- wavetable position, wave selection, interpolation, and modulation handling; -- CV DAC update scheduling for filters, VCAs, and panning; -- envelope, LFO, glide, and control-rate tables if those functions execute on WDV; and -- board status, acknowledgements, error reporting, and synchronization with the main CPU. - -### Why `wdv.sys` is the prize - -The oscillator ASIC is undocumented. A schematic can show address lines, data lines, chip selects, clocks, and attached RAM, but not the semantic meaning of every write. `wdv.sys` must drive that interface correctly. Consequently, every hardware access made by the WDV code is evidence about the ASIC protocol. - -Once the executable is running in an instrumented CPU emulator, the project can record a timestamped trace such as: +## 2. System architecture (as found in the code) ```text -WDV CPU cycle -> address -> value -> read/write -> decoded device/voice +MIDI / keybed / panel / disk / RS-232 + | + v + Main 68000 (w2sys.bin, 1 MB DRAM, runs from RAM) + | + shared SRAM 0x100000-0x17FFFF (one RAM, seen at the same addresses by master and boards) + | + +--------+--------+--------+ + | | | + WDV 1 WDV 2 WDV 3 (up to 3 by the 3-bit slot strap) + 68000 16 voices each + | + CV DACs + S/H, two 8-voice ASICs (0x980000 / 0x980100), wave RAM A/B, routing latch ``` -Correlating those traces with controlled note, pitch, wavetable, envelope, and modulation changes can reveal: - -- which address ranges select ASIC A versus ASIC B; -- how voices 1-8 and 9-16 are selected; -- the format and width of pitch and wavetable-position values; -- whether updates are immediate, double-buffered, latched, or interrupt-driven; -- the control-rate cadence and ordering of oscillator and CV updates; and -- how the firmware synchronizes shared data with the main CPU. - -That is much stronger evidence than reconstructing the ASIC solely from audible output. It still does not disclose the ASIC's internal oscillator arithmetic, interpolation, aliasing, or truncation rules; those require hardware measurements, logic traces, patent/design evidence if available, and hypothesis testing against real output. - -## 4. Relevance to sample-accurate emulation - -“Sample-accurate” should be used narrowly. At a minimum, the same input event and initial state must produce deterministic hardware-side events at the same emulated times, and those events must affect the rendered host sample at the correct fractional-sample position. - -A credible Wave model therefore needs: - -1. **Firmware-accurate control behaviour.** The original voice allocation, modulation rules, quantization, tables, update order, and edge cases should come from executed firmware where practical. -2. **Clock-domain scheduling.** Main-CPU execution, WDV execution, interrupts, shared-bus handshakes, ASIC clocks, and control updates need one monotonic emulated timeline. Host audio block boundaries must not change the result. -3. **Timestamped side effects.** ASIC, waveform-RAM, DAC, multiplexer, and gate writes must be timestamped rather than collapsed into one value per host block. -4. **Digital voice modelling.** The ASIC must remain a black box. A behavioural proxy for wavetable addressing, phase accumulation, interpolation/truncation, mixing, and output quantization can only be inferred and validated from its external bus and real-hardware measurements; this cannot establish the undocumented internal implementation. -5. **Mixed-signal modelling.** The time-multiplexed conversion, sample-and-hold behaviour, 12-bit control-voltage stepping, reconstruction stages, CEM3387 filter/VCA/panning response, and output path need explicit models. - -Executing both firmware images without those hardware models would reproduce the control computer but not the instrument's sound. Conversely, a good DSP approximation without the firmware can sound Wave-like while still getting voice allocation, timing, modulation, and obscure behaviours wrong. The goal is to join the two at the same observable hardware boundary. - -Cycle-exact CPU emulation may not be necessary everywhere, but it is the safest initial reference. After traces show which timings are audible or state-significant, noncritical regions can be optimized while preserving the same timestamped I/O sequence. - -## 5. Recommended reverse-engineering workflow - -### Step 1: preserve provenance and build a manifest - -- Acquire `System.zip` only from Waldorf's official Legacy Wave link. -- Record retrieval date, source URL, byte size, and SHA-256 for the outer and nested archives. -- Extract every Waldorf version into a local research area excluded from version control. -- Record the per-file sizes and hashes shown above. -- Make the emulator accept user-supplied files and identify exact known revisions. Never silently accept a different image as 1.700. - -The existing `FirmwareBundle` already authenticates the 1.700 pair. Extend its manifest to recognize the official 1.668, 1.671, and 1.680 main images while recording that their WDV image is shared. - -### Step 2: confirm CPU architecture and object formats - -- Trace the CPU part number, clock, reset, interrupt, bus, RAM, and chip-select nets in the service schematics for both the main and WDV boards. -- Configure analysis as big-endian Motorola 68000 initially. Do not select 68020 merely because a host utility uses that label for the a.out header. -- Parse the `wdv.sys` header explicitly. Confirm magic, text/data/BSS fields, relocation assumptions, entry semantics, and the loader's copy/zero/launch sequence in `w2sys.bin`. -- Determine whether the WDV vector table begins at file offset `0x20`, whether it is copied to board address `0x000000`, and whether reset begins at `0x000400`. -- Determine the main image base by following its opening jump stubs, absolute references, reset mapping, and the main-board memory decode. - -The load address should be proven from both code and hardware decode. A disassembly that happens to look plausible at one base is not enough. - -### Step 3: construct the memory maps - -For each CPU, create a table containing: - -- ROM or downloaded code; -- local SRAM/DRAM and BSS; -- shared/global RAM windows; -- system-bus address windows; -- ASIC A and B interfaces; -- waveform RAM; -- DAC, timer, interrupt, reset, status, and watchdog registers; and -- any programmable-logic-controlled aliases or mirrors. - -Start from schematic chip-select equations and then validate each range against absolute-address references in the disassembly. Name addresses by evidence level: `confirmed`, `probable`, or `unknown_io_x`. - -### Step 4: recover the main OS's WDV loader - -The main OS contains excellent string anchors around reading, validating, starting, and checking `WDV.SYS`. Work outward from those references to recover: - -1. disk read and object validation; -2. header parsing; -3. WDV reset assertion and release; -4. transfer destination and length; -5. BSS clearing or relocation processing; -6. start address/vector handling; -7. board-presence and ready polling; and -8. timeout, retry, and error paths. - -Implementing this loader behaviour first provides a concrete success criterion: an emulated WDV should reach the same ready state that the main OS expects. - -### Step 5: recover main-to-WDV communication - -The service schematics and diagnostics indicate an S-bus/shared-state relationship, but the exact protocol must be derived. Identify: - -- per-board address selection and board numbering; -- reset, fault, setup, status, and interrupt lines; -- shared-memory or mailbox layout; -- ownership and bus-arbitration rules; -- command and acknowledgement fields; -- sequence counters, ready flags, and timeout rules; -- voice-to-board mapping; and -- wavetable bulk-transfer paths. - -Use cross-references from the main OS's voice-board error strings and from WDV interrupt handlers. Then run both CPUs under one deterministic scheduler with all shared-memory reads, writes, interrupts, and status transitions logged. - -### Step 6: identify ASIC and CV side effects - -On WDV, enumerate every access outside ordinary code/RAM. Group accesses by address, instruction site, interrupt context, and repetition rate. For each candidate register or port: - -- vary one high-level parameter at a time; -- compare the resulting write sequence; -- test voice 1 versus 8, 9, and 16 to expose chip and voice selection; -- test minimum, midpoint, maximum, negative, and wrapped values; -- measure write cadence and phase relative to WDV interrupts; and -- determine whether values are raw, table-transformed, split across bytes/words, or latched by a later write. - -Do not name a register from a single correlation. Require at least two independent experiments or a matching schematic/code clue. - -### Step 7: locate envelope, LFO, pitch, and control-rate tables - -Search both binaries because responsibilities may be divided between CPUs. Useful methods include: - -- locating monotonic or symmetric word/longword arrays; -- testing candidate tables as big-endian signed and unsigned values; -- finding multiply/divide or indexed-load code near pitch, envelope, glide, and LFO parameter paths; -- comparing 1.668/1.671/1.680/1.700 main images to isolate changed routines and tables; -- tracing from SysEx/patch fields through normalized parameters to shared-memory writes; and -- observing table indices and outputs dynamically. - -Extracted tables should carry their source image hash, file offset, interpreted type, and evidence. Avoid committing copyrighted bulk data unless redistribution rights are clear; generating derived tests or asking the user to supply firmware at runtime is safer. - -Initial OS 1.700 result: the WDV executable body contains a 128-entry big-endian signed-word modulation table at offset `0x25e8`. Its entries follow `sign(n) * 8 * n^2` for signed amount `n = -64...+63`, with the negative endpoint saturated to `-32767`. General modulation destinations apply this table once. The oscillator-pitch routines at `0x254e` and `0x25a6` apply the table twice, producing a signed fourth-power depth curve. Both oscillator callers then apply `asr.l #3` at `0x0e34` and `0x0eea` before adding the result to their pitch accumulators. Pitch accumulation uses 256 internal units per semitone. Including that caller scaling makes the factory value `+12` approximately ±0.020 semitone at a full-scale LFO, rather than the ±2.25 semitones produced by a linear interpretation. - -### Step 8: build a deterministic dual-CPU execution harness - -The first harness should prioritize observability over real-time speed: - -- one main 68000 instance; -- one WDV 68000 instance initially, expandable to three; -- a single emulated-time scheduler based on device clock cycles; -- instrumented memory and I/O handlers; -- interrupt and reset tracing; -- reproducible disk/boot inputs; and -- trace checkpoints for boot, note-on, note-off, pitch bend, wavetable movement, and modulation. - -The service schematic's 32 MHz oscillator divided to a 16 MHz WDV CPU clock supplies a starting timing anchor. Other device rates must be derived from the schematics and measured behaviour rather than inferred from host sample rate. - -### Step 9: turn traces into the ASIC contract - -Define an internal interface at the observable WDV hardware boundary, for example: - -```text -write(time, device, address, value) -read(time, device, address) -> value -interrupt(time, source, level) -``` - -Keep the firmware-facing register model separate from the oscillator algorithm. This allows multiple ASIC hypotheses to be tested against the same authoritative firmware trace and lets a future gate-level or measured model replace an approximation without changing CPU emulation. - -### Step 10: validate against real hardware - -Use several independent oracles: - -- service diagnostics and expected boot/status messages; -- logic-analyser captures of WDV bus, ASIC selects, clocks, DAC updates, and shared-bus activity where safe; -- audio captures for controlled patches at multiple notes, velocities, wavetable positions, filter settings, and modulation rates; -- repeated captures to separate deterministic behaviour from analogue tolerance/noise; and -- cross-version tests, especially 1.680 versus 1.700 voice allocation under identical MIDI sequences. - -Validation should compare event traces before comparing audio. If the ASIC/DAC write stream is wrong, tuning the analogue model to hide it will produce a fragile imitation. - -## 6. Legal and practical caveats +Key facts: -### Publicly downloadable is not necessarily redistributable +- **The OS runs from RAM.** It is loaded at 0x1000 by a loader that is not in this image (an Atari-ST-style floppy boot sector). Evidence: the DOS layer stores its "current drive" in the first opcode word of `fs_init`, and the line-draw code patches opcodes in itself. +- **Boot (0x100C):** reset the three serial chips, copy a 1 KB exception-vector template to address 0, boot self-tests (cookies 0xBABEFACE / 0xFEEDFEED skip them on a warm start), heap init, load the voice boards, start the UI. Holding both panel boot switches (0xFE980020 bits 7/6) jumps into diagnostics. +- **Interrupts:** VIA timer = level 2 (system tick, key/encoder scan, LED PWM), MIDI ACIAs = level 4, console ACIA = level 5, floppy controller = level 6. Exceptions drop into the built-in System Monitor. +- **The OS also contains** a 27-command System Monitor, a diagnostics menu, a complete DOS/FAT12 file system with a group-file "database" layer, a TIFF screenshot writer, an FFT-based sample-to-wavetable analyser, FM and formant wavetable synthesis, and Microwave bulk-dump import. -Waldorf publicly hosts the archive, which is strong evidence that owners and researchers can legitimately obtain the historical system files from the manufacturer. It is not a clear software license granting third parties permission to mirror, bundle, modify, or commercially redistribute the binaries. +### Main-CPU devices (24-bit bus: firmware address 0xFEB80001 is 0xB80001) -Recommended project policy: +| address | device | +|---|---| +| 0x000000-0x0FFFFF | DRAM, 1 MB (OS image at 0x1000, heap 0x5AA80-0xFDFEA) | +| 0x100000-0x17FFFF | shared SRAM, 512 KB, battery-backed | +| 0xFEA00000-0xFEA03FFF | LCD video RAM: 1 bpp, 64 bytes/row, 4 pages of 0x1000 (480 x 64 visible) | +| 0xFEA80000 | floppy controller (uPD765/82077-type), data moved by CPU polling | +| 0xFEAA0000 | real-time clock (RP5C01-type) | +| 0xFEB80001/3, 0xFEBA0001/3 | 6850 ACIAs: MIDI A and B | +| 0xFEBC0001/3 | 6850 ACIA: RS-232 console / monitor | +| 0xFEBE0001-0x1D | 6522 VIA: tick timer, WDV reset line, board-present strap, LCD display page, FDC DRQ | +| 0xFE90..0xFE9E | keybed, buttons, encoders, LED matrix, analogue mux and ADC | -- do not commit `System.zip`, `w2sys.bin`, `wdv.sys`, factory wavetables, or derived bulk firmware data; -- direct users to Waldorf's official Legacy Wave page or provide an opt-in fetch script; -- authenticate user-supplied files by hash; -- store only hashes, sizes, format metadata, addresses, annotations, and independently written emulation code in the repository; -- keep clean-room boundaries available if the project may become commercial; and -- obtain legal advice before distributing firmware-derived tables or translated code. +### Voice board (WDV) -### Version provenance matters +| address (board bus) | device | +|---|---| +| 0x0000-0xFFFF | private RAM (program copy at 0, per-voice workspaces from 0x9000) | +| 0x100000-0x17FFFF | the same shared SRAM as the master | +| 0x040000 / 0x050000 / 0x060000 | wave RAM A / B / write-both alias; the firmware writes 8-bit samples to odd bytes at 0x60001 + (bank << 13) | +| 0x800001 | board control/status byte (bit 1 bus claim, bit 3 wave-RAM write, slot strap in bits 0-2 on read) | +| 0x880000 | CV DAC data latch (12-bit values) | +| 0x8A0000 + 16*voice + channel | sample-and-hold strobe | +| 0x8C0000 | output routing latch | +| 0x980000 / 0x980100 | ASIC A (voices 0-7) / ASIC B (voices 8-15) | -Do not call every pair of correctly named files “Waldorf OS 1.700.” Verify both images together. A mixed pair may boot but invalidate research results. Later community OS images have different sizes, features, and key/personalization behaviour and need separate approval, manifests, and test expectations. +## 3. The master-to-voice-board protocol -### Hardware revisions matter +There is **no command parser on the board.** The master drives it through shared RAM and the board polls: -The main OS detects old- and new-style voice hardware. A single software model may therefore need multiple WDV/analogue-board configurations, timing variations, or compatibility behaviours. Machine-specific calibration and filter-adjustment data should not be mistaken for firmware or shared blindly between instruments. +1. **Download:** the master opens `WDV.SYS`, reads the 32-byte header (accepted type words 0x107/0x108/0x10B/0x111 at +2), and copies the body to 0x100000. It then pulses the board reset via VIA port B bit 0. +2. **Board start (0x400):** the board copies 0x2000 words from 0x100000 to its own address 0, reads its slot id from the one-cold strap in `0x800001` bits 0-2, marks itself present at `0x105086 + id`, then waits for the master's go flag at `0x10508A + id`. +3. **Voice update:** the master takes the bus mutex (`0x1050E2`, test-and-set), writes a 0x100-byte voice record at `0x100000 + (16*board + voice) * 0x100`, sets that voice's bit in the per-board update mask at `0x105092`, and releases. Each voice has a semaphore at `0x1050A2 + voice`. +4. **Board scan (0x79C):** per voice, the board runs ramp, LFOs, mixer, envelopes, pitch, filter, VCA and pan stages, then writes the CV DACs, the sample-and-hold strobes and the ASIC registers. +5. **Wavetable load:** the master renders 64 waves of 64 bytes into a staging area at 0x104000, writes the bank to `0x105081` and a command to `0x105080`; each board writes the samples to wave RAM and acknowledges. The only command byte on the board is this one. +6. **Fault report:** a board fault writes a record at 0x105100 (type at +1, board id word at +2) and blinks a code on `0x800001`. The master's CA2 interrupt path reads it, prints "VOICE BOARD n" and enters the monitor. -### Firmware does not guarantee bit-perfect sound +### Modulation depth (the part the old note called "table applied once or twice") -The ASIC design and analogue voice chain remain only partly documented. Component variation means that even two physical Waves will not necessarily null against each other. The defensible goal is deterministic, traceable digital/control behaviour plus a measured analogue model with documented tolerances—not an unsupported “bit-perfect” claim. +- The WDV holds a 128-entry signed-word modulation table. It follows `clamp(sign(n) * 8 * n^2, +-32767)` with `n = index - 64`; **125 of 128 entries match**, and the three real deviations are index 6 (-29912), index 126 (31000) and index 127 (32767). Index 0 (-32767) is the clamp. +- One routine applies the table once (linear depth); the pitch routines apply it **twice**, giving a fourth-order depth curve. The two oscillator callers then `asr.l #3` before adding to the pitch accumulator, at 0xE14 and 0xECA. Pitch accumulates at 256 units per semitone, so full depth is 4095 units, about 16 semitones. +- The pitch-to-increment table has 257 words with a constant of about 19.584, not 19.58. -## 7. Recommended next-step checklist +## 4. Data formats established -### Acquisition and evidence +The map documents these in `spec/firmware-map.md` §4: the sound record (256 B), multi/performance record (512 B), voice record (0x100 B), global parameter block (0x180 B at 0x148800), tuning tables (4 x 128 x {coarse, fine}), velocity curves (128 B, 8 breakpoints), wavetable records (0x8A B: a 9-character name, a 0x55 marker, 64 wave numbers) and the 300 factory waves of 64 bytes, the Wave's own SysEx format (`F0 3E 03 ... F7`), the Microwave bulk-import format, the MIDI Tuning Standard and Sample Dump handlers, the FAT12 disk layout, and the file types `.SND .PFM .SBK .PBK .ARR .TTB .VTB .GLB .WTB .GSX .SET .WVS` plus the TIFF screenshot. -- [ ] Run `scripts/fetch-official-firmware.sh` and verify the 1.700 hashes against this document. -- [ ] Extend the acquisition script to optionally extract the old-systems archive without committing any binaries. -- [ ] Add a machine-readable manifest for 1.668, 1.671, 1.680, and 1.700. -- [ ] Record archive provenance and retrieval date in a research log. -- [ ] Add firmware and analysis-project directories to ignore rules if they are not already excluded. +## 5. Corrections to the 23 August note and to the existing emulator constants -### Static analysis +Checked against the bytes: -- [ ] Create separate big-endian 68000 analysis projects for `w2sys.bin` and `wdv.sys`. -- [ ] Parse and document the 32-byte WDV object header. -- [ ] Confirm WDV vector-table offset, initial stack, entry point, and copy address from the main loader. -- [ ] Confirm the main image base and startup vectors from schematic address decode. -- [ ] Import known strings, schematic net names, and service-diagnostic labels as symbols. -- [ ] Diff all four official main images and classify changed functions/data. +- **Not a Microwave-style VIA/FDC layout at FEB8/FEBA/FEBC:** those are three 6850 serial chips (two MIDI, one RS-232 console). The VIA is at FEBE, the floppy controller at FEA8, the RTC at FEAA. +- **Waveform RAM** is at board 0x040000 / 0x050000 with a write-both alias at 0x060000, written via `0x60001 + (bank << 13)`. The emulator's `VoiceFirmwareRuntime.h` constant `waveformRamBase = 0x600000` does not match the code. +- **Shared SRAM** is one RAM seen at 0x100000 by master and boards, not a window onto board address 0. The offsets 0x5092 / 0x50A2 / 0x50E2 used in the emulator are offsets from 0x100000 and are confirmed. +- **The modulation-table `asr.l #3` sites** are 0xE14 and 0xECA, not 0x0E34 and 0x0EEA. The table has three real deviations, not four (index 0 is the clamp). +- **The master/loader details** are confirmed: `w2sys` base 0x1000, `wdv.sys` header words, SP 0x8FFE, reset 0x400, board reset on VIA port B bit 0, board presence on VIA port A bits 0-2, board id from the strap on `0x800001`. +- The old note's recommended acquisition and "do not commit firmware" policy stands. -### Hardware and protocol recovery +## 6. Firmware bugs the oracle reproduced -- [ ] Produce reviewed main-CPU and WDV memory maps with evidence levels. -- [ ] Recover the WDV reset/download/start handshake. -- [ ] Recover the shared-memory/mailbox structures and interrupt protocol. -- [ ] Identify ASIC A/B, waveform-RAM, DAC, timer, and status address ranges. -- [ ] Trace one complete note-on/note-off path from MIDI event to WDV hardware writes. -- [ ] Locate pitch, envelope, LFO, glide, and control-rate transforms on whichever CPU owns them. +Behaviours that the real ROM exhibits under the 68000 core and that the gates now pin: the SysEx program-change-map dump converts only 8 or 9 of 128 words; the Sample Dump receiver answers a bad checksum with ACK; the MIDI Tuning dump encoder is one semitone flat and reads its names from the wrong address; the single-note-change receiver is off by one and negative cents give an invalid data byte; a multi request always answers multi 247; `strncpy_pad_d` pads using the destination address as the count; several FAT12 directory-scan bugs (slot-15 re-scan, a sub-directory find-next that restarts at the first cluster, signed drive-letter checks); the heap `shrink` is a no-op on the first 20 blocks; `printf` mishandles `%d` of -32768; the velocity-curve loader overruns its slot; the voice-board boot pre-init uses the wrong stride for boards other than 0; and the global-parameter SysEx receiver copies 0x160 of 0x180 bytes. The full table (about 40 suspected bugs, with evidence level) is in `spec/firmware-map.md` §6. -### Emulator integration +## 7. What is still open -- [ ] Integrate an instruction-correct 68000 core behind testable memory/I/O callbacks. -- [ ] Run one main CPU and one WDV CPU under a deterministic common scheduler. -- [ ] Timestamp every hardware side effect independently of host audio block size. -- [ ] Reproduce Wave boot, WDV ready, diagnostics, and failure cases before enabling audio. -- [ ] Expand from one WDV board to two and three only after the one-board protocol is stable. -- [ ] Keep the firmware CPU/ASIC bridge explicit; the UI must not report genuine firmware execution while only the procedural model is active. +- **The reported OS 1.700 voice-allocation bug** (missing notes, fixed in 1.8x) is **not localised.** The voice-board image is identical to 1.680, so the cause is on the master. Candidate areas are the note-on allocation code (0x26C4-0x3162) and the key scheduler (0x35B8). This needs a diff of the 1.680 and 1.700 `w2sys.bin` plus oracle runs with the same polyphonic MIDI sequence. +- **ASIC semantics:** the roles of the ASIC registers (0x980000 + 0/2/4/6/0xE, and 0x12), the CV channel meanings (cutoff, resonance and pan are guesses), the routing latch bits, and how the key-on wave-number write selects a bank. +- **Hardware details:** VIA port bits beyond those listed, the panel boot-switch bits, LED strobe rows (7 vs 8), the physical meaning of the analogue channels. +- **Maximum board count:** the strap allows 3, diagnostics accept up to 4, and some tables hold 64 entries. +- **Static only:** the UI pages, the Wave Editor, the LFO, env3/env4 and glide stages on the board, and anything timing-dependent. -### Measurement and validation +## 8. What this means for sample-accurate emulation -- [ ] Design a minimal set of hardware-safe logic captures for clock, reset, bus, ASIC select, and DAC timing. -- [ ] Record calibrated single-voice audio fixtures from a real Wave. -- [ ] Compare event traces before spectral/audio comparisons. -- [ ] Test 1.680 and 1.700 with the same polyphonic MIDI sequence to isolate the known allocation difference. -- [ ] Publish an accuracy matrix stating what is confirmed, inferred, approximated, or not yet implemented. +"Sample-accurate" still requires the same things the earlier note listed: one deterministic emulated timeline, timestamped side effects independent of host block size, a digital voice model for the ASIC, and a measured mixed-signal model for the converters, filters and output stage. The mapping removes the guesswork from the *control* side: the order and content of every write the firmware makes to the ASICs, DACs and strobes is now documented and gated. The remaining work is on the other side of that boundary, where only measurements of a real Wave can settle the open points in §7. -## 8. Suggested milestone definition +Practical next steps: -The next meaningful milestone should be called **firmware-driven control emulation**, not complete Wave emulation. It is achieved when: +- [ ] Check the emulator's constants and behaviours against `spec/firmware-map.md` §3-§5 (starting with the waveform-RAM address). +- [ ] Diff OS 1.668, 1.671, 1.680 and 1.700 `w2sys.bin` and localise the voice-allocation change. +- [ ] Capture ASIC/CV write traces for one note from the oracle and compare them with the emulator's. +- [ ] Plan the hardware captures (bus, ASIC select, DAC timing, calibrated audio) needed to pin the ASIC and analogue model. -1. authentic `w2sys.bin` and `wdv.sys` execute on emulated 68000 CPUs; -2. the main OS downloads and starts WDV using the recovered hardware protocol; -3. a controlled MIDI note produces a deterministic, timestamped series of WDV ASIC/DAC writes; -4. the trace is stable across host buffer sizes and runs; and -5. at least one part of that trace is confirmed against a real Wave or an independently documented schematic/diagnostic behaviour. +## 9. Legal and practical caveats -Only after that milestone should the project claim that original firmware is actively driving the sound model. Sample-accurate Wave emulation then becomes a measurable hardware-modelling problem rather than a speculative reimplementation of the instrument's control software. +- Waldorf publicly hosts the archive, which supports legitimate research use. It is not a licence to redistribute. **Do not commit `System.zip`, `w2sys.bin`, `wdv.sys`, the factory wavetables or derived bulk firmware data** to this or any repository. Keep hashes, sizes, addresses, annotations and independently written code only. +- Verify both images together; a mismatched pair can boot yet invalidate results. Later community OS images need separate provenance. +- Hardware revisions matter (the OS detects old and new voice hardware); machine-specific calibration data in the shared SRAM is not firmware and should not be shared between instruments. +- Firmware does not guarantee bit-perfect sound. The defensible goal is deterministic, traceable digital/control behaviour plus a measured analogue model with documented tolerances. ## References From 74e6e5b6ea590dd67ec5000a08137b5ee2e5ca08 Mon Sep 17 00:00:00 2001 From: BD <46272996+xbraindance@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:40:51 +0200 Subject: [PATCH 04/11] Add WaveVoiceTrace tool for the emulator-vs-oracle ASIC/CV write diff WaveVoiceTrace boots the real WDV.SYS in VoiceFirmwareRuntime from a small command script (poke, until_pc, scan, run, mark) and prints the logged ASIC/CV write stream with cycle stamps. The firmware repo's oracle/gate_e_wdv_note_trace.py runs the same script on the Musashi oracle and diffs the two: one note on voices 3 and 9 matches in order, value and cycle across all 11,146 checks. Both sides use the same 68000 core, so this checks bus decode, shared RAM and IRQ plumbing, not the ROM. The tool is EXCLUDE_FROM_ALL and needs no ROMs at build time. The research note's next-step checkbox is ticked with the result and the gaps found: the routing latch (0x8C0000) and board-control latch (0x800001) are neither logged nor decoded, and the engine's firmware register shadow is unused. Co-Authored-By: Claude Sonnet 5.5 --- Tests/CMakeLists.txt | 6 + Tests/WaveVoiceTrace.cpp | 132 +++++++++++++++++++ waldorf-wave-firmware-reverse-engineering.md | 2 +- 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 Tests/WaveVoiceTrace.cpp diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index 2250a30..deecaf0 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -75,3 +75,9 @@ if(WAVE_HAS_PRIVATE_ROMS) WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" ENVIRONMENT "WAVE_FACTORY_SET=${CMAKE_SOURCE_DIR}/wave.set") endif() + +# Developer tool: drives the real WDV.SYS from a script and prints the ASIC/CV +# write stream for the oracle diff (needs no ROMs at build time). +add_executable(WaveVoiceTrace EXCLUDE_FROM_ALL WaveVoiceTrace.cpp) +target_include_directories(WaveVoiceTrace PRIVATE ../Source) +target_link_libraries(WaveVoiceTrace PRIVATE WaveCore) diff --git a/Tests/WaveVoiceTrace.cpp b/Tests/WaveVoiceTrace.cpp new file mode 100644 index 0000000..676efca --- /dev/null +++ b/Tests/WaveVoiceTrace.cpp @@ -0,0 +1,132 @@ +// Runs the real WDV.SYS in VoiceFirmwareRuntime from a command script and prints +// the logged ASIC/CV write stream ("W "). The oracle gate +// gate_e_wdv_note_trace.py (Waldorf Wave firmware repo) runs the same script on +// the Musashi oracle and diffs the two streams. +// +// Script commands: poke (shared RAM 0x100000..0x1fffff), +// until_pc (single-step), scan (wait for one full voice scan), +// run , mark . +#include "Firmware/VoiceFirmwareRuntime.h" + +#include +#include +#include +#include +#include + +using namespace wave::firmware; + +namespace +{ +constexpr uint32_t loopAliveFlag = 0x10508e; // st.b after every 0x79c call, board 0 +constexpr int scanSliceCycles = 200; +constexpr long stepLimit = 50'000'000; + +uint8_t* sharedAt(SharedFirmwareMemory& memory, uint32_t address) +{ + if (address >= 0x180000 && address < 0x180000 + memory.extension.size()) + return &memory.extension[address - 0x180000]; + if (address >= 0x140000 && address < 0x140000 + memory.work.size()) + return &memory.work[address - 0x140000]; + if (address >= 0x100000 && address < 0x100000 + memory.program.size()) + return &memory.program[address - 0x100000]; + return nullptr; +} +} // namespace + +int main(int argc, char** argv) +{ + if (argc != 3) + { + std::fprintf(stderr, "usage: WaveVoiceTrace wdv.sys script\n"); + return 2; + } + juce::MemoryBlock image; + if (!juce::File(argv[1]).loadFileAsData(image)) + { + std::fprintf(stderr, "cannot read %s\n", argv[1]); + return 2; + } + + SharedFirmwareMemory memory; + memory.clear(); + VoiceFirmwareRuntime runtime; + runtime.attachSharedMemory(memory); + if (!runtime.loadAndReset(image)) + { + std::fprintf(stderr, "WDV image rejected\n"); + return 2; + } + + const auto flush = [&] { + for (const auto& write : runtime.pendingHardwareWrites()) + std::printf("W %llu %06x %02x\n", static_cast(write.cycle), + write.address, write.value); + runtime.clearHardwareWrites(); + }; + + std::ifstream script(argv[2]); + std::string line; + while (std::getline(script, line)) + { + std::istringstream in(line); + std::string command; + in >> command; + if (command == "poke") + { + uint32_t address = 0, value = 0; + in >> std::hex >> address >> value; + auto* byte = sharedAt(memory, address); + if (byte == nullptr) + { + std::fprintf(stderr, "poke outside shared RAM: %06x\n", address); + return 2; + } + *byte = static_cast(value); + } + else if (command == "until_pc") + { + uint32_t pc = 0; + in >> std::hex >> pc; + long steps = 0; + while (runtime.programCounter() != pc) + { + runtime.runCycles(1); + if (++steps > stepLimit) + { + std::fprintf(stderr, "PC %06x not reached\n", pc); + return 3; + } + } + } + else if (command == "scan") + { + *sharedAt(memory, loopAliveFlag) = 0; + long slices = 0; + while (runtime.sharedByte(loopAliveFlag - 0x100000) == 0) + { + runtime.runCycles(scanSliceCycles); + if (++slices > stepLimit) + { + std::fprintf(stderr, "scan did not finish\n"); + return 3; + } + } + } + else if (command == "run") + { + int cycles = 0; + in >> cycles; + runtime.runCycles(cycles); + } + else if (command == "mark") + { + std::string name; + in >> name; + flush(); + std::printf("M %s\n", name.c_str()); + } + } + flush(); + return 0; +} diff --git a/waldorf-wave-firmware-reverse-engineering.md b/waldorf-wave-firmware-reverse-engineering.md index 6eb156b..192492d 100644 --- a/waldorf-wave-firmware-reverse-engineering.md +++ b/waldorf-wave-firmware-reverse-engineering.md @@ -146,7 +146,7 @@ Practical next steps: - [ ] Check the emulator's constants and behaviours against `spec/firmware-map.md` §3-§5 (starting with the waveform-RAM address). - [ ] Diff OS 1.668, 1.671, 1.680 and 1.700 `w2sys.bin` and localise the voice-allocation change. -- [ ] Capture ASIC/CV write traces for one note from the oracle and compare them with the emulator's. +- [x] Capture ASIC/CV write traces for one note from the oracle and compare them with the emulator's (2026-10-02, `oracle/gate_e_wdv_note_trace.py` + `Tests/WaveVoiceTrace.cpp`). The same script (boot, seeded voice records, an idle scan, key-on of voices 3 and 9, three more scans) runs on the Musashi oracle and on `VoiceFirmwareRuntime`; all 11,146 logged writes (CV latch, CV strobes, ASIC, incl. tick-IRQ acknowledges) match in order, value and cycle stamp. Both sides use the same 68000 core, so this checks the emulator's bus decode, shared RAM and IRQ plumbing, not the ROM. Findings: `WaldorfAsic.cpp` never sees these writes (it is a behavioural proxy driven by the engine); `WaldorfEngine::applyFirmwareHardwareWrite` only shadows the bytes and nothing reads the shadow yet; the emulator does not log or decode the routing latch (0x8C0000, 128 byte-writes per scan) or the board-control latch (0x800001). - [ ] Plan the hardware captures (bus, ASIC select, DAC timing, calibrated audio) needed to pin the ASIC and analogue model. ## 9. Legal and practical caveats From 9a3e5979f0aae48f1bde1a5c3f375efaf2699591 Mon Sep 17 00:00:00 2001 From: BD <46272996+xbraindance@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:45:53 +0200 Subject: [PATCH 05/11] Fix WDV wave RAM decode and shared SRAM size per the firmware map Wave RAM is at board 0x040000 (A) / 0x050000 (B) with a write-both alias at 0x060000, not 0x600000. The voice bus also fell through to master DRAM below 0x100000, so WDV wave-RAM stores landed in the master heap. The board now sees only its private 64 KB, the shared SRAM and its devices. The shared SRAM is 512 KB (0x100000-0x17FFFF); drop the 0x180000 extension decode on both buses so accesses above it count as unmapped. Verified: ctest passes (4/4) at this commit. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- Source/Firmware/MasterFirmwareRuntime.cpp | 9 ----- Source/Firmware/MasterFirmwareRuntime.h | 1 - Source/Firmware/SharedFirmwareMemory.h | 4 +- Source/Firmware/VoiceFirmwareRuntime.cpp | 45 +++++++++++------------ Source/Firmware/VoiceFirmwareRuntime.h | 18 +++++++-- Tests/WaveCoreTests.cpp | 35 ++++++++++++++++++ 7 files changed, 75 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index b1bd06e..1f09961 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ This sample is honest about a hard distinction: - The master 68000 runs continuously on a 16 MHz emulated clock with a 1 kHz timer source. The model exposes the documented UART, timer/mode, RTC/latch, front-panel switch/LED, ADC-selection/readback, shared-memory, and LCD windows used by the authenticated boot path. - The WDV voice image's 32-byte loader header is decoded and its genuine 68000 body publishes at shared mailbox `$105086`, waits for the main CPU at `$10508A`, and reaches its service state after that externally visible acknowledgement. Production code no longer inserts a synthetic acknowledgement. - Both CPU runtimes use the same shared program/work SRAM object. The genuine master `w2sys.bin` executes its cold-start hardware writes and its OS 1.700 post-copy loader handoff, writing the observed acknowledgement value `1` to `$10508A`; the WDV CPU then publishes service at `$10508E`. -- The decoded WDV service transport uses a per-board 16-voice update mask at `$105092`, per-voice TAS semaphores at `$1050A2`, and 256-byte voice records beginning at `$100000`. The WDV runtime maps its observed waveform RAM at `$600000`, CV banks at `$880000/$8A0000`, and two oscillator-chip register pages at `$980000/$980100`. All writes are timestamped on the WDV clock for correlation with captures. +- The decoded WDV service transport uses a per-board 16-voice update mask at `$105092`, per-voice TAS semaphores at `$1050A2`, and 256-byte voice records beginning at `$100000`. The WDV runtime maps its two wave RAMs at `$040000`/`$050000` (with a write-both alias at `$060000`), CV banks at `$880000/$8A0000`, and two oscillator-chip register pages at `$980000/$980100`. All writes are timestamped on the WDV clock for correlation with captures. - The current bridge supplies `INIT.SND` and `INIT.PFM` through a narrowly scoped virtual system-disk service, and the genuine OS 1.700 loader opens, reads, and closes both files. Until original factory files are available, their contents are byte-exact copies of the genuine safe sound and performance records embedded in OS 1.700; they are conservative defaults, not claimed factory banks. The same records are preloaded as a defensive fallback. The CPU board's DP8473 floppy controller is modelled for mounted raw MS-DOS disk images; the separate boot-time `WDV.SYS` copy remains bypassed because the authenticated WDV body is installed directly into shared SRAM before the genuine master instructions resume at the verified post-copy handoff. This is not presented as a complete main-OS boot. - The CPU-board LCD is represented as two byte-wide 5563 SRAMs forming a 16-bit word, four 4 KiB pages, two parallel 74LS166 shifters, and the firmware page latch at `$BE0001`. The genuine OS renderer proves a 64-byte hardware scanline: 60 visible bytes for 480 pixels followed by four blanking bytes. Writes in the `$FEA00000-$FEA03FFF` video window automatically replace the model diagnostics with correctly decoded firmware scanout. - The oscillator ASIC is an undocumented black box. Its internal algorithms cannot be recovered or claimed from the available documentation. The DSP is explicitly an external behavioural proxy, using Waldorf's documented 250 kHz rate and ES2 eight-bit numerical mix-overflow boundary; its exact accumulator ordering and truncation remain to be validated against real-hardware measurements. Firmware register traces reveal only the external contract. diff --git a/Source/Firmware/MasterFirmwareRuntime.cpp b/Source/Firmware/MasterFirmwareRuntime.cpp index 1031cfa..81e1d5a 100644 --- a/Source/Firmware/MasterFirmwareRuntime.cpp +++ b/Source/Firmware/MasterFirmwareRuntime.cpp @@ -1139,9 +1139,6 @@ uint8_t MasterFirmwareRuntime::read8(uint32_t address) noexcept return sharedMemory->program[address - sharedProgramBase]; if (address >= sharedWorkBase && address < sharedWorkBase + sharedMemory->work.size()) return sharedMemory->work[address - sharedWorkBase]; - if (address >= sharedExtensionBase - && address < sharedExtensionBase + sharedMemory->extension.size()) - return sharedMemory->extension[address - sharedExtensionBase]; if (address >= lcdVideoBase && address < lcdVideoBase + lcdVideoRam.size()) return lcdVideoRam[address - lcdVideoBase].load(std::memory_order_relaxed); @@ -1225,12 +1222,6 @@ void MasterFirmwareRuntime::write8(uint32_t address, uint8_t value) noexcept sharedMemory->work[address - sharedWorkBase] = value; return; } - if (address >= sharedExtensionBase - && address < sharedExtensionBase + sharedMemory->extension.size()) - { - sharedMemory->extension[address - sharedExtensionBase] = value; - return; - } if (address >= lcdVideoBase && address < lcdVideoBase + lcdVideoRam.size()) { lcdVideoRam[address - lcdVideoBase].store(value, std::memory_order_relaxed); diff --git a/Source/Firmware/MasterFirmwareRuntime.h b/Source/Firmware/MasterFirmwareRuntime.h index 52f993d..19793fd 100644 --- a/Source/Firmware/MasterFirmwareRuntime.h +++ b/Source/Firmware/MasterFirmwareRuntime.h @@ -166,7 +166,6 @@ class MasterFirmwareRuntime final : private M68000Bus static constexpr uint32_t sharedProgramSize = SharedFirmwareMemory::programSize; static constexpr uint32_t sharedWorkBase = 0x140000; static constexpr uint32_t sharedWorkSize = SharedFirmwareMemory::workSize; - static constexpr uint32_t sharedExtensionBase = 0x180000; static constexpr uint32_t lcdVideoBase = 0xa00000; static constexpr uint32_t lcdVideoWindowSize = 0x4000; using LcdVideoSnapshot = std::array; diff --git a/Source/Firmware/SharedFirmwareMemory.h b/Source/Firmware/SharedFirmwareMemory.h index 561c2b1..a887461 100644 --- a/Source/Firmware/SharedFirmwareMemory.h +++ b/Source/Firmware/SharedFirmwareMemory.h @@ -10,7 +10,9 @@ struct SharedFirmwareMemory static constexpr uint32_t mainRamSize = 0x100000; static constexpr uint32_t programSize = 0x40000; static constexpr uint32_t workSize = 0x40000; - static constexpr uint32_t extensionSize = 0x80000; + // The shared SRAM is 512 KB (0x100000-0x17FFFF = program + work); nothing + // is decoded above it, so accesses there count as unmapped. + static constexpr uint32_t extensionSize = 0; void clear() noexcept { diff --git a/Source/Firmware/VoiceFirmwareRuntime.cpp b/Source/Firmware/VoiceFirmwareRuntime.cpp index b50b57e..d2e370f 100644 --- a/Source/Firmware/VoiceFirmwareRuntime.cpp +++ b/Source/Firmware/VoiceFirmwareRuntime.cpp @@ -25,7 +25,8 @@ bool VoiceFirmwareRuntime::loadAndReset(const juce::MemoryBlock& voiceImage) if (sharedMemory == &ownedSharedMemory) sharedMemory->clear(); asicRegisters.fill(0); - waveformRam.fill(0); + for (auto& ram : waveformRam) + ram.fill(0); for (auto& bank : cvRegisters) bank.fill(0); controlLatch = 0; @@ -156,17 +157,18 @@ uint8_t VoiceFirmwareRuntime::read8(uint32_t address) noexcept { if (address < localRam.size()) return localRam[address]; - if (address < sharedMemory->mainRam.size()) - return sharedMemory->mainRam[address]; + // The board has only its private 64 KB below the shared SRAM; master DRAM + // is on the other bus. if (address >= sharedProgramBase && address < sharedProgramBase + sharedMemory->program.size()) return sharedMemory->program[address - sharedProgramBase]; if (address >= sharedWorkBase && address < sharedWorkBase + sharedMemory->work.size()) return sharedMemory->work[address - sharedWorkBase]; - if (address >= sharedExtensionBase - && address < sharedExtensionBase + sharedMemory->extension.size()) - return sharedMemory->extension[address - sharedExtensionBase]; - if (address >= waveformRamBase && address < waveformRamBase + waveformRam.size()) - return waveformRam[address - waveformRamBase]; + if (address >= waveRamABase && address < waveRamABase + waveRamSize) + return waveformRam[0][address - waveRamABase]; + if (address >= waveRamBBase && address < waveRamBBase + waveRamSize) + return waveformRam[1][address - waveRamBBase]; + if (address >= waveRamBothBase && address < waveRamBothBase + waveRamSize) + return waveformRam[0][address - waveRamBothBase]; if (address == boardControlAddress) return boardStatus; if (address >= asicBase && address < asicBase + asicRegisters.size()) @@ -188,11 +190,6 @@ void VoiceFirmwareRuntime::write8(uint32_t address, uint8_t value) noexcept localRam[address] = value; return; } - if (address < sharedMemory->mainRam.size()) - { - sharedMemory->mainRam[address] = value; - return; - } if (address >= sharedProgramBase && address < sharedProgramBase + sharedMemory->program.size()) { sharedMemory->program[address - sharedProgramBase] = value; @@ -203,20 +200,22 @@ void VoiceFirmwareRuntime::write8(uint32_t address, uint8_t value) noexcept sharedMemory->work[address - sharedWorkBase] = value; return; } - if (address >= sharedExtensionBase - && address < sharedExtensionBase + sharedMemory->extension.size()) + // Wave-RAM stores are not queued: a bank load is up to 32K bytes, which + // would crowd the 64K ASIC/CV write queue, and the engine ignores them. + if (address >= waveRamABase && address < waveRamABase + waveRamSize) { - sharedMemory->extension[address - sharedExtensionBase] = value; + waveformRam[0][address - waveRamABase] = value; return; } - if (address >= waveformRamBase && address < waveformRamBase + waveformRam.size()) + if (address >= waveRamBBase && address < waveRamBBase + waveRamSize) { - waveformRam[address - waveformRamBase] = value; - if (hardwareWrites.size() < 65536) - hardwareWrites.push_back( - { emulatedCycles - + static_cast(juce::jmax(0, cpu.currentExecutionCycleOffset())), - address, value }); + waveformRam[1][address - waveRamBBase] = value; + return; + } + if (address >= waveRamBothBase && address < waveRamBothBase + waveRamSize) + { + waveformRam[0][address - waveRamBothBase] = value; + waveformRam[1][address - waveRamBothBase] = value; return; } if (address == boardControlAddress) diff --git a/Source/Firmware/VoiceFirmwareRuntime.h b/Source/Firmware/VoiceFirmwareRuntime.h index f1b0734..861f405 100644 --- a/Source/Firmware/VoiceFirmwareRuntime.h +++ b/Source/Firmware/VoiceFirmwareRuntime.h @@ -44,6 +44,12 @@ class VoiceFirmwareRuntime final : private M68000Bus [[nodiscard]] uint16_t asicWord(uint32_t offset) const noexcept; [[nodiscard]] uint16_t cvWord(int bank, uint32_t offset) const noexcept; [[nodiscard]] uint8_t sharedByte(uint32_t offset) const noexcept; + [[nodiscard]] uint8_t waveRamByte(int ram, uint32_t offset) const noexcept + { + return (ram == 0 || ram == 1) && offset < waveRamSize + ? waveformRam[static_cast(ram)][offset] + : 0xffu; + } [[nodiscard]] uint64_t emulatedCycleCount() const noexcept { return emulatedCycles; } [[nodiscard]] uint64_t controlTickCount() const noexcept { return controlTicks; } [[nodiscard]] int getBoardIndex() const noexcept { return boardIndex; } @@ -53,9 +59,13 @@ class VoiceFirmwareRuntime final : private M68000Bus static constexpr uint32_t sharedProgramBase = 0x100000; static constexpr uint32_t sharedProgramSize = SharedFirmwareMemory::programSize; static constexpr uint32_t sharedWorkBase = 0x140000; - static constexpr uint32_t sharedExtensionBase = 0x180000; - static constexpr uint32_t waveformRamBase = 0x600000; - static constexpr uint32_t waveformRamSize = 0x20000; + // Two 64 KB wave RAMs (A, B) with a write-both alias. WDV.SYS stores 8-bit + // samples on odd bytes at 0x060001 + (bank << 13); the alias is read back + // as RAM A (alias read behaviour is not established by the firmware). + static constexpr uint32_t waveRamABase = 0x040000; + static constexpr uint32_t waveRamBBase = 0x050000; + static constexpr uint32_t waveRamBothBase = 0x060000; + static constexpr uint32_t waveRamSize = 0x10000; static constexpr uint32_t cvBankABase = 0x880000; static constexpr uint32_t cvBankBBase = 0x8a0000; static constexpr uint32_t cvWindowSize = 0x10000; @@ -76,7 +86,7 @@ class VoiceFirmwareRuntime final : private M68000Bus SharedFirmwareMemory ownedSharedMemory; SharedFirmwareMemory* sharedMemory = &ownedSharedMemory; std::array asicRegisters{}; - std::array waveformRam{}; + std::array, 2> waveformRam{}; std::array, 2> cvRegisters{}; uint8_t controlLatch = 0; int boardIndex = 0; diff --git a/Tests/WaveCoreTests.cpp b/Tests/WaveCoreTests.cpp index d8d8947..baa14b0 100644 --- a/Tests/WaveCoreTests.cpp +++ b/Tests/WaveCoreTests.cpp @@ -2425,6 +2425,40 @@ void testDecodedVoiceBoardProtocol() "WDV shared bus mutex was not released"); } +void testVoiceBoardWaveRamDecode() +{ + // Hand-built WDV image (no firmware needed): SP 0x8FFE, reset 0x400, then + // byte stores through RAM A, RAM B and the write-both alias, and a spin. + static constexpr uint8_t code[] = { + 0x13, 0xfc, 0x00, 0xa5, 0x00, 0x06, 0x00, 0x01, // move.b #$a5,$060001 + 0x13, 0xfc, 0x00, 0x5a, 0x00, 0x05, 0x00, 0x03, // move.b #$5a,$050003 + 0x13, 0xfc, 0x00, 0x11, 0x00, 0x04, 0x00, 0x05, // move.b #$11,$040005 + 0x60, 0xfe // bra.s * + }; + juce::MemoryBlock image(0x20 + 0x400 + sizeof(code), true); + auto* bytes = static_cast(image.getData()); + bytes[0x20 + 2] = 0x8f; + bytes[0x20 + 3] = 0xfe; + bytes[0x20 + 6] = 0x04; + std::copy(std::begin(code), std::end(code), bytes + 0x20 + 0x400); + + wave::firmware::SharedFirmwareMemory memory; + memory.clear(); + wave::firmware::VoiceFirmwareRuntime runtime; + runtime.attachSharedMemory(memory); + require(runtime.loadAndReset(image), "Synthetic WDV image was rejected"); + runtime.runCycles(2000); + require(runtime.waveRamByte(0, 1) == 0xa5 && runtime.waveRamByte(1, 1) == 0xa5, + "Write at $060001 did not reach both wave RAMs"); + require(runtime.waveRamByte(1, 3) == 0x5a && runtime.waveRamByte(0, 3) == 0, + "Write at $050003 did not reach wave RAM B only"); + require(runtime.waveRamByte(0, 5) == 0x11 && runtime.waveRamByte(1, 5) == 0, + "Write at $040005 did not reach wave RAM A only"); + require(memory.mainRam[0x60001] == 0 && memory.mainRam[0x50003] == 0 + && memory.mainRam[0x40005] == 0, + "Voice-board wave RAM stores leaked into master DRAM"); +} + class Test68000Bus final : public wave::firmware::M68000Bus { public: @@ -2825,6 +2859,7 @@ int main() testLcdFramebuffer(); testCompletePanelWiringContract(); testDecodedVoiceBoardProtocol(); + testVoiceBoardWaveRamDecode(); testOfficialFirmwareWhenAvailable(); std::cout << "WaveCoreTests: all checks passed\n"; return 0; From c19601ea7ac4c084e44e6abd95f361a9be1e66e0 Mon Sep 17 00:00:00 2001 From: BD <46272996+xbraindance@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:45:53 +0200 Subject: [PATCH 06/11] Apply the three WDV modulation-table typos and correct the cited addresses Table 0x25E8 follows 8*n^2 except index 6 (-29912), 126 (31000) and 127 (32767). The oscillator asr.l #3 sites are 0xE14/0xECA, not 0xE34/0xEEA. Verified: ctest passes (4/4) at this commit. No test covers the table values. Co-Authored-By: Claude Sonnet 5.5 --- Source/Dsp/WaldorfEngine.cpp | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/Source/Dsp/WaldorfEngine.cpp b/Source/Dsp/WaldorfEngine.cpp index 2e0a8eb..9a391cd 100644 --- a/Source/Dsp/WaldorfEngine.cpp +++ b/Source/Dsp/WaldorfEngine.cpp @@ -3,22 +3,33 @@ #include #include +#include #include #include +#include namespace wave::dsp { namespace { // WDV OS 1.700 stores modulation amounts in a 128-word table at 0x25e8. -// Apart from the saturated -64 endpoint, its exact relationship is -// sign(amount) * amount^2 / 4096. The pitch routine applies that curve twice, -// then both oscillator callers at $0e34/$0eea arithmetic-shift its result -// right by three before adding it to the pitch accumulator. +// Apart from the saturated -64 endpoint and three typos, its relationship is +// sign(amount) * amount^2 / 4096 (8 * n^2 of 32768). The typos are index 6 +// (amount -58: -29912 instead of -26912), index 126 (+62: 31000 instead of +// 30752) and index 127 (+63: 32767 instead of 31752). The pitch routine +// applies the result squared, then both oscillator callers at $0e14/$0eca +// arithmetic-shift it right by three before adding it to the pitch +// accumulator. float wdvAmountDepth(float amount) noexcept { const auto limited = juce::jlimit(-64.0f, 63.0f, amount); - return std::copysign(limited * limited / 4096.0f, limited); + static constexpr std::array, 3> tableTypos {{ + { -58.0f, -3000.0f }, { 62.0f, 248.0f }, { 63.0f, 1015.0f } + }}; + auto depth = std::copysign(limited * limited / 4096.0f, limited); + for (const auto& [index, error] : tableTypos) + depth += error / 32768.0f * juce::jmax(0.0f, 1.0f - std::abs(limited - index)); + return depth; } float wdvPitchDepthSemitones(float amount) noexcept From 17d8704c40b528a083f58c6ab77830e0b422ffc3 Mon Sep 17 00:00:00 2001 From: BD <46272996+xbraindance@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:45:53 +0200 Subject: [PATCH 07/11] Stop Bundle::load searching the whole filesystem for a missing path A nonexistent firmware path fell back to its parent directory, so the recursive image search walked "/" and effectively never returned. Only step up to the parent when the path is an existing file. Verified: ctest passes (4/4) at this commit; the new WaveCoreTests case covers the missing-path load. Co-Authored-By: Claude Sonnet 5.5 --- Source/Firmware/FirmwareBundle.cpp | 8 +++++--- Tests/WaveCoreTests.cpp | 10 ++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Source/Firmware/FirmwareBundle.cpp b/Source/Firmware/FirmwareBundle.cpp index d9ad0ec..f02443a 100644 --- a/Source/Firmware/FirmwareBundle.cpp +++ b/Source/Firmware/FirmwareBundle.cpp @@ -17,9 +17,11 @@ Bundle::Report Bundle::load(const juce::File& directoryOrImage) { clear(); - const auto root = directoryOrImage.isDirectory() - ? directoryOrImage - : directoryOrImage.getParentDirectory(); + // A missing path must not fall back to its parent: the recursive image + // search below would then walk e.g. the whole filesystem for "/nonexistent". + const auto root = directoryOrImage.existsAsFile() + ? directoryOrImage.getParentDirectory() + : directoryOrImage; const auto masterFile = findImage(root, "w2sys.bin"); const auto voiceFile = findImage(root, "wdv.sys"); diff --git a/Tests/WaveCoreTests.cpp b/Tests/WaveCoreTests.cpp index baa14b0..1b17b51 100644 --- a/Tests/WaveCoreTests.cpp +++ b/Tests/WaveCoreTests.cpp @@ -2238,6 +2238,15 @@ void testReferenceComparison() << " peak=" << metrics.peakError << '\n'; } +void testFirmwareBundleRejectsMissingPath() +{ + // Used to search the parent ("/") recursively and effectively never return. + wave::firmware::Bundle bundle; + const auto report = bundle.load(juce::File("/nonexistent-wave-firmware-dir")); + require(report.authenticity != wave::firmware::Bundle::Authenticity::verifiedOs1700, + "Missing firmware path was accepted"); +} + void testOfficialFirmwareWhenAvailable() { const auto* path = std::getenv("WAVE_FIRMWARE_DIR"); @@ -2860,6 +2869,7 @@ int main() testCompletePanelWiringContract(); testDecodedVoiceBoardProtocol(); testVoiceBoardWaveRamDecode(); + testFirmwareBundleRejectsMissingPath(); testOfficialFirmwareWhenAvailable(); std::cout << "WaveCoreTests: all checks passed\n"; return 0; From de989b9fcdcb8c70490777f8c5005dd26f30e24d Mon Sep 17 00:00:00 2001 From: BD <46272996+xbraindance@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:11:40 +0200 Subject: [PATCH 08/11] Add opt-in fix for the OS 1.700 voice-allocation bug OS 1.700's find_free_voice (0x2EAC) returns the lowest-level voice of any part when no voice is free, so the callers' steal passes never run and a fresh note steals a held note of its chord. A one-byte patch at 0x2EE2 (bcs -> bra) restores OS 1.680's allocation. It is applied only to an image whose surrounding bytes are the 1.700 ones, off by default, toggled from the System menu and saved in plugin state. Verified: ctest passes (4/4) at this commit. The 1.700 vs 1.680 allocation difference and the effect of the one-byte patch were checked against the 68000 oracle (gate_f_voice_alloc_1680_vs_1700.py, 12/12 checks: 1.680 steals no held note, 1.700 does, 1.700 with the patch does not). The oracle's voice-board side is a model, not hardware-validated. Co-Authored-By: Claude Sonnet 5.5 --- Source/Firmware/MasterFirmwareRuntime.cpp | 16 ++++++++++++++++ Source/Firmware/MasterFirmwareRuntime.h | 7 +++++++ Source/PluginEditor.cpp | 9 +++++++++ Source/PluginProcessor.cpp | 13 +++++++++++++ Source/PluginProcessor.h | 3 +++ Tests/WaveCoreTests.cpp | 4 ++++ 6 files changed, 52 insertions(+) diff --git a/Source/Firmware/MasterFirmwareRuntime.cpp b/Source/Firmware/MasterFirmwareRuntime.cpp index 81e1d5a..6285107 100644 --- a/Source/Firmware/MasterFirmwareRuntime.cpp +++ b/Source/Firmware/MasterFirmwareRuntime.cpp @@ -99,6 +99,21 @@ void MasterFirmwareRuntime::Acia6850::pushReceive(uint8_t value) noexcept } } +bool MasterFirmwareRuntime::setVoiceAllocationFix(bool enabled) noexcept +{ + voiceAllocationFix = enabled; + // find_free_voice: cmpw 80(a0),d5 (BA68 0050) ; bcs.s +6 (65 06) ; movew 80(a0),d5 ; movew d7,d4 + constexpr uint32_t branch = 0x2ee2u; + constexpr uint8_t prefix[] = { 0xba, 0x68, 0x00, 0x50 }; + auto& ram = sharedMemory->mainRam; + const auto isOs1700 = std::equal(std::begin(prefix), std::end(prefix), ram.begin() + (branch - 4u)) + && ram[branch + 1u] == 0x06 && (ram[branch] == 0x65 || ram[branch] == 0x60); + if (!isOs1700) + return false; + ram[branch] = enabled ? 0x60 : 0x65; + return enabled; +} + bool MasterFirmwareRuntime::loadAndStart(const juce::MemoryBlock& masterImage) { loaded = false; @@ -160,6 +175,7 @@ bool MasterFirmwareRuntime::loadAndStart(const juce::MemoryBlock& masterImage) return false; std::copy_n(bytes, masterImage.getSize(), sharedMemory->mainRam.begin() + imageBase); + setVoiceAllocationFix(voiceAllocationFix); // INIT.PFM and INIT.SND are normally separate files on the Wave system // disk. OS 1.700 also contains genuine safe fallback records, used after diff --git a/Source/Firmware/MasterFirmwareRuntime.h b/Source/Firmware/MasterFirmwareRuntime.h index 19793fd..970cdbb 100644 --- a/Source/Firmware/MasterFirmwareRuntime.h +++ b/Source/Firmware/MasterFirmwareRuntime.h @@ -22,6 +22,12 @@ class MasterFirmwareRuntime final : private M68000Bus public: void attachSharedMemory(SharedFirmwareMemory& memory) noexcept { sharedMemory = &memory; } bool loadAndStart(const juce::MemoryBlock& masterImage); + // Opt-in: OS 1.700's find_free_voice (0x2EAC) returns the quietest voice instead of + // letting the steal passes run, so a fresh note steals a held note of its chord + // (firmware-map.md section 5). One byte at 0x2EE2 (bcs -> bra) restores OS 1.680's + // allocation. Only applied to an image whose surrounding bytes are the 1.700 ones; + // returns whether it is applied now (it is re-applied on every loadAndStart). + bool setVoiceAllocationFix(bool enabled) noexcept; int runCycles(int cycles); int runForAudioSamples(int samples, double sampleRate); bool runOs1700InitialisationFileLoad(); @@ -252,6 +258,7 @@ class MasterFirmwareRuntime final : private M68000Bus int pendingPerformanceRefresh = -1; bool voiceLoaderReached = false; bool customInitialisationRecords = false; + bool voiceAllocationFix = false; bool voiceBoardHandoffComplete = false; int activeVoiceBoardCount = 0; bool coldHardwareSetupComplete = false; diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 53c542b..8476bc5 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -52,6 +52,7 @@ constexpr auto toggleKeyboardMenuItem = 0x470b; constexpr auto loadPanelSkinMenuItem = 0x470c; constexpr auto defaultPanelSkinMenuItem = 0x470d; constexpr auto tabbedLayoutMenuItem = 0x470e; +constexpr auto voiceAllocationFixMenuItem = 0x470f; // The tabbed layout reuses the original artwork and wiring unchanged: each tab // is a list of full-SVG rectangles translated into a smaller view. The screen @@ -818,6 +819,8 @@ juce::PopupMenu WaveEmulationAudioProcessorEditor::getMenuForIndex( return menu; menu.addItem(loadFirmwareMenuItem, "Load System Firmware Folder..."); + menu.addItem(voiceAllocationFixMenuItem, "OS 1.700 Voice Allocation Fix (no stolen held notes)", true, + ownerProcessor.getVoiceAllocationFix()); menu.addSeparator(); const auto mounted = ownerProcessor.hasMountedDiskImage(); menu.addItem(createBlankDiskMenuItem, "New Blank 720 KB DD Disk Image..."); @@ -903,6 +906,12 @@ void WaveEmulationAudioProcessorEditor::menuItemSelected(int menuItemId, int) showFirmwareFolderChooser(); return; } + if (menuItemId == voiceAllocationFixMenuItem) + { + ownerProcessor.setVoiceAllocationFix(!ownerProcessor.getVoiceAllocationFix()); + menuItemsChanged(); + return; + } if (menuItemId == createBlankDiskMenuItem) { showCreateBlankDiskChooser(); diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index a7e9619..54a36d9 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -797,6 +797,18 @@ juce::Result WaveEmulationAudioProcessor::saveMountedDiskImageAs( return mounted; } +void WaveEmulationAudioProcessor::setVoiceAllocationFix(bool enabled) +{ + const juce::ScopedLock callbackLock(getCallbackLock()); + masterFirmware.setVoiceAllocationFix(enabled); + parameters.state.setProperty("voiceAllocationFix", enabled, nullptr); +} + +bool WaveEmulationAudioProcessor::getVoiceAllocationFix() const +{ + return static_cast(parameters.state.getProperty("voiceAllocationFix", false)); +} + juce::Result WaveEmulationAudioProcessor::ejectDiskImage() { const auto result = masterFirmware.ejectDiskImage(); @@ -5038,6 +5050,7 @@ void WaveEmulationAudioProcessor::setStateInformation(const void* data, int size : 0; currentProgram.store(restoredProgram, std::memory_order_release); performanceFadersTouched.store(0, std::memory_order_release); + masterFirmware.setVoiceAllocationFix(getVoiceAllocationFix()); // applied by the loadAndStart below const auto savedDirectory = parameters.state.getProperty("firmwareDirectory").toString(); if (savedDirectory.isNotEmpty()) { diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h index 31cbb82..17bb3f9 100644 --- a/Source/PluginProcessor.h +++ b/Source/PluginProcessor.h @@ -94,6 +94,9 @@ class WaveEmulationAudioProcessor final : public juce::AudioProcessor, juce::Result flushMountedDiskImage(); juce::Result saveMountedDiskImageAs(const juce::File& destination); juce::Result ejectDiskImage(); + // OS 1.700 voice-allocation fix (MasterFirmwareRuntime::setVoiceAllocationFix); off by default, saved with the plugin state. + void setVoiceAllocationFix(bool enabled); + [[nodiscard]] bool getVoiceAllocationFix() const; void resetToColdStart(); [[nodiscard]] bool hasMountedDiskImage() const noexcept { diff --git a/Tests/WaveCoreTests.cpp b/Tests/WaveCoreTests.cpp index 1b17b51..2f95221 100644 --- a/Tests/WaveCoreTests.cpp +++ b/Tests/WaveCoreTests.cpp @@ -2298,6 +2298,10 @@ void testOfficialFirmwareWhenAvailable() require(masterRuntime.completedColdHardwareSetup(), "Master firmware did not perform its observed cold-start hardware writes"); + require(masterRuntime.setVoiceAllocationFix(true) && masterRuntime.localByte(0x2ee2u) == 0x60, + "Voice allocation fix did not patch find_free_voice"); + require(!masterRuntime.setVoiceAllocationFix(false) && masterRuntime.localByte(0x2ee2u) == 0x65, + "Voice allocation fix was not reverted"); masterRuntime.runCycles(2000000); require(masterRuntime.runOs1700InitialisationFileLoad(), "Master OS did not read synthetic INIT.SND and INIT.PFM through its file loader"); From 7857e34822d8792a27a8e20b2163982d5c7ad483 Mon Sep 17 00:00:00 2001 From: BD <46272996+xbraindance@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:42:33 +0200 Subject: [PATCH 09/11] Log the output routing latch and let WaveVoiceTrace select the board WDV writes the write-only routing latch (0x8C0000, 8 bytes per voice) but the voice board neither logged nor decoded it, so the emulator-vs-oracle diff could not cover it. VoiceFirmwareRuntime now queues those writes with the ASIC/CV writes (the engine still ignores them) through one queueHardwareWrite helper. WaveVoiceTrace takes an optional board argument (0-2) so the gate can run all three voice boards. The research note records the routing latch layout, the resolved board limit (3) and three emulator assumptions that disagree with the firmware map; none of those are changed here. Verified: ctest passes (4/4). gate_e_wdv_note_trace.py runs boards 0-2 against the Musashi oracle: 35,004 checks, 0 failures. Both sides use the same 68000 core, so this checks bus decode and plumbing, not the ROM or the hardware. Co-Authored-By: Claude Sonnet 5.5 --- Source/Firmware/VoiceFirmwareRuntime.cpp | 29 ++++---- Source/Firmware/VoiceFirmwareRuntime.h | 5 ++ Tests/WaveCoreTests.cpp | 77 ++++++++++++++++++++ Tests/WaveVoiceTrace.cpp | 26 ++++--- waldorf-wave-firmware-reverse-engineering.md | 13 +++- 5 files changed, 122 insertions(+), 28 deletions(-) diff --git a/Source/Firmware/VoiceFirmwareRuntime.cpp b/Source/Firmware/VoiceFirmwareRuntime.cpp index d2e370f..a450dfd 100644 --- a/Source/Firmware/VoiceFirmwareRuntime.cpp +++ b/Source/Firmware/VoiceFirmwareRuntime.cpp @@ -226,30 +226,29 @@ void VoiceFirmwareRuntime::write8(uint32_t address, uint8_t value) noexcept if (address >= asicBase && address < asicBase + asicRegisters.size()) { asicRegisters[address - asicBase] = value; - if (hardwareWrites.size() < 65536) - hardwareWrites.push_back( - { emulatedCycles - + static_cast(juce::jmax(0, cpu.currentExecutionCycleOffset())), - address, value }); + queueHardwareWrite(address, value); } if (address >= cvBankABase && address < cvBankABase + cvWindowSize) { cvRegisters[0][address - cvBankABase] = value; - if (hardwareWrites.size() < 65536) - hardwareWrites.push_back( - { emulatedCycles - + static_cast(juce::jmax(0, cpu.currentExecutionCycleOffset())), - address, value }); + queueHardwareWrite(address, value); } if (address >= cvBankBBase && address < cvBankBBase + cvWindowSize) { cvRegisters[1][address - cvBankBBase] = value; - if (hardwareWrites.size() < 65536) - hardwareWrites.push_back( - { emulatedCycles - + static_cast(juce::jmax(0, cpu.currentExecutionCycleOffset())), - address, value }); + queueHardwareWrite(address, value); } + if (address >= routingLatchBase && address < routingLatchBase + routingLatchWindowSize) + queueHardwareWrite(address, value); +} + +void VoiceFirmwareRuntime::queueHardwareWrite(uint32_t address, uint8_t value) noexcept +{ + if (hardwareWrites.size() < 65536) + hardwareWrites.push_back( + { emulatedCycles + + static_cast(juce::jmax(0, cpu.currentExecutionCycleOffset())), + address, value }); } uint32_t VoiceFirmwareRuntime::bigEndian32(const uint8_t* bytes) noexcept diff --git a/Source/Firmware/VoiceFirmwareRuntime.h b/Source/Firmware/VoiceFirmwareRuntime.h index 861f405..7ca5570 100644 --- a/Source/Firmware/VoiceFirmwareRuntime.h +++ b/Source/Firmware/VoiceFirmwareRuntime.h @@ -70,6 +70,10 @@ class VoiceFirmwareRuntime final : private M68000Bus static constexpr uint32_t cvBankBBase = 0x8a0000; static constexpr uint32_t cvWindowSize = 0x10000; static constexpr uint32_t boardControlAddress = 0x800001; + // Write-only output routing latch: WDV 0xCBA writes 8 bytes (4 words) + // here per voice. Queued with the ASIC/CV writes; the engine ignores it. + static constexpr uint32_t routingLatchBase = 0x8c0000; + static constexpr uint32_t routingLatchWindowSize = 0x10000; // Firmware-visible black-box register window. The name describes the // physical target only; register storage does not model chip internals. static constexpr uint32_t asicBase = 0x980000; @@ -79,6 +83,7 @@ class VoiceFirmwareRuntime final : private M68000Bus private: [[nodiscard]] uint8_t read8(uint32_t address) noexcept override; void write8(uint32_t address, uint8_t value) noexcept override; + void queueHardwareWrite(uint32_t address, uint8_t value) noexcept; [[nodiscard]] static uint32_t bigEndian32(const uint8_t* bytes) noexcept; M68000 cpu; diff --git a/Tests/WaveCoreTests.cpp b/Tests/WaveCoreTests.cpp index 2f95221..ec394bb 100644 --- a/Tests/WaveCoreTests.cpp +++ b/Tests/WaveCoreTests.cpp @@ -1156,6 +1156,35 @@ void testAllVoiceFiltersAreCalibrated() "Fixed reconstruction filters retain an undocumented voice spread"); } +void testWvcInputNetworkResponse() +{ + // Expected gains (dB) from scripts/derive_wvc_input_network.py: the WVC + // input network of the service manual schematic, p.47. + struct Point { double hz; double expectedDb; }; + for (const auto& point : { Point { 1000.0, 0.26 }, Point { 8000.0, 3.66 }, + Point { 20000.0, -1.93 }, Point { 30000.0, -8.05 } }) + { + wave::dsp::ReconstructionStage stage; + stage.prepare(250000.0, 0.0f); + stage.setAge(0.0f); + double energy = 0.0; + constexpr auto total = 100000; + for (int sample = 0; sample < total; ++sample) + { + const auto input = 0.1f * std::sin(static_cast( + juce::MathConstants::twoPi * point.hz + * static_cast(sample) / 250000.0)); + const auto output = stage.process(input); + if (sample >= total / 2) + energy += static_cast(output) * output; + } + const auto gainDb = 20.0 * std::log10( + std::sqrt(energy / (total / 2)) / (0.1 / std::sqrt(2.0))); + require(std::abs(gainDb - point.expectedDb) < 0.6, + "Reconstruction stage departs from the WVC input network schematic"); + } +} + void testCemControlVoltageSettling() { wave::dsp::Cem3387 circuit; @@ -2415,6 +2444,10 @@ void testOfficialFirmwareWhenAvailable() < wave::firmware::VoiceFirmwareRuntime::asicBase + 0x200u; }), "Firmware-driven note did not reach either oscillator-chip register page"); + require(std::any_of(midiWrites.begin(), midiWrites.end(), [](const auto& write) { + return write.address == wave::firmware::VoiceFirmwareRuntime::routingLatchBase; + }), + "Firmware-driven note did not refresh the output routing latch"); require(masterRuntime.unmappedReadCount() == 0 && voiceRuntime.unmappedReadCount() == 0, "Firmware-driven MIDI note reached an unmodelled system-bus address"); } @@ -2472,6 +2505,48 @@ void testVoiceBoardWaveRamDecode() "Voice-board wave RAM stores leaked into master DRAM"); } +void testVoiceBoardRoutingLatchAndBoardLimit() +{ + // The one-cold slot strap (WDV 0x466 scan, w2sys 0xB80E mask) names boards + // 0-2 only, so the three cards the engine renders are the whole machine. + static_assert(wave::firmware::VoiceFirmwareRuntime::maximumBoardCount + == wave::dsp::WaldorfEngine::voiceBoardCount); + wave::firmware::VoiceFirmwareRuntime strapProbe; + require(strapProbe.setBoardIndex(0) && strapProbe.setBoardIndex(2), + "Board slots 0-2 were rejected"); + require(!strapProbe.setBoardIndex(3) && !strapProbe.setBoardIndex(-1), + "A fourth voice board slot was accepted although the strap names only three"); + + // Hand-built WDV image: one long store to the write-only routing latch, + // as WDV 0xCBA does (MOVE.L d0,$8C0000), then a spin. + static constexpr uint8_t code[] = { + 0x23, 0xfc, 0xdb, 0x6d, 0x8b, 0x6d, 0x00, 0x8c, 0x00, 0x00, // move.l #$db6d8b6d,$8c0000 + 0x60, 0xfe // bra.s * + }; + juce::MemoryBlock image(0x20 + 0x400 + sizeof(code), true); + auto* bytes = static_cast(image.getData()); + bytes[0x20 + 2] = 0x8f; + bytes[0x20 + 3] = 0xfe; + bytes[0x20 + 6] = 0x04; + std::copy(std::begin(code), std::end(code), bytes + 0x20 + 0x400); + + wave::firmware::SharedFirmwareMemory memory; + memory.clear(); + wave::firmware::VoiceFirmwareRuntime runtime; + runtime.attachSharedMemory(memory); + require(runtime.loadAndReset(image), "Synthetic WDV image was rejected"); + runtime.runCycles(2000); + const auto& writes = runtime.pendingHardwareWrites(); + constexpr std::array expected { 0xdb, 0x6d, 0x8b, 0x6d }; + require(writes.size() == expected.size(), + "Routing latch long store was not logged as four byte writes"); + for (size_t i = 0; i < expected.size(); ++i) + require(writes[i].address == wave::firmware::VoiceFirmwareRuntime::routingLatchBase + i + && writes[i].value == expected[i], + "Routing latch byte write has the wrong address or value"); + require(runtime.unmappedReadCount() == 0, "Routing latch store caused an unmapped read"); +} + class Test68000Bus final : public wave::firmware::M68000Bus { public: @@ -2841,6 +2916,7 @@ int main() testCemFilterResponse(); testMeasuredWaveResonancePassbandLoss(); testAllVoiceFiltersAreCalibrated(); + testWvcInputNetworkResponse(); testCemControlVoltageSettling(); testLiveCutoffUsesContinuousBaseControlVoltage(); testAsicHighpassResponse(); @@ -2873,6 +2949,7 @@ int main() testCompletePanelWiringContract(); testDecodedVoiceBoardProtocol(); testVoiceBoardWaveRamDecode(); + testVoiceBoardRoutingLatchAndBoardLimit(); testFirmwareBundleRejectsMissingPath(); testOfficialFirmwareWhenAvailable(); std::cout << "WaveCoreTests: all checks passed\n"; diff --git a/Tests/WaveVoiceTrace.cpp b/Tests/WaveVoiceTrace.cpp index 676efca..fcd77ad 100644 --- a/Tests/WaveVoiceTrace.cpp +++ b/Tests/WaveVoiceTrace.cpp @@ -1,11 +1,12 @@ // Runs the real WDV.SYS in VoiceFirmwareRuntime from a command script and prints -// the logged ASIC/CV write stream ("W "). The oracle gate -// gate_e_wdv_note_trace.py (Waldorf Wave firmware repo) runs the same script on -// the Musashi oracle and diffs the two streams. +// the logged ASIC/CV/routing-latch write stream ("W "). +// The oracle gate gate_e_wdv_note_trace.py (Waldorf Wave firmware repo) runs the +// same script on the Musashi oracle and diffs the two streams. // // Script commands: poke (shared RAM 0x100000..0x1fffff), // until_pc (single-step), scan (wait for one full voice scan), -// run , mark . +// run , mark . An optional third argument selects the board +// (0-2, default 0; the board's alive flag is 0x10508e + board). #include "Firmware/VoiceFirmwareRuntime.h" #include @@ -18,7 +19,7 @@ using namespace wave::firmware; namespace { -constexpr uint32_t loopAliveFlag = 0x10508e; // st.b after every 0x79c call, board 0 +constexpr uint32_t loopAliveFlag = 0x10508e; // st.b after every 0x79c call, + board id constexpr int scanSliceCycles = 200; constexpr long stepLimit = 50'000'000; @@ -36,11 +37,12 @@ uint8_t* sharedAt(SharedFirmwareMemory& memory, uint32_t address) int main(int argc, char** argv) { - if (argc != 3) + if (argc != 3 && argc != 4) { - std::fprintf(stderr, "usage: WaveVoiceTrace wdv.sys script\n"); + std::fprintf(stderr, "usage: WaveVoiceTrace wdv.sys script [board]\n"); return 2; } + const auto board = argc == 4 ? std::atoi(argv[3]) : 0; juce::MemoryBlock image; if (!juce::File(argv[1]).loadFileAsData(image)) { @@ -52,6 +54,11 @@ int main(int argc, char** argv) memory.clear(); VoiceFirmwareRuntime runtime; runtime.attachSharedMemory(memory); + if (!runtime.setBoardIndex(board)) + { + std::fprintf(stderr, "board %d is not a valid strap slot\n", board); + return 2; + } if (!runtime.loadAndReset(image)) { std::fprintf(stderr, "WDV image rejected\n"); @@ -101,9 +108,10 @@ int main(int argc, char** argv) } else if (command == "scan") { - *sharedAt(memory, loopAliveFlag) = 0; + const auto alive = loopAliveFlag + static_cast(board); + *sharedAt(memory, alive) = 0; long slices = 0; - while (runtime.sharedByte(loopAliveFlag - 0x100000) == 0) + while (runtime.sharedByte(alive - 0x100000) == 0) { runtime.runCycles(scanSliceCycles); if (++slices > stepLimit) diff --git a/waldorf-wave-firmware-reverse-engineering.md b/waldorf-wave-firmware-reverse-engineering.md index 192492d..f2cde7a 100644 --- a/waldorf-wave-firmware-reverse-engineering.md +++ b/waldorf-wave-firmware-reverse-engineering.md @@ -91,7 +91,7 @@ Key facts: | 0x800001 | board control/status byte (bit 1 bus claim, bit 3 wave-RAM write, slot strap in bits 0-2 on read) | | 0x880000 | CV DAC data latch (12-bit values) | | 0x8A0000 + 16*voice + channel | sample-and-hold strobe | -| 0x8C0000 | output routing latch | +| 0x8C0000 | output routing latch (write-only; 64-bit image of one cold bit per voice, from part byte +0xC "Audio Out", plus four "Analog in" bits for voices 0-3; the emulator logs the writes) | | 0x980000 / 0x980100 | ASIC A (voices 0-7) / ASIC B (voices 8-15) | ## 3. The master-to-voice-board protocol @@ -133,9 +133,14 @@ Behaviours that the real ROM exhibits under the 68000 core and that the gates no ## 7. What is still open - **The reported OS 1.700 voice-allocation bug** (missing notes, fixed in 1.8x) is **not localised.** The voice-board image is identical to 1.680, so the cause is on the master. Candidate areas are the note-on allocation code (0x26C4-0x3162) and the key scheduler (0x35B8). This needs a diff of the 1.680 and 1.700 `w2sys.bin` plus oracle runs with the same polyphonic MIDI sequence. -- **ASIC semantics:** the roles of the ASIC registers (0x980000 + 0/2/4/6/0xE, and 0x12), the CV channel meanings (cutoff, resonance and pan are guesses), the routing latch bits, and how the key-on wave-number write selects a bank. +- **ASIC semantics:** the *write patterns* are decoded (`spec/firmware-map.md` §4): reg 6 carries the 6-bit wave position every scan, reg 4 three bits each of Wave 1/Wave 2/Noise volume, reg 0xE commits with bit 0 = oscillator, bits 1-3 = voice, bit 4 = parameters/phase load and bits 5-7 = wave RAM bank (one of 8 resident wavetables), and the once-per-key-on write through regs 2/0/4 is the 7-bit **start phase** ("Startphase", 0 = free start, nothing written), not a wave number. What the chips do with them, the CV channel meanings (cutoff, resonance and pan are guesses) and the physical meaning of the routing bits (main / sub 1 / sub 2 by UI label only) need hardware. - **Hardware details:** VIA port bits beyond those listed, the panel boot-switch bits, LED strobe rows (7 vs 8), the physical meaning of the analogue channels. -- **Maximum board count:** the strap allows 3, diagnostics accept up to 4, and some tables hold 64 entries. +- **Maximum board count: resolved, 3.** The presence mask and the WDV slot strap are 3 bits wide; the "up to 4" and 64-entry tables are sizing slack (`spec/firmware-map.md` §7.2). `gate_e` runs boards 0, 1 and 2. +- **Emulator assumptions that disagree with the map (found 2026-10-02, not yet changed, hardware-unvalidated):** + 1. *Audio Out byte.* The firmware's instrument page 1 table (0x14BCA = `04 05 07 0C 09 0A 02 03`) puts "Audio Out" at part byte **+0xC**, and WDV 0xBC6 routes from that byte; the plugin reads and edits **+8** (`PluginProcessor.cpp` `pageOneRecordOffsets`, `byte(8)`), a byte nothing consumes and which is 0 in every factory set. +0xC is 1 in every factory set (enum labels at 0x29D24: Aux only / main / sub 1 / sub 2), so the plugin's `audioOutput == 0` test is right only by accident. A fix has to read +0xC and map 1 -> main; sets with +0xC = 0 (three parts in `pg.set`/`ultimate.set`) would then leave the main mix. + 2. *Start modulation.* The firmware adds the route in sound bytes 28/29 (44/45) to **Startphase** (byte 27/43, written once at key-on, 0 = free start) and clamps it to 1-127; `WaldorfEngine` applies `wave1StartMod`/`wave2StartMod` as a wave-position offset sampled at note-on instead. The engine's Startphase itself (`wavePhases`, 0 = free start) matches the write pattern. + 3. *Level resolution.* reg 4 carries only 3 bits per level (value 0-0x70 >> 4); the engine mixes 7-bit level codes (0-112) from the ES2 description. Whether the chip has another level path is open. + Single wavetable per voice (one bank slot for both oscillators), 64 waves of 64 stored samples per table, 8 resident tables per board and 128 table ids (64 factory + 64 user) all match `WavetableBank` and `WaldorfEngine`; the mirrored second half of each wave is an inference that no write shows. - **Static only:** the UI pages, the Wave Editor, the LFO, env3/env4 and glide stages on the board, and anything timing-dependent. ## 8. What this means for sample-accurate emulation @@ -146,7 +151,7 @@ Practical next steps: - [ ] Check the emulator's constants and behaviours against `spec/firmware-map.md` §3-§5 (starting with the waveform-RAM address). - [ ] Diff OS 1.668, 1.671, 1.680 and 1.700 `w2sys.bin` and localise the voice-allocation change. -- [x] Capture ASIC/CV write traces for one note from the oracle and compare them with the emulator's (2026-10-02, `oracle/gate_e_wdv_note_trace.py` + `Tests/WaveVoiceTrace.cpp`). The same script (boot, seeded voice records, an idle scan, key-on of voices 3 and 9, three more scans) runs on the Musashi oracle and on `VoiceFirmwareRuntime`; all 11,146 logged writes (CV latch, CV strobes, ASIC, incl. tick-IRQ acknowledges) match in order, value and cycle stamp. Both sides use the same 68000 core, so this checks the emulator's bus decode, shared RAM and IRQ plumbing, not the ROM. Findings: `WaldorfAsic.cpp` never sees these writes (it is a behavioural proxy driven by the engine); `WaldorfEngine::applyFirmwareHardwareWrite` only shadows the bytes and nothing reads the shadow yet; the emulator does not log or decode the routing latch (0x8C0000, 128 byte-writes per scan) or the board-control latch (0x800001). +- [x] Capture ASIC/CV write traces for one note from the oracle and compare them with the emulator's (2026-10-02, `oracle/gate_e_wdv_note_trace.py` + `Tests/WaveVoiceTrace.cpp`). The same script (boot, seeded voice records, an idle scan, key-on of voices 3 and 9, three more scans) runs on the Musashi oracle and on `VoiceFirmwareRuntime`; all 11,146 logged writes (CV latch, CV strobes, ASIC, incl. tick-IRQ acknowledges) match in order, value and cycle stamp. Both sides use the same 68000 core, so this checks the emulator's bus decode, shared RAM and IRQ plumbing, not the ROM. Findings: `WaldorfAsic.cpp` never sees these writes (it is a behavioural proxy driven by the engine); `WaldorfEngine::applyFirmwareHardwareWrite` only shadows the bytes and nothing reads the shadow yet; at that time the emulator did not log the routing latch (0x8C0000, 128 byte-writes per scan) or the board-control latch (0x800001). Since then the routing latch is logged and diffed too, and the gate runs on all three boards (35,004 checks, 0 failures). - [ ] Plan the hardware captures (bus, ASIC select, DAC timing, calibrated audio) needed to pin the ASIC and analogue model. ## 9. Legal and practical caveats From b2a0ff43ecc279f615adda002ea16adb6e6f29cb Mon Sep 17 00:00:00 2001 From: BD <46272996+xbraindance@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:42:33 +0200 Subject: [PATCH 10/11] Ignore the local service manual and header image Both are local-only files that must not be committed. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 7348bd6..ed98575 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ /Sounds.zip /System.zip /Tools.zip +/Waldorf-WAVE-Service-Manual.pdf +/wave_header_3840x1440px.png From 707ddc76775e8909bba9a6b5e607b97c1b392405 Mon Sep 17 00:00:00 2001 From: BD <46272996+xbraindance@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:09:57 +0200 Subject: [PATCH 11/11] Add opt-in 3-bit oscillator level model The WDV image sends each oscillator level to the ASIC as three bits (ws level >> 4, written at 0x9B8/0xA58) and never to a CV channel. The engine's 7-bit level path is finer than that firmware contract. WaldorfEngine::setQuantiseWaveLevels keeps only those three bits, scaled back to 0..0x70. Off by default, no UI. The scaling is a hypothesis about the ASIC gain law; README's accuracy boundary says so and asks for an eight-step hardware level capture. Verified: ctest passes (4/4); a synthetic test covers the level mask. Not verified audibly or against hardware. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 1 + Source/Dsp/WaldorfAsic.h | 8 ++++++++ Source/Dsp/WaldorfEngine.cpp | 9 +++++++-- Source/Dsp/WaldorfEngine.h | 8 ++++++++ Tests/WaveCoreTests.cpp | 7 +++++++ 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1f09961..50231de 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,7 @@ This sample is honest about a hard distinction: - The current bridge supplies `INIT.SND` and `INIT.PFM` through a narrowly scoped virtual system-disk service, and the genuine OS 1.700 loader opens, reads, and closes both files. Until original factory files are available, their contents are byte-exact copies of the genuine safe sound and performance records embedded in OS 1.700; they are conservative defaults, not claimed factory banks. The same records are preloaded as a defensive fallback. The CPU board's DP8473 floppy controller is modelled for mounted raw MS-DOS disk images; the separate boot-time `WDV.SYS` copy remains bypassed because the authenticated WDV body is installed directly into shared SRAM before the genuine master instructions resume at the verified post-copy handoff. This is not presented as a complete main-OS boot. - The CPU-board LCD is represented as two byte-wide 5563 SRAMs forming a 16-bit word, four 4 KiB pages, two parallel 74LS166 shifters, and the firmware page latch at `$BE0001`. The genuine OS renderer proves a 64-byte hardware scanline: 60 visible bytes for 480 pixels followed by four blanking bytes. Writes in the `$FEA00000-$FEA03FFF` video window automatically replace the model diagnostics with correctly decoded firmware scanout. - The oscillator ASIC is an undocumented black box. Its internal algorithms cannot be recovered or claimed from the available documentation. The DSP is explicitly an external behavioural proxy, using Waldorf's documented 250 kHz rate and ES2 eight-bit numerical mix-overflow boundary; its exact accumulator ordering and truncation remain to be validated against real-hardware measurements. Firmware register traces reveal only the external contract. +- Static trace of the WDV image (OS 1.700): each oscillator level reaches the ASIC as three bits (`ws level >> 4`, writer at `$9B8`/`$A58`), never a CV channel. The 7-bit level path in `WaldorfEngine` is therefore finer than the firmware contract. `WaldorfEngine::setQuantiseWaveLevels` (off by default, no UI) keeps only those three bits, scaled back to 0..`$70`; that scaling is a hypothesis about the ASIC gain law and needs an eight-step real-hardware level capture. - The included procedural wavetable bank is a neutral fallback. The loader accepts a user-supplied exact 64 or 128 x 64 x 128 signed-eight-bit dump, a 32 x 64 x 128 first-table dump, or a raw PPG Wave 2.2/2.3 EPROM image. Raw PPG images are decoded from their 768-byte sparse table directory and 256 stored 64-sample half waves. The V6 firmware's recursive midpoint averaging, complemented half-cycle, calculated tables 28/29, and byte-exact triangle/pulse/square/saw tail are reproduced, yielding all 30 original lower tables. Native Wave SET images also install all 64 user Wavetables and their 1000-Wave pool. The lower 30 PPG tables are included as decoded samples. Remaining tables retain procedural fallback data until a user imports a bank. - The filter path implements all four sound-record modes: analogue 24 dB low-pass, digital 12 dB high-pass, linked serial band-pass with width, and Dual mode with independently modulated HP/LP cutoffs. The WDV frequency table establishes semitone-spaced cutoff values beginning at 20 Hz. The fixed CEM3387 three-pole 1 dB Chebyshev reconstruction section and nonlinear resonant four-pole low-pass are separate from the pre-analogue ASIC high-pass. Envelope selection, velocity, key tracking, both modulation routes, resonance modulation, and maximum-resonance self-oscillation are active. Unknown ASIC arithmetic and unmeasured component tolerances remain explicit calibration hypotheses. - The mixed-signal path explicitly models eight-bit multiplexed conversion, signed ES2 mixer wrap, sample-and-hold behaviour, three-pole reconstruction and AC coupling, the distinct VCF input saturation around 70% mixer output, AD7545-style 12-bit CV quantisation, nonlinear four-pole CEM3387 filtering, VCA bleed/noise, panning, output coupling, slew, and rails. Values without measurements remain documented calibration hypotheses. diff --git a/Source/Dsp/WaldorfAsic.h b/Source/Dsp/WaldorfAsic.h index 731633c..6a2657e 100644 --- a/Source/Dsp/WaldorfAsic.h +++ b/Source/Dsp/WaldorfAsic.h @@ -119,6 +119,14 @@ class AsicOutputMixer int8_t oscillator2, uint8_t level1, uint8_t level2) noexcept; + // The WDV firmware sends each oscillator level to the ASIC as only three + // bits (ws level >> 4, OS 1.700 WDV 0x9B8/0xA58). Scaling that code back to + // the 0..0x70 level range is a hypothesis about the ASIC's gain law, not a + // documented fact. + [[nodiscard]] static uint8_t levelFromRegisterBits(uint8_t level) noexcept + { + return static_cast(level & 0x70u); + } [[nodiscard]] static int8_t quantiseOscillator(float sample) noexcept; [[nodiscard]] static float normaliseOutput(int8_t sample) noexcept; }; diff --git a/Source/Dsp/WaldorfEngine.cpp b/Source/Dsp/WaldorfEngine.cpp index 9a391cd..87e6754 100644 --- a/Source/Dsp/WaldorfEngine.cpp +++ b/Source/Dsp/WaldorfEngine.cpp @@ -1089,10 +1089,15 @@ Cem3387::StereoSample WaldorfEngine::Voice::process( } reconstruction.setAge(parameters.circuitAgeAmount); - const auto levelCode1 = static_cast(juce::jlimit( + auto levelCode1 = static_cast(juce::jlimit( 0, 127, juce::roundToInt(controlWaveLevels[0] * 112.0f))); - const auto levelCode2 = static_cast(juce::jlimit( + auto levelCode2 = static_cast(juce::jlimit( 0, 127, juce::roundToInt(controlWaveLevels[1] * 112.0f))); + if (quantiseWaveLevels) + { + levelCode1 = AsicOutputMixer::levelFromRegisterBits(levelCode1); + levelCode2 = AsicOutputMixer::levelFromRegisterBits(levelCode2); + } asicClockPhase += OscillatorChipProxy::modelClockRate() / sampleRate; while (asicClockPhase >= 1.0) { diff --git a/Source/Dsp/WaldorfEngine.h b/Source/Dsp/WaldorfEngine.h index 1945955..62ce2bf 100644 --- a/Source/Dsp/WaldorfEngine.h +++ b/Source/Dsp/WaldorfEngine.h @@ -147,6 +147,12 @@ class WaldorfEngine [[nodiscard]] float firstActiveVcaControlValue() const noexcept; [[nodiscard]] uint16_t filterCalibrationCode(int voice) const noexcept; void setFilterCalibrationCode(int voice, uint16_t code) noexcept; + // Not synchronised: call with the callback lock held (or before audio starts). + void setQuantiseWaveLevels(bool enabled) noexcept + { + for (auto& voice : voices) + voice.quantiseWaveLevels = enabled; + } void setVoiceCardThreadingEnabled(bool enabled) noexcept { voiceCardThreadingEnabled.store(enabled, std::memory_order_relaxed); @@ -192,6 +198,8 @@ class WaldorfEngine { circuit.setCutoffCalibrationCode(code); } + // Off by default; see AsicOutputMixer::levelFromRegisterBits. + bool quantiseWaveLevels = false; [[nodiscard]] float sourceValue(int source, float ampEnvelope, float waveEnvelopeValue, diff --git a/Tests/WaveCoreTests.cpp b/Tests/WaveCoreTests.cpp index ec394bb..abfbd60 100644 --- a/Tests/WaveCoreTests.cpp +++ b/Tests/WaveCoreTests.cpp @@ -319,6 +319,13 @@ void testAsicClockMixOverflowAndVcfSaturation() require(Mixer::mixOscillatorCodes(-128, -128, 64, 65) == 127, "Negative ES2 numerical overflow clips instead of wrapping positive"); + // Firmware sends levels as ws>>4 (3 bits): the opt-in model keeps only those. + require(Mixer::levelFromRegisterBits(0x70) == 0x70 + && Mixer::levelFromRegisterBits(127) == 0x70 + && Mixer::levelFromRegisterBits(0x6f) == 0x60 + && Mixer::levelFromRegisterBits(0x0f) == 0, + "Three-bit ASIC level code no longer keeps only ws bits 4-6"); + const auto small = wave::dsp::Cem3387::saturateVcfInput(0.1f); const auto knee = wave::dsp::Cem3387::saturateVcfInput(0.7f); const auto full = wave::dsp::Cem3387::saturateVcfInput(1.0f);