From 29feffa250eaabfd6f9e7d1b7b485b7977d5185f 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 1/8] 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 e5d2fd3..3d3dd69 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,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 bb92d62..f9912ea 100644 --- a/Source/Firmware/MasterFirmwareRuntime.cpp +++ b/Source/Firmware/MasterFirmwareRuntime.cpp @@ -1177,9 +1177,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); @@ -1265,12 +1262,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 2ef2097..cdbbf69 100644 --- a/Source/Firmware/MasterFirmwareRuntime.h +++ b/Source/Firmware/MasterFirmwareRuntime.h @@ -179,7 +179,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 857fd3c..83deee8 100644 --- a/Tests/WaveCoreTests.cpp +++ b/Tests/WaveCoreTests.cpp @@ -3302,6 +3302,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: @@ -3711,6 +3745,7 @@ int main() testLcdFramebuffer(); testCompletePanelWiringContract(); testDecodedVoiceBoardProtocol(); + testVoiceBoardWaveRamDecode(); testOfficialFirmwareWhenAvailable(); std::cout << "WaveCoreTests: all checks passed\n"; return 0; From 0394db4af5d4e46c7af2a1846d4686746809ad1e 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 2/8] 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 b06a2c5..ae5c369 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 8281e49b5195a2241c66f675bdcd52390162f80d 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 3/8] 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 83deee8..601774b 100644 --- a/Tests/WaveCoreTests.cpp +++ b/Tests/WaveCoreTests.cpp @@ -3079,6 +3079,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"); @@ -3746,6 +3755,7 @@ int main() testCompletePanelWiringContract(); testDecodedVoiceBoardProtocol(); testVoiceBoardWaveRamDecode(); + testFirmwareBundleRejectsMissingPath(); testOfficialFirmwareWhenAvailable(); std::cout << "WaveCoreTests: all checks passed\n"; return 0; From 826d1663f03f838497994e5ffcc92ba38c02570f 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 4/8] 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 d17559f96afa6f3f1840f5782dbd75d7e660d9e7 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 5/8] 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 1fbe186..ac6b701 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -88,3 +88,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 694bcf34977d0317edb562f100aea584f15ba29d 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 6/8] 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 | 47 ++++++++++++++++++++ Tests/WaveVoiceTrace.cpp | 26 +++++++---- waldorf-wave-firmware-reverse-engineering.md | 13 ++++-- 5 files changed, 92 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 601774b..c01961b 100644 --- a/Tests/WaveCoreTests.cpp +++ b/Tests/WaveCoreTests.cpp @@ -3252,6 +3252,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"); @@ -3345,6 +3349,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: @@ -3755,6 +3801,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 473e1b2739edf746114eed5d132d2196d6d2a397 Mon Sep 17 00:00:00 2001 From: BD <46272996+xbraindance@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:52:05 +0200 Subject: [PATCH 7/8] Fix the Audio Out byte: use instrument part byte +0xC The plugin edited Instrument part byte +8 as Audio Out, a byte nothing consumes. The firmware page table 0x14BCA and WDV 0xBC6 use +0xC (0 Aux only, 1 main, 2 sub 1, 3 sub 2). The engine now keeps the firmware value and only main reaches the plugin output. Verified: WaveCoreTests incl. the new Audio Out routing test. WaveEditorTests needs the private firmware assets and was not run; its Audio Out fader check now expects +12. Co-Authored-By: Claude Sonnet 5.5 --- Source/Dsp/WaldorfEngine.cpp | 5 ++-- Source/Dsp/WaldorfEngine.h | 5 +++- Source/PluginProcessor.cpp | 6 ++--- Tests/WaveCoreTests.cpp | 28 ++++++++++++++++++++ Tests/WaveEditorTests.cpp | 3 ++- waldorf-wave-firmware-reverse-engineering.md | 4 +-- 6 files changed, 42 insertions(+), 9 deletions(-) diff --git a/Source/Dsp/WaldorfEngine.cpp b/Source/Dsp/WaldorfEngine.cpp index ae5c369..b249d1f 100644 --- a/Source/Dsp/WaldorfEngine.cpp +++ b/Source/Dsp/WaldorfEngine.cpp @@ -1652,7 +1652,8 @@ void WaldorfEngine::renderRangeChunk(juce::AudioBuffer& output, continue; } const auto& layer = performance.layers[static_cast(voice.layerIndex)]; - const auto audible = layer.enabled && !layer.muted && layer.audioOutput == 0 + const auto audible = layer.enabled && !layer.muted + && layer.audioOutput == PerformanceLayer::mainAudioOut && (!hasSoloedInstrument || layer.soloed); voice.currentFreeWheel = juce::jlimit(-1.0f, 1.0f, @@ -1985,7 +1986,7 @@ WaldorfEngine::VoiceProbe WaldorfEngine::probeVoice( voice.start(midiNote, 1, velocity, order, order, layerIndex, layer, tunedNote, tunedNote, 0.0f, false, false, 0.0f, 0, 0.0f, 0.0f, 0.0f); - const auto performanceGain = layer.audioOutput == 0 ? layer.gain : 0.0f; + const auto performanceGain = layer.audioOutput == PerformanceLayer::mainAudioOut ? layer.gain : 0.0f; result.minimumCutoffHz = std::numeric_limits::max(); double energy = 0.0; const auto bank = wavetableBank.renderSnapshot(); diff --git a/Source/Dsp/WaldorfEngine.h b/Source/Dsp/WaldorfEngine.h index 6b12b2f..b9d0836 100644 --- a/Source/Dsp/WaldorfEngine.h +++ b/Source/Dsp/WaldorfEngine.h @@ -49,7 +49,10 @@ class WaldorfEngine float detuneCents = 0.0f; float gain = 1.0f; float auxGain = 0.0f; - int audioOutput = 0; + // Firmware "Audio Out" (Instrument part byte +0xC): 0 Aux only, + // 1 main, 2 sub 1, 3 sub 2. Only main reaches the plugin output. + static constexpr int mainAudioOut = 1; + int audioOutput = mainAudioOut; int keyLow = 0; int keyHigh = 127; int velocityLow = 1; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index 497bb44..020c3c5 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -3196,7 +3196,7 @@ void WaveEmulationAudioProcessor::sendPendingInstrumentFadersToFirmware() } static constexpr std::array pageOneRecordOffsets { - 4u, 5u, 7u, 8u, 9u, 10u, 2u, 3u + 4u, 5u, 7u, 12u, 9u, 10u, 2u, 3u // firmware table 0x14BCA }; static constexpr std::array pageTwoRecordOffsets { 4u, 5u, 7u, 21u, 22u, 15u, 16u, 11u @@ -3427,7 +3427,7 @@ void WaveEmulationAudioProcessor::synchronisePerformanceInstrumentsFromFirmware( -1.0f, 1.0f, static_cast(byte(5) - 64) / 64.0f); const auto panMode = juce::jlimit(0, 2, byte(6)); const auto auxGain = static_cast(byte(7)) / 127.0f; - const auto audioOutput = juce::jlimit(0, 3, byte(8)); + const auto audioOutput = juce::jlimit(0, 3, byte(12)); const auto transpose = byte(9) - 64; const auto detune = static_cast(byte(10) - 64); const auto keyLow = byte(17); @@ -5972,7 +5972,7 @@ void WaveEmulationAudioProcessor::applyFactoryProgram(int index, bool notifyFirm destination.auxGain = static_cast(sevenBit(performance[layerOffset + 7])) / 127.0f; destination.audioOutput - = juce::jlimit(0, 3, sevenBit(performance[layerOffset + 8])); + = juce::jlimit(0, 3, sevenBit(performance[layerOffset + 12])); destination.keyLow = sevenBit(performance[layerOffset + 17]); destination.keyHigh = sevenBit(performance[layerOffset + 18]); destination.velocityLow = juce::jmax(1, sevenBit(performance[layerOffset + 19])); diff --git a/Tests/WaveCoreTests.cpp b/Tests/WaveCoreTests.cpp index c01961b..1b09622 100644 --- a/Tests/WaveCoreTests.cpp +++ b/Tests/WaveCoreTests.cpp @@ -790,6 +790,33 @@ void testDspMathTables() "MIDI pitch lookup table exceeded its error bound"); } +void testPerformanceAudioOutRouting() +{ + // Firmware Audio Out values: 0 Aux only, 1 main, 2 sub 1, 3 sub 2. + const auto mainLevel = [](int audioOutput) { + wave::dsp::WaldorfEngine engine; + engine.prepare(48000.0, 512); + wave::dsp::WaldorfEngine::PerformanceSnapshot performance; + auto& layer = performance.layers[0]; + layer.enabled = true; + layer.source = 2; + layer.audioOutput = audioOutput; + layer.sound.attackSeconds = 0.001f; + juce::AudioBuffer audio(2, 512); + juce::MidiBuffer noteOn; + noteOn.addEvent(juce::MidiMessage::noteOn(1, 60, 0.9f), 0); + for (int block = 0; block < 4; ++block) + { + audio.clear(); + engine.render(audio, block == 0 ? noteOn : juce::MidiBuffer(), performance); + } + return audio.getMagnitude(0, 512); + }; + require(mainLevel(1) > 1.0e-3f, "Audio Out 'main' (1) is silent"); + require(mainLevel(0) < 1.0e-4f && mainLevel(2) < 1.0e-4f && mainLevel(3) < 1.0e-4f, + "Aux-only and sub outputs reached the main mix"); +} + void testPerformanceTuningTables() { wave::dsp::WaldorfEngine engine; @@ -3749,6 +3776,7 @@ int main() testWaveEnvelopeTraversal(); testWaveLfo(); testDspMathTables(); + testPerformanceAudioOutRouting(); testPerformanceTuningTables(); testFreeRunningEngineLfo(); testFactorySetWhenAvailable(); diff --git a/Tests/WaveEditorTests.cpp b/Tests/WaveEditorTests.cpp index b1cf030..9d60e4b 100644 --- a/Tests/WaveEditorTests.cpp +++ b/Tests/WaveEditorTests.cpp @@ -508,7 +508,8 @@ void testInstrumentPageOneFaderDoesNotLeakIntoOtherPages() wave::panel::performanceFaderAdcChannels[audioOutFader], 0.92f, false); processBlocks(*processor, audio, 1200); - constexpr auto audioOutRecordOffset = size_t { 8 }; + // Audio Out is Instrument part byte +0xC (firmware table 0x14BCA). + constexpr auto audioOutRecordOffset = size_t { 12 }; require((processor->getMasterFirmwareRuntime().sharedProgramByte( base + static_cast(audioOutRecordOffset)) & 0x7fu) diff --git a/waldorf-wave-firmware-reverse-engineering.md b/waldorf-wave-firmware-reverse-engineering.md index f2cde7a..cba7a6b 100644 --- a/waldorf-wave-firmware-reverse-engineering.md +++ b/waldorf-wave-firmware-reverse-engineering.md @@ -136,8 +136,8 @@ Behaviours that the real ROM exhibits under the 68000 core and that the gates no - **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: 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. +- **Emulator assumptions that disagreed with the map (found 2026-10-02; 1 fixed, hardware-unvalidated; 2 and 3 are open):** + 1. *Audio Out byte (fixed: the plugin now reads and edits +0xC; `PerformanceLayer::audioOutput` holds the firmware value and only 1 = main is audible).* 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. Sets with +0xC = 0 (three parts in `pg.set`/`ultimate.set`) now leave the main mix, as the "Aux only" label implies. 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. From 8694d399aedefa34f5a0d6489be857d032089171 Mon Sep 17 00:00:00 2001 From: BD <46272996+xbraindance@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:52:13 +0200 Subject: [PATCH 8/8] Apply start modulation to Startphase with the WDV's integer arithmetic The wave start-modulation routes add to Startphase (sound byte 27/43) in WDV 0xF92 (Wave 1) / 0x1074 (Wave 2); a Startphase of 0 is a free start and ignores the route. The engine offset the wave position instead. WaldorfEngine::startPhaseCode reproduces the firmware's key-on phase code: amount table 0x25E8 x source word through 0x246C, summed in 16-bit words. The two waves do not clamp alike - Wave 1 compares signed, so its 8-bit result wraps (64 + 127 gives 191); Wave 2 compares unsigned, so any result outside 1..126 becomes 127. The earlier "clamp to 1-127" description was wrong outside the nominal range. Bit 7 of the 8-bit code is assumed ignored by the ASIC (hardware-unvalidated). Tests: golden table executed on the WDV in the Musashi oracle, free start, fixed phase, positive/negative modulation, clamping, an engine-level note-on check, and - with WAVE_FIRMWARE_DIR set - the same cases run through the real WDV in VoiceFirmwareRuntime and compared with the ASIC phase-load write. Co-Authored-By: Claude Sonnet 5.5 --- Source/Dsp/WaldorfEngine.cpp | 87 +++++-- Source/Dsp/WaldorfEngine.h | 12 +- Tests/WaveCoreTests.cpp | 251 ++++++++++++++++++- waldorf-wave-firmware-reverse-engineering.md | 4 +- 4 files changed, 324 insertions(+), 30 deletions(-) diff --git a/Source/Dsp/WaldorfEngine.cpp b/Source/Dsp/WaldorfEngine.cpp index b249d1f..50fffdf 100644 --- a/Source/Dsp/WaldorfEngine.cpp +++ b/Source/Dsp/WaldorfEngine.cpp @@ -494,6 +494,32 @@ void WaldorfEngine::Voice::prepare(double newSampleRate, int index) reset(); } +int WaldorfEngine::startPhaseCode(int programmed, int storedAmount, int source, + bool secondOscillator) noexcept +{ + if (programmed <= 0) + return 0; + + const auto word = [](int value) { return static_cast(static_cast(value)); }; + // WDV 0x246C: source word times the amount table at 0x25E8 (saturated at + // -32767), keeping the high word and doubling it in a 16-bit add. + auto modulation = 0; + if (storedAmount != 64 && source != 0) + { + const auto table = juce::jlimit( + -32767, 32767, + juce::roundToInt(wdvAmountDepth(static_cast(storedAmount - 64)) * 32768.0f)); + modulation = word(((source * table) >> 16) * 2); + } + const auto code = word(word(programmed << 8) + modulation) >> 8; + if (code == 0) + return 1; + // 0xFB8 compares signed (ble) in Wave 1, 0x109C unsigned (bcs) in Wave 2. + if (!secondOscillator) + return std::min(code, 127) & 0xff; + return code > 0 && code < 127 ? code : 127; +} + void WaldorfEngine::Voice::reset() { oscillator1.reset(); @@ -521,7 +547,6 @@ void WaldorfEngine::Voice::reset() active = false; currentWavePosition = 0.0f; currentLfoValues.fill(0.0f); - waveStartOffsets.fill(0.0f); currentPitchModulations.fill(0.0f); currentGlideNote = 0.0f; targetGlideNote = 0.0f; @@ -555,7 +580,7 @@ void WaldorfEngine::Voice::reset() vcaDrainSamplesRemaining = 0; controlFilterMode = 0; asicClockPhase = 1.0; - waveStartOffsetsPending = false; + startPhaseModPending = false; filterEnvelopePending = false; filterEnvelopeTriggered = false; } @@ -695,8 +720,7 @@ void WaldorfEngine::Voice::start(int midiNote, int midiChannel, float noteVeloci for (size_t lfo = 0; lfo < lfos.size(); ++lfo) lfos[lfo].noteOn(parameters.lfos[lfo].sync, parameters.lfos[lfo].phaseDegrees); - waveStartOffsets.fill(0.0f); - waveStartOffsetsPending = true; + startPhaseModPending = true; } void WaldorfEngine::Voice::release(bool allowSustain) @@ -900,24 +924,37 @@ Cem3387::StereoSample WaldorfEngine::Voice::process( { // Start modifiers are sampled at note-on; all other WDV targets are // refreshed by the voice-board control interrupt rather than by the - // host audio clock. - if (waveStartOffsetsPending) + // host audio clock. WDV 0xF92/0x1074 add the route to Startphase (sound + // byte 27/43, one full cycle at full scale) in 16-bit arithmetic, see + // startPhaseCode; a Startphase of 0 is a free start and ignores it. + if (startPhaseModPending) { - waveStartOffsets[0] - = 64.0f - * wdvAmountDepth( - parameters.modulationRoutes[parameters::wave1StartMod].amount) - * routeValue(parameters::wave1StartMod, parameters, env, - waveEnvelopeValue, currentLfoValues, modWheel, - channelPressure, pitchBend); - waveStartOffsets[1] - = 64.0f - * wdvAmountDepth( - parameters.modulationRoutes[parameters::wave2StartMod].amount) - * routeValue(parameters::wave2StartMod, parameters, env, - waveEnvelopeValue, currentLfoValues, modWheel, - channelPressure, pitchBend); - waveStartOffsetsPending = false; + constexpr std::array startRoutes { + parameters::wave1StartMod, parameters::wave2StartMod + }; + std::array oscillators { &oscillator1, &oscillator2 }; + for (size_t i = 0; i < startRoutes.size(); ++i) + { + const auto& route = parameters.modulationRoutes[startRoutes[i]]; + // ponytail: float sources map to words at 32768 full scale; the + // master's MIDI controller words are CC << 8 (max 0x7F00), so a + // controller-driven start can land one step off. Per-source word + // scales would fix every route, not just this one. + const auto source = juce::jlimit( + -32768, 32767, + juce::roundToInt(32768.0f + * routeValue(startRoutes[i], parameters, env, + waveEnvelopeValue, currentLfoValues, + modWheel, channelPressure, pitchBend))); + const auto code = startPhaseCode(juce::roundToInt(parameters.wavePhases[i]), + juce::roundToInt(route.amount) + 64, + source, i == 1); + // ponytail: the ASIC takes the code as 8 bits, but Startphase + // is documented 0..127 = one cycle; bit 7 is assumed ignored. + if (code != 0) + oscillators[i]->reset(static_cast(code & 127) / 128.0); + } + startPhaseModPending = false; } const auto bend1 = pitchBend * parameters.oscillatorBendRanges[0] / 2.0f; @@ -973,8 +1010,8 @@ Cem3387::StereoSample WaldorfEngine::Voice::process( baseFrequency * performanceRatio * std::exp2((pitch2 + detune[1] / 100.0f) / 12.0f)); - const auto wave1Mod = waveStartOffsets[0] - + 64.0f * wdvAmountDepth( + const auto wave1Mod + = 64.0f * wdvAmountDepth( parameters.modulationRoutes[parameters::wave1Mod1].amount) * routeValue(parameters::wave1Mod1, parameters, env, waveEnvelopeValue, currentLfoValues, modWheel, @@ -994,8 +1031,8 @@ Cem3387::StereoSample WaldorfEngine::Voice::process( + 64.0f * wdvAmountDepth(parameters.waveKeytrackAmounts[0]) * keyPosition + wave1Mod); currentWavePosition = controlWavePositions[0]; - const auto wave2Mod = waveStartOffsets[1] - + 64.0f * wdvAmountDepth( + const auto wave2Mod + = 64.0f * wdvAmountDepth( parameters.modulationRoutes[parameters::wave2Mod1].amount) * routeValue(parameters::wave2Mod1, parameters, env, waveEnvelopeValue, currentLfoValues, modWheel, diff --git a/Source/Dsp/WaldorfEngine.h b/Source/Dsp/WaldorfEngine.h index b9d0836..fdb419e 100644 --- a/Source/Dsp/WaldorfEngine.h +++ b/Source/Dsp/WaldorfEngine.h @@ -82,6 +82,15 @@ class WaldorfEngine float circuitAgeAmount = 0.18f; }; + // Phase-load code the WDV writes to the ASIC at key-on (OS 1.700 0xF92 for + // Wave 1, 0x1074 for Wave 2). `programmed` is Startphase (sound byte 27/43, + // 0 = free start), `storedAmount` the route amount byte (0..127, 64 = off) + // and `source` the 16-bit modulation word. Returns 0 for a free start + // (nothing is written), otherwise the byte the firmware sends: the sum is + // formed in 16-bit words, so Wave 1 wraps to 8 bits where Wave 2 saturates. + [[nodiscard]] static int startPhaseCode(int programmed, int storedAmount, int source, + bool secondOscillator) noexcept; + struct VoiceProbe { float minimumCutoffHz = 0.0f; @@ -260,7 +269,6 @@ class WaldorfEngine bool active = false; float currentWavePosition = 0.0f; std::array currentLfoValues{}; - std::array waveStartOffsets{}; std::array currentPitchModulations{}; float currentGlideNote = 0.0f; float targetGlideNote = 0.0f; @@ -300,7 +308,7 @@ class WaldorfEngine int filterEnvelopeDelaySamples = 0; int vcaDrainSamplesRemaining = 0; int controlFilterMode = 0; - bool waveStartOffsetsPending = false; + bool startPhaseModPending = false; bool filterEnvelopePending = false; bool filterEnvelopeTriggered = false; }; diff --git a/Tests/WaveCoreTests.cpp b/Tests/WaveCoreTests.cpp index 1b09622..99e7fbb 100644 --- a/Tests/WaveCoreTests.cpp +++ b/Tests/WaveCoreTests.cpp @@ -817,6 +817,252 @@ void testPerformanceAudioOutRouting() "Aux-only and sub outputs reached the main mix"); } +void testStartPhaseCode() +{ + using Engine = wave::dsp::WaldorfEngine; + // Free start: Startphase 0 writes no phase load and ignores the route. + require(Engine::startPhaseCode(0, 127, 32767, false) == 0 + && Engine::startPhaseCode(0, 0, -32768, true) == 0, + "A free start was modulated"); + // Fixed phase: amount byte 64 (route off) or a zero source leaves it alone. + for (const auto phase : { 1, 64, 127 }) + for (const auto second : { false, true }) + require(Engine::startPhaseCode(phase, 64, 32767, second) == phase + && Engine::startPhaseCode(phase, 120, 0, second) == phase, + "An unmodulated fixed Startphase changed"); + // Half-scale source, amount +7 steps (stored 120): 64 + 49, both waves. + require(Engine::startPhaseCode(64, 120, 16384, false) == 113 + && Engine::startPhaseCode(64, 120, 16384, true) == 113 + && Engine::startPhaseCode(64, 120, -16384, false) == 15, + "Positive/negative Startphase modulation differs from WDV 0xF92"); + + // Reference values executed on WDV 0xF92 (Wave 1) and 0x1074 (Wave 2) of OS + // 1.700 in the Musashi oracle. Outside 1..127 the two waves differ: Wave 1 + // adds in 16-bit words and keeps 8 bits (signed compare), Wave 2 saturates + // at 127 (unsigned compare), so a negative result gives 127 there. + struct Row { int phase; int amount; int source; int wave1; int wave2; }; + static constexpr Row rows[] = { + { 1, 0, -32768, 128, 127 }, + { 1, 0, -16384, 64, 64 }, + { 1, 0, 16384, 193, 127 }, + { 1, 0, 32767, 129, 127 }, + { 1, 40, -32768, 19, 19 }, + { 1, 40, -16384, 10, 10 }, + { 1, 40, 16384, 248, 127 }, + { 1, 40, 32767, 239, 127 }, + { 1, 88, -32768, 239, 127 }, + { 1, 88, -16384, 248, 127 }, + { 1, 88, 16384, 10, 10 }, + { 1, 88, 32767, 18, 18 }, + { 1, 127, -32768, 129, 127 }, + { 1, 127, -16384, 193, 127 }, + { 1, 127, 16384, 64, 64 }, + { 1, 127, 32767, 128, 127 }, + { 64, 0, -32768, 191, 127 }, + { 64, 0, -16384, 127, 127 }, + { 64, 0, 16384, 1, 1 }, + { 64, 0, 32767, 192, 127 }, + { 64, 40, -32768, 82, 82 }, + { 64, 40, -16384, 73, 73 }, + { 64, 40, 16384, 55, 55 }, + { 64, 40, 32767, 46, 46 }, + { 64, 88, -32768, 46, 46 }, + { 64, 88, -16384, 55, 55 }, + { 64, 88, 16384, 73, 73 }, + { 64, 88, 32767, 81, 81 }, + { 64, 127, -32768, 192, 127 }, + { 64, 127, -16384, 1, 1 }, + { 64, 127, 16384, 127, 127 }, + { 64, 127, 32767, 191, 127 }, + { 127, 0, -32768, 254, 127 }, + { 127, 0, -16384, 190, 127 }, + { 127, 0, 16384, 63, 63 }, + { 127, 0, 32767, 255, 127 }, + { 127, 40, -32768, 145, 127 }, + { 127, 40, -16384, 136, 127 }, + { 127, 40, 16384, 118, 118 }, + { 127, 40, 32767, 109, 109 }, + { 127, 88, -32768, 109, 109 }, + { 127, 88, -16384, 118, 118 }, + { 127, 88, 16384, 136, 127 }, + { 127, 88, 32767, 144, 127 }, + { 127, 127, -32768, 255, 127 }, + { 127, 127, -16384, 63, 63 }, + { 127, 127, 16384, 190, 127 }, + { 127, 127, 32767, 254, 127 }, + }; + for (const auto& row : rows) + require(Engine::startPhaseCode(row.phase, row.amount, row.source, false) == row.wave1 + && Engine::startPhaseCode(row.phase, row.amount, row.source, true) == row.wave2, + "Startphase code differs from the WDV firmware"); +} + +void testStartPhaseCodeMatchesFirmwareWhenAvailable() +{ + // Runs the real WDV key-on path for every Startphase/route combination and + // compares the phase load it writes to the ASIC (reg 2 = n & 3, reg 4 = + // n >> 2, then a select write with bit 4 clear) with startPhaseCode(). + const auto* path = std::getenv("WAVE_FIRMWARE_DIR"); + if (path == nullptr) + return; + wave::firmware::Bundle bundle; + require(bundle.load(juce::File(path)).authenticity + == wave::firmware::Bundle::Authenticity::verifiedOs1700, + "WAVE_FIRMWARE_DIR does not contain the known OS 1.700 pair"); + + auto memoryStorage = std::make_unique(); + auto& memory = *memoryStorage; + memory.clear(); + wave::firmware::VoiceFirmwareRuntime runtime; + runtime.attachSharedMemory(memory); + require(runtime.setBoardIndex(0) && runtime.loadAndReset(bundle.getVoiceImage()), + "WDV image was rejected"); + + const auto poke = [&](uint32_t address, uint32_t value) { + (address >= 0x140000 ? memory.work[address - 0x140000] + : memory.program[address - 0x100000]) + = static_cast(value); + }; + const auto scan = [&] { + constexpr uint32_t alive = 0x10508e; // set after every full voice scan + poke(alive, 0); + for (int slice = 0; runtime.sharedByte(alive - 0x100000) == 0; ++slice) + { + require(slice < 5'000'000, "WDV voice scan did not finish"); + runtime.runCycles(200); + } + }; + + // Same seed as the oracle gate: master-go preset, boot to the main loop, + // 0x40-centred tuning bytes, one voice record per voice. + constexpr uint32_t p2 = 0x130000, p3 = 0x131000, p4 = 0x132000, p5 = 0x133000; + poke(0x10508a, 1); + poke(0x105006, 0x5a); + poke(0x1050e7, 48); + for (int steps = 0; runtime.programCounter() != 0x516; ++steps) + { + require(steps < 50'000'000, "WDV did not reach its main loop"); + runtime.runCycles(1); + } + poke(0x148812, 0x5a); + poke(0x148803, 1); + poke(0x148802, 0x80); + poke(0x148826, 0x40); + for (const auto offset : { 1u, 2u, 13u, 14u }) + poke(p2 + offset, 0x40); + poke(p4 + 12, 2); + poke(p4 + 16, 1); + poke(p4 + 9, 0x40); + poke(p4 + 10, 0x80); + poke(p2 + 0xef, 0x55); + poke(p3 + 0x30, 0x55); + poke(p2 + 40, 1); + constexpr uint32_t voice = 3; + for (uint32_t v = 0; v < 16; ++v) + { + const auto record = 0x100000 + v * 0x100; + const auto pointer = [&](uint32_t offset, uint32_t value) { + for (uint32_t i = 0; i < 4; ++i) + poke(record + offset + i, value >> (24 - 8 * i)); + }; + pointer(0x2c, p5); + pointer(0x30, p4); + pointer(0x34, p3); + pointer(0x38, p2); + poke(record + 0x3d, 0x20 * (v % 3)); + poke(record + 0x3c, v & 1); + poke(record + 0x0a, 48 + v); + poke(0x15a680 + 2 * v, 1); + poke(0x15a681 + 2 * v, 3 * v); + } + scan(); + + // Route source 0x16 is the modulation-wheel word at record + 0xAC. + struct Case { int phase; int amount; int source; }; + for (const auto& c : { Case { 0, 127, 32767 }, Case { 64, 64, 32767 }, Case { 64, 120, 16384 }, + Case { 64, 120, -16384 }, Case { 2, 0, 32767 }, Case { 2, 0, -32768 }, + Case { 100, 127, 24000 }, Case { 127, 8, -8192 }, Case { 1, 127, -32768 }, + Case { 127, 127, 32767 }, Case { 64, 40, 32767 }, Case { 33, 100, -20000 } }) + { + const auto record = 0x100000 + voice * 0x100; + const auto word = static_cast(c.source); + poke(record + 0xac, word >> 8); + poke(record + 0xad, word & 0xff); + for (const auto base : { 0x1bu, 0x2bu }) + { + poke(p2 + base, static_cast(c.phase)); + poke(p2 + base + 1, 0x16); + poke(p2 + base + 2, static_cast(c.amount)); + poke(p5 + base + 2, 0xff); + } + runtime.clearHardwareWrites(); + poke(record + 6, 1); + scan(); + scan(); + + // Pair the ASIC bytes into words, and catch the phase-load selects. + std::array loaded { -1, -1 }; + int reg2 = 0, reg4 = 0; + const auto& writes = runtime.pendingHardwareWrites(); + for (size_t i = 0; i + 1 < writes.size(); ++i) + { + if (writes[i].address < 0x980000 || writes[i].address >= 0x980200 + || (writes[i].address & 1) != 0 || writes[i + 1].address != writes[i].address + 1) + continue; + const auto value = (writes[i].value << 8) | writes[i + 1].value; + const auto reg = writes[i].address & 0xfe; + if (reg == 2) reg2 = value; + else if (reg == 4) reg4 = value; + else if (reg == 0xe && (value & 0x1e) == (0x2 * (voice & 7)) && writes[i].address < 0x980100) + loaded[value & 1] = (reg4 << 2) | (reg2 & 3); + } + for (const auto second : { false, true }) + { + const auto expected = wave::dsp::WaldorfEngine::startPhaseCode( + c.phase, c.amount, c.source, second); + require(loaded[second ? 1 : 0] == (expected == 0 ? -1 : expected), + "startPhaseCode differs from the Startphase the WDV firmware loads"); + } + } +} + +void testStartModulationAtNoteOn() +{ + // The route is evaluated once at note-on and offsets Startphase, not the + // wave position. Source 38 x control 38 is a constant +1. + const auto render = [](float startphase, float amount) { + wave::dsp::WaldorfEngine engine; + engine.prepare(48000.0, 512); + wave::dsp::WaldorfEngine::PerformanceSnapshot performance; + auto& layer = performance.layers[0]; + layer.enabled = true; + layer.source = 2; + layer.sound.attackSeconds = 0.001f; + for (const auto oscillator : { 0, 1 }) + layer.sound.wavePhases[static_cast(oscillator)] = startphase; + for (const auto route : { wave::parameters::wave1StartMod, wave::parameters::wave2StartMod }) + { + auto& assignment = layer.sound.modulationRoutes[static_cast(route)]; + assignment.source = 38; + assignment.control = 38; + assignment.amount = amount; + } + juce::AudioBuffer audio(2, 512); + juce::MidiBuffer noteOn; + noteOn.addEvent(juce::MidiMessage::noteOn(1, 60, 0.9f), 0); + engine.render(audio, noteOn, performance); + return std::vector(audio.getReadPointer(0), audio.getReadPointer(0) + 512); + }; + require(render(0.0f, 0.0f) == render(0.0f, 40.0f) && render(0.0f, 0.0f) == render(0.0f, -40.0f), + "A free start was modulated"); + require(render(64.0f, 0.0f) != render(32.0f, 0.0f), + "Startphase does not change the note-on waveform"); + require(render(64.0f, 40.0f) != render(64.0f, 0.0f) + && render(64.0f, -40.0f) != render(64.0f, 0.0f) + && render(64.0f, 40.0f) != render(64.0f, -40.0f), + "Start modulation did not move a fixed Startphase in both directions"); +} + void testPerformanceTuningTables() { wave::dsp::WaldorfEngine engine; @@ -3776,8 +4022,11 @@ int main() testWaveEnvelopeTraversal(); testWaveLfo(); testDspMathTables(); - testPerformanceAudioOutRouting(); testPerformanceTuningTables(); + testPerformanceAudioOutRouting(); + testStartPhaseCode(); + testStartPhaseCodeMatchesFirmwareWhenAvailable(); + testStartModulationAtNoteOn(); testFreeRunningEngineLfo(); testFactorySetWhenAvailable(); testDamagedUserWavetableImport(); diff --git a/waldorf-wave-firmware-reverse-engineering.md b/waldorf-wave-firmware-reverse-engineering.md index cba7a6b..f126463 100644 --- a/waldorf-wave-firmware-reverse-engineering.md +++ b/waldorf-wave-firmware-reverse-engineering.md @@ -136,9 +136,9 @@ Behaviours that the real ROM exhibits under the 68000 core and that the gates no - **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: 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 disagreed with the map (found 2026-10-02; 1 fixed, hardware-unvalidated; 2 and 3 are open):** +- **Emulator assumptions that disagreed with the map (found 2026-10-02; 1 and 2 fixed, hardware-unvalidated; 3 is open):** 1. *Audio Out byte (fixed: the plugin now reads and edits +0xC; `PerformanceLayer::audioOutput` holds the firmware value and only 1 = main is audible).* 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. Sets with +0xC = 0 (three parts in `pg.set`/`ultimate.set`) now leave the main mix, as the "Aux only" label implies. - 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. + 2. *Start modulation (fixed: the route now offsets the oscillator's start phase at note-on, 128 steps = one cycle at full scale, ignored when Startphase is 0; `WaldorfEngine::startPhaseCode` reproduces the firmware's integer arithmetic).* 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) in 16-bit words and the two waves do not clamp alike: Wave 1 (WDV 0xF92) compares signed, so the 8-bit result wraps (64 + 127 gives 191, a negative sum gives 128-255); Wave 2 (0x1074) compares unsigned, so any result outside 1-126 becomes 127. Whether the ASIC uses bit 7 of the code is unknown, the engine ignores it; `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.