From ca7e35775883e6441c59d5c1957954d0a02e6a6d Mon Sep 17 00:00:00 2001 From: James Hutchinson Date: Thu, 29 Oct 2026 18:15:51 -0700 Subject: [PATCH 1/4] Light a HUB75 panel's last row as long as every other Two chained 64x64 panels showed a line along the seam between the halves and along the bottom edge: brighter than its neighbors below half gray, nearly dark just above it, invisible on white. Each half-panel's last row now holds its color the way every other row does. **Light domain** - Hub75Slots.h: row 0's words light the last row of the plane before, still in the latch, so they take that plane's lit window, and plane 0's take the top plane's, the one the buffer wraps from. They took the window of the plane being clocked in, so the last row of each half weighed its lower bits double and its top bit at the shortest window. The `@technical` section says why the window follows the latched data. **Tests** - unit_Hub75Slots: every row of a value is lit as long as every other, the last row of each half included, read as the panel sees it (a row lights what the row before it latched); it fails against the old window. litWords and the output-enable test measure the row that lights a plane's data rather than the row that clocks it in. - moonlive_ir_golden.inc: ramp.mle's IR hash, captured with MM_PRINT_IR_GOLDEN=1. - test_desktop --module Hub75Driver: 21 passed. --module MoonLive: 394 passed. **Docs/CI** - moonlive/effects/basics/ramp.mle: a gray ramp, the same on every row, the test pattern that shows a row driven wrong; script_catalog.h regenerated with its entry. - docs/work/present/atlas/example-hub75-seam-row.md: how the cause was found from the report, with the session's transcript to the test pattern. --- .../present/atlas/example-hub75-seam-row.md | 166 ++++++++++++++++++ moonlive/effects/basics/ramp.mle | 21 +++ src/core/moonlive/script_catalog.h | 8 +- src/light/drivers/Hub75Slots.h | 8 +- test/unit/light/moonlive_ir_golden.inc | 1 + test/unit/light/unit_Hub75Slots.cpp | 43 ++++- 6 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 docs/work/present/atlas/example-hub75-seam-row.md create mode 100644 moonlive/effects/basics/ramp.mle diff --git a/docs/work/present/atlas/example-hub75-seam-row.md b/docs/work/present/atlas/example-hub75-seam-row.md new file mode 100644 index 00000000..af1ba2f4 --- /dev/null +++ b/docs/work/present/atlas/example-hub75-seam-row.md @@ -0,0 +1,166 @@ +# MoonAtlas example: a bright row at a HUB75 panel's seam + +A report from the bench: two 64x64 HUB75 panels chained side by side, 128x64 on an ESP32, show a bright line along row 32 or 33. The maintainer knows the driver's name and nothing of its wire format. Three queries and one 60-line read take them from the symptom to a hypothesis with predictions to check on the panels, and to the test that missed it, and a MoonLive script then puts the prediction on the panels with no firmware build. Output from the tree on 2026-10-29; the transcript at the end is the session as it ran. + +## What it is + +``` +$ uv run moondeck/docs/show_atlas.py what Hub75Driver +module:Hub75Driver: Drives HUB75 LED panels straight from the board's own pins, with no receiving card in between. + defined_in -> src/light/drivers/Hub75Driver.h + documented_by -> docs/moonmodules/light/drivers.md#hub75 + … eleven has_control lines, bitDepth to whiteMode … + is -> Hub75Driver src/light/drivers/Hub75Driver.h:34 + tests <- test/unit/light/unit_Hub75Driver_pins.cpp + tests <- test/unit/light/unit_Hub75Slots.cpp +class:Hub75Driver src/light/drivers/Hub75Driver.h:34 + derives -> DriverBase src/light/drivers/DriverBase.h:24 + defines <- src/light/drivers/Hub75Driver.h [34] + is <- module:Hub75Driver + … three mentions lines: two shipped plans and the MoonCore plan … +``` + +The driver, its doc section and its two test files, one of them named for the encoder. + +## Where the wire format lives + +``` +$ uv run moondeck/docs/show_atlas.py impact Hub75Bus.h +included by (4) + src/light/drivers/Hub75Driver.h + src/light/drivers/Hub75Slots.h + src/module_types.cpp + src/platform/esp32/platform_esp32_hub75.cpp +unit tests (2) + test/unit/light/unit_Hub75Driver_pins.cpp + test/unit/light/unit_Hub75Slots.cpp +scenarios: none +MoonWalk steps (1) + step:driver-setup/add-hub75 Add the HUB75 driver. +docs (1) + docs/work/present/Plan-20261025 - MoonCore under other apps (2 of 12).md +claims (2) + docs/work/present/atlas/areas/platform-fable.json#plat-014 A HUB75 geometry encodes only with 2 to 6 bit planes, a height that divides by its scan rate, and an even number of rows per address step, since each step drives an upper and a lower row together. + docs/work/present/atlas/areas/platform-fable.json#plat-016 In the 16-bit HUB75 bus word the OE bit is set where the panel is lit and the platform inverts the pin, since OE is active low and a peripheral idles its lines low. +``` + +The bus contract has one encoder, `Hub75Slots.h`, and one platform port. A search of the claims for HUB75 adds the one that matters most: + +> `drv-101`: hub75Encode lays the frame plane-major with the latch riding the last column's word, the data words naming the row latched one step earlier, and brightness as the number of lit words per row, doubling per plane. + +Two facts now frame the symptom. On a 64-row panel at 1/32 scan, address step r lights row r and row r + 32 (`plat-014`), so row 31, the last of the upper half, sits at the seam. And the row a panel lights is the one latched a step earlier (`drv-101`), so the first row of each pass lights whatever the previous pass left in the latch. + +## The read + +`hub75Encode` (`src/light/drivers/Hub75Slots.h:40-102`) loops plane, then scan row, then column. Its comment at line 68 says what row 0 shows: "for row 0 the last row of the previous plane or frame". The lit window at lines 75-77 is computed from the current `plane`. + +## The hypothesis + +The last scan row is lit with the next plane's on-time. While row r ≥ 1 of plane p clocks in, the panel lights row r − 1 of the same plane p, for plane p's window: correct. While row 0 of plane p clocks in, the latch holds row 31 of plane p − 1, lit for plane p's window, twice as long as its own. Row 31's top plane carries into the next frame's plane 0 and lights for plane 0's window. + +So rows 31 and 63 show each lower bit at twice its weight and the top bit at a fraction of it. Row 31 is row 32 counted from one: the seam the bench reports. + +The panels confirm it (On the bench, below). Its predictions: + +1. The bottom edge row of each panel, row 63, is off the same way. +2. The error depends on the value. Full white (255) looks right, since the doubled lower bits and the shrunken top bit sum to the same total; a value below 128 shows about twice as bright (0x7F reads as 254); a value just above 128 shows nearly dark. +3. One panel alone shows it, so the chain plays no part. + +## Why the tests pass + +`test/unit/light/unit_Hub75Slots.cpp:185`, "the DMA wrap lights the last row once, from row 0's data words", fills every pixel with 255, the one value where the error cancels, and checks that the last row is lit, not for how long per plane. The comment below it records an earlier bug of the same family on the same panel: "row 63 ghosting onto row 32, rows 0 and 32 brighter, flashing", fixed then through the output-enable polarity. + +## The fix + +Row 0 of each plane takes the lit window of the plane its latched data came from: plane p − 1, and the top plane for plane 0. The test that pins it sums each row's on-time per plane for a mid value such as 0x7F and requires every row to agree; it fails against the old encoder, where rows 31 and 63 light 123 words against every other row's 62. + +## Where the graph stops + +The graph pointed at the right file and handed over the two facts that frame the symptom; the timing argument came from reading the encoder, since the graph holds file, class and test edges and no data flow. The earlier ghosting bug sat in a test comment, which the graph indexes as a test of the module and not as a lesson. + +## On the bench + +`ramp.mle` (in the transcript below) runs on the MatrixPortal S3 driving the two chained panels, loaded as a script with no firmware build. + +1. **Before the fix, in sync with the panel:** a short dark segment on the seam row, right of the middle, darkest at its left end and fading to the right. That is prediction 2 with the driver's brightness curve on, which maps the ramp's values there to just above 128 on the wire. +2. **Before the fix, the camera pushed out of sync:** the shutter separates the bit planes' time slots, and a bright line stands out on the last row of each half, the seam and the bottom edge, rows 31 and 63. It lines up with the upper section's bottom row, not the lower section's top row, which rules out rows 0 and 32. +3. **After the fix, flashed over the air through MoonBase:** the seam and the bottom edge match their neighbors across the whole ramp. + +## Transcript + +The session as it ran, from the product owner's report to the test pattern that checks it on the panels. Other work in the same session is left out; the tool steps are summarized in italics. + +**James:** example scenario. A multi panel hub75 with 64x64 panels, 2 wide 1 deep on esp32, the display shows a brightness along the 32 or 33 row. + +*Claude runs `what Hub75Driver`, `why Hub75Driver` and `impact Hub75Bus.h`, searches the claims for HUB75, reads `hub75Encode` in `Hub75Slots.h` and the geometry in `Hub75Bus.h`, and lists the encoder's tests.* + +**Claude:** Here's how the graph got me to a hypothesis, which fits your symptom closely. It's a hypothesis, not a confirmed root cause, until it's checked on the panels. + +How the graph got there, in three queries and one read: + +- `what Hub75Driver` named the header, its doc section and its two unit tests. +- `impact Hub75Bus.h` showed the encoder, `Hub75Slots.h`, is the one place the wire format lives. +- Two claims gave the key facts. `drv-101`: row r's data words are shown while the panel is still addressed to row r−1. `plat-014`: each address step lights an upper and a lower row together. + +Then I read `hub75Encode` itself, about 60 lines. + +The hypothesis: the last scan row is lit with the wrong bit plane's on-time. Your chain is 128×64 at 1/32 scan, so there are 32 scan rows, and step r lights row r and row r+32. + +- Why the address lags: the panel shows the row last latched, so while row r's data clocks in, row r−1 is lit. For r ≥ 1, row r−1 of plane p is lit for plane p's window, which is correct. +- The wrap: at row 0 of plane p, the latch still holds row 31 from plane p−1, the last row of the plane before. `Hub75Slots.h:69-77` computes the lit window from the current plane p, not from p−1. +- The effect on row 31: every lower bit shows for twice its correct time. The top bit, carried into the next frame's plane 0, shows for only plane 0's short window. +- That row's position: 0-based row 31 is your 1-based row 32. It's the last row of the upper half, right at the seam between the halves. Its partner is row 63, the panel's bottom edge. + +Predictions you can check on the panels: + +1. The bottom edge row of each panel (row 64, 1-based) should be off in the same way as the seam row. +2. It depends on color value. Full white (255) should look correct, because the doubled lower bits and the shrunken top bit happen to add up to the same total. Values below 128 should look about twice as bright (0x7F comes out as roughly 254). Values just above 128 should look nearly dark. +3. It shouldn't depend on the chain. One 64×64 panel alone should show it too. + +Why the tests miss it: the wrap test at `unit_Hub75Slots.cpp:185` fills every pixel with 255, the one value where the error cancels out. It checks that the last row is lit once, not for how long per plane. The comment just after it records an earlier bug in the same family: "row 63 ghosting onto row 32, rows 0 and 32 brighter, flashing", fixed then through the output-enable polarity. + +If it's confirmed, the fix is small. For row 0 of each plane, compute the lit window from the plane the latched data came from: plane p−1, or the top plane for plane 0. The test adds up each row's on-time per plane for a mid value such as 0x7F and requires every row to come out the same; that test fails against today's code. + +Your MatrixPortal S3 drives HUB75, so it could check predictions 1 and 2 directly. + +**James:** make a test pattern that highlights the hub75 issue + +*Claude reads the MoonLive README and three of the basic effect scripts, then `gradient.mle`, and checks the driver's brightness curve.* + +**James:** could mmlive be used? + +**Claude:** Yes, this is exactly what MoonLive is for. A MoonLive effect compiles on the device, so the pattern can be pasted into a MoonLive effect card on the MatrixPortal and runs with no firmware build or flash. + +`gradient.mle` won't do. Its value follows the light index, so every row has a different color and an odd row can't stand out. What the diagnosis needs is a ramp across the width that is identical on every row. Then a row whose bit planes are mis-timed shows up as a stripe that doesn't match the rows around it. + +*Claude writes `moonlive/effects/basics/ramp.mle`:* + +```cpp +// Ramp: gray rising from black on the left to white on the right, the same on every row, the test pattern for a panel's bit planes. +// Every row matches its neighbors, so a row that differs is a fault in how the panel is driven, not in the picture: +// a HUB75 row lit with the wrong plane's on-time reads brighter than its neighbors below half gray and darker just above it. + +class RampEffect { + + int dimensions() { return 2; } + + string tags() { return "💫"; } + + void tick() { + int span = width - 1; + if (span < 1) { span = 1; } + for (int x = 0; x < width; x = x + 1) { + int v = div(x * 255, span); + for (int y = 0; y < height; y = y + 1) { + setRGB(y * width + x, v, v, v); + } + } + } +} +``` + +**Claude:** The ramp draws gray from black at the left to white at the right, identical on every row and spanning both chained panels. If the hypothesis holds, rows 31 and 63 (rows 32 and 64 counting from one) show a brighter stripe in the left half of the ramp, below mid-gray. Just right of the middle, the stripe turns dark, and at the far right, white, it disappears. + +It also works with gamma or CIE correction on: the ramp still crosses every bit boundary, just at different x positions. For the cleanest reading, set the driver's brightness curve to linear. + +To try it on the MatrixPortal before any release carries it: add a MoonLive effect, create a new script, paste the file's contents and save. It compiles on the next tick. diff --git a/moonlive/effects/basics/ramp.mle b/moonlive/effects/basics/ramp.mle new file mode 100644 index 00000000..5a34814f --- /dev/null +++ b/moonlive/effects/basics/ramp.mle @@ -0,0 +1,21 @@ +// Ramp: gray rising from black on the left to white on the right, the same on every row, the test pattern for a panel's bit planes. +// Every row matches its neighbors, so a row that differs is a fault in how the panel is driven, not in the picture: +// a HUB75 row lit with the wrong plane's on-time reads brighter than its neighbors below half gray and darker just above it. + +class RampEffect { + + int dimensions() { return 2; } + + string tags() { return "💫"; } + + void tick() { + int span = width - 1; + if (span < 1) { span = 1; } + for (int x = 0; x < width; x = x + 1) { + int v = div(x * 255, span); + for (int y = 0; y < height; y = y + 1) { + setRGB(y * width + x, v, v, v); + } + } + } +} diff --git a/src/core/moonlive/script_catalog.h b/src/core/moonlive/script_catalog.h index bc11e1d2..1e1e4a52 100644 --- a/src/core/moonlive/script_catalog.h +++ b/src/core/moonlive/script_catalog.h @@ -40,6 +40,7 @@ constexpr const char* kEffectCatalog[] = { "dot.mle", "gradient.mle", "lines.mle", + "ramp.mle", "random-pixel.mle", "sparkle.mle", "mh-aim.mle", @@ -81,7 +82,7 @@ constexpr const char* kEffectCatalog[] = { "stadbeest-thrusters.mle", "stadbeest-walk.mle", }; -constexpr size_t kEffectCatalogCount = 49; +constexpr size_t kEffectCatalogCount = 50; /// What each effect above declares about itself, in the same order, 0 meaning it says nothing and the device decides. constexpr unsigned char kEffectCatalogDim[] = { 1, @@ -93,6 +94,7 @@ constexpr unsigned char kEffectCatalogDim[] = { 2, 3, 2, + 2, 1, 3, 1, @@ -147,6 +149,7 @@ constexpr const char* kEffectCatalogTags[] = { "💫", "💫", "💫", + "💫", "💫🎯", "🗼🎶🎯", "💫🎯", @@ -199,6 +202,7 @@ constexpr unsigned char kEffectCatalogFolder[] = { 1, 1, 1, + 1, 2, 2, 2, @@ -407,6 +411,6 @@ constexpr unsigned char kPaletteCatalogFolder[] = { 10, }; -constexpr size_t kCatalogCount = 77; ///< every factory script, all roles +constexpr size_t kCatalogCount = 78; ///< every factory script, all roles } // namespace mm::moonlive diff --git a/src/light/drivers/Hub75Slots.h b/src/light/drivers/Hub75Slots.h index 9dc596e1..06acbb6e 100644 --- a/src/light/drivers/Hub75Slots.h +++ b/src/light/drivers/Hub75Slots.h @@ -30,13 +30,15 @@ namespace mm { /// /// A panel lights where output-enable, the addressed row and the latched row meet, and the latch holds the row last strobed. So row r's words name row r - 1, the latch word moves the address to r in the dark, and row 0's words name the last row. /// +/// The lit window follows the data in the latch, not the plane being clocked in. Row 0's words light the last row of the plane before, so they take that plane's window, and plane 0's take the top plane's, the one the buffer wraps from. With the current plane's window instead, the last row of each half-panel weighed its bits double and its top bit almost nothing: a line at the seam and along the bottom edge, which a uniform white hides. +/// /// ## Brightness is time /// /// A panel's chips only switch a column on or off, so plane p lights 2^p as long as plane 0 and brightness shortens every plane's window. /// /// Below about a quarter brightness, the lowest plane of a 64-column row rounds to nothing. -/// Encode one RGB frame into @xref{the-wire-layout|bit planes}, @xref{the-address-lags-the-data-by-one-row|the address a row late}, @xref{brightness-is-time|brightness as time}; bytes written, or 0 on a bad geometry: a dark frame beats half a frame. +/// Encode one RGB frame into @xref{the-wire-layout|bit planes}, @xref{the-address-lags-the-data-by-one-row|the address and the lit window a row late}, @xref{brightness-is-time|brightness as time}; bytes written, or 0 on a bad geometry: a dark frame beats half a frame. inline size_t hub75Encode(const uint8_t* rgb, uint8_t* out, const Hub75Geometry& geo, const Hub75Layout& lay = {}) { if (!rgb || !out) return 0; @@ -71,10 +73,12 @@ inline size_t hub75Encode(const uint8_t* rgb, uint8_t* out, const uint16_t darkWord = addrMask(latched); const uint16_t litWord = static_cast(darkWord | (1u << lay.oe)); const uint16_t latchWord = static_cast(addrMask(r) | (1u << lay.lat)); + // The plane whose data the latch holds while these words clock in, the previous one for row 0: its window is the one these words light. + const uint8_t shownPlane = r == 0 ? static_cast((plane + geo.bitDepth - 1) % geo.bitDepth) : plane; // Lit words: the top plane fills the row less its latch, each lower plane half the one above; 32 bits hold 2,048 words at 6 bits. const uint32_t span = 255u << (geo.bitDepth - 1); const uint32_t litWords = - (static_cast(rowWords - 1) * geo.brightness * (1u << plane) + span / 2) / span; + (static_cast(rowWords - 1) * geo.brightness * (1u << shownPlane) + span / 2) / span; for (uint16_t pair = 0; pair < pairs; pair++) for (uint16_t x = 0; x < geo.width; x++) { // Pair p of step r is row r + p*scanRate, paired half a panel below. diff --git a/test/unit/light/moonlive_ir_golden.inc b/test/unit/light/moonlive_ir_golden.inc index 7a5ce698..57bddcf1 100644 --- a/test/unit/light/moonlive_ir_golden.inc +++ b/test/unit/light/moonlive_ir_golden.inc @@ -7,6 +7,7 @@ {"effects/basics/dot.mle", 0x1e5cd842f29bd16cull}, {"effects/basics/gradient.mle", 0x066b6e80d9fb84cbull}, {"effects/basics/lines.mle", 0x61a519a99d69b4c6ull}, + {"effects/basics/ramp.mle", 0x56270ce14f72fa1full}, {"effects/basics/random-pixel.mle", 0xf7fb12703b9df7e6ull}, {"effects/basics/sparkle.mle", 0x6ea5f2562ef2cd4full}, {"effects/moving-heads/mh-aim.mle", 0xa3b9705009ee6730ull}, diff --git a/test/unit/light/unit_Hub75Slots.cpp b/test/unit/light/unit_Hub75Slots.cpp index fa76956a..29f895aa 100644 --- a/test/unit/light/unit_Hub75Slots.cpp +++ b/test/unit/light/unit_Hub75Slots.cpp @@ -27,6 +27,12 @@ uint16_t slot(const std::vector& out, size_t n) { return static_cast(out[n * 2] | (out[n * 2 + 1] << 8)); } +/// The first slot of the row that lights plane p's first row: the one after it latches, since a row is lit while the next one clocks in, and the buffer wraps. +size_t litRowBase(const mm::Hub75Geometry& geo, uint8_t p) { + const size_t rowsInFrame = static_cast(geo.bitDepth) * geo.scanRows(); + return (static_cast(p) * geo.scanRows() + 1) % rowsInFrame * geo.width; +} + } // namespace TEST_CASE("HUB75 encode: the frame is bit-plane major, one word per column") { @@ -206,6 +212,37 @@ TEST_CASE("HUB75 encode: the DMA wrap lights the last row once, from row 0's dat CHECK((slot(out, 0) & (1u << lay.a)) == (lastRow & 1 ? (1u << lay.a) : 0u)); } +// Seen on two chained 64x64 panels: a line at the seam (row 31) and along the bottom edge (row 63), brighter below half gray and nearly dark just above it. Row 0's words lit the last row with the plane being clocked in, while the latch held the plane before it, so that row weighed its bits double and its top bit almost nothing. A uniform white sums to the same total, which is why the wrap test above passes either way. +TEST_CASE("HUB75 encode: every row of a value is lit as long as every other, the last row of each half included") { + mm::Hub75Geometry geo; + geo.width = 64; geo.height = 64; geo.scanRate = 32; geo.bitDepth = 6; geo.brightness = 255; + mm::Hub75Layout lay; + std::vector rgb(static_cast(geo.width) * geo.height * 3, 0x7F); // below half: every plane but the top one + std::vector out(geo.frameBytes(), 0); + REQUIRE(mm::hub75Encode(rgb.data(), out.data(), geo, lay) == geo.frameBytes()); + + // The panel's view: a row lights what the row before it latched, so each latched row's brightness is the lit words + // of the row that follows, counted where its data bit is set, summed over the planes. The buffer wraps. + const uint16_t rows = geo.scanRows(); + const size_t rowsInFrame = static_cast(geo.bitDepth) * rows; + std::vector upper(rows, 0), lower(rows, 0); + for (size_t b = 0; b < rowsInFrame; b++) { + const size_t latchedFrom = (b + rowsInFrame - 1) % rowsInFrame; + const uint16_t data = slot(out, latchedFrom * geo.width); + uint32_t lit = 0; + for (uint16_t x = 0; x < geo.width; x++) lit += (slot(out, b * geo.width + x) & (1u << lay.oe)) != 0; + const uint16_t row = static_cast(latchedFrom % rows); + if (data & (1u << lay.r1)) upper[row] += lit; + if (data & (1u << lay.r2)) lower[row] += lit; + } + REQUIRE(upper[0] > 0); + for (uint16_t r = 0; r < rows; r++) { + CAPTURE(r); + CHECK(upper[r] == upper[0]); // row r of the upper half + CHECK(lower[r] == upper[0]); // row r + 32: the lower half, row 63 the bottom edge + } +} + // Seen on a 64x64 panel: row 63 ghosting onto row 32, rows 0 and 32 brighter, flashing. The peripheral idles its lines LOW between scans, and with OE encoded as the panel reads it (active low) that idle bus lit address 0 with the last latched row. TEST_CASE("HUB75 encode: the OE bit is set where the panel is lit, so an all-zero idle bus is dark") { mm::Hub75Geometry geo; @@ -215,7 +252,7 @@ TEST_CASE("HUB75 encode: the OE bit is set where the panel is lit, so an all-zer std::vector out(geo.frameBytes(), 0); REQUIRE(mm::hub75Encode(rgb.data(), out.data(), geo, lay) == geo.frameBytes()); // The top plane fills its row, so every column word lights but the latch word. - const size_t top = static_cast(geo.bitDepth - 1) * geo.scanRows() * geo.width; + const size_t top = litRowBase(geo, geo.bitDepth - 1); for (uint16_t x = 0; x + 1 < geo.width; x++) CHECK((slot(out, top + x) & (1u << lay.oe)) != 0); CHECK((slot(out, top + geo.width - 1) & (1u << lay.oe)) == 0); } @@ -237,9 +274,9 @@ TEST_CASE("HUB75 encode: a row clocks exactly its width, so the last column reac } namespace { -// How many words of plane p's first row carry the OE (lit) bit. +// How many words light plane p's first row: the words of the row after it that carry the OE (lit) bit. size_t litWords(const std::vector& out, const mm::Hub75Geometry& geo, const mm::Hub75Layout& lay, uint8_t p) { - const size_t base = static_cast(p) * geo.scanRows() * geo.width; + const size_t base = litRowBase(geo, p); size_t n = 0; for (uint16_t x = 0; x < geo.width; x++) n += (slot(out, base + x) & (1u << lay.oe)) != 0; return n; From f2afe3458e154d4eac6bf211a6319a89cb4d13b9 Mon Sep 17 00:00:00 2001 From: James Hutchinson Date: Thu, 29 Oct 2026 18:33:40 -0700 Subject: [PATCH 2/4] Keep the HUB75 fix's comments within the documentation rules check_docgen failed #211: the new test's two-line comment is a hard wrap, and three lines ran past the length limits. **Light domain** - Hub75Slots.h: the paragraph on the lit window splits in two, each within a doc line's 250 characters, and the encoder's brief keeps its earlier wording. **Tests** - unit_Hub75Slots: the new test's comments, one line each, within a comment line's 250 characters. - check_docgen: 0 errors, every count at or below the committed one. --- src/light/drivers/Hub75Slots.h | 6 ++++-- test/unit/light/unit_Hub75Slots.cpp | 5 ++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/light/drivers/Hub75Slots.h b/src/light/drivers/Hub75Slots.h index 06acbb6e..484077cc 100644 --- a/src/light/drivers/Hub75Slots.h +++ b/src/light/drivers/Hub75Slots.h @@ -30,7 +30,9 @@ namespace mm { /// /// A panel lights where output-enable, the addressed row and the latched row meet, and the latch holds the row last strobed. So row r's words name row r - 1, the latch word moves the address to r in the dark, and row 0's words name the last row. /// -/// The lit window follows the data in the latch, not the plane being clocked in. Row 0's words light the last row of the plane before, so they take that plane's window, and plane 0's take the top plane's, the one the buffer wraps from. With the current plane's window instead, the last row of each half-panel weighed its bits double and its top bit almost nothing: a line at the seam and along the bottom edge, which a uniform white hides. +/// The lit window follows the data in the latch: row 0's words light the last row of the plane before, so they take that plane's window, and plane 0's take the top plane's, where the buffer wraps. +/// +/// With the current plane's window, the last row of each half weighed its bits double and its top bit almost nothing: a line at the seam and the bottom edge, hidden by white. /// /// ## Brightness is time /// @@ -38,7 +40,7 @@ namespace mm { /// /// Below about a quarter brightness, the lowest plane of a 64-column row rounds to nothing. -/// Encode one RGB frame into @xref{the-wire-layout|bit planes}, @xref{the-address-lags-the-data-by-one-row|the address and the lit window a row late}, @xref{brightness-is-time|brightness as time}; bytes written, or 0 on a bad geometry: a dark frame beats half a frame. +/// Encode one RGB frame into @xref{the-wire-layout|bit planes}, @xref{the-address-lags-the-data-by-one-row|the address a row late}, @xref{brightness-is-time|brightness as time}; bytes written, or 0 on a bad geometry: a dark frame beats half a frame. inline size_t hub75Encode(const uint8_t* rgb, uint8_t* out, const Hub75Geometry& geo, const Hub75Layout& lay = {}) { if (!rgb || !out) return 0; diff --git a/test/unit/light/unit_Hub75Slots.cpp b/test/unit/light/unit_Hub75Slots.cpp index 29f895aa..75806bc1 100644 --- a/test/unit/light/unit_Hub75Slots.cpp +++ b/test/unit/light/unit_Hub75Slots.cpp @@ -212,7 +212,7 @@ TEST_CASE("HUB75 encode: the DMA wrap lights the last row once, from row 0's dat CHECK((slot(out, 0) & (1u << lay.a)) == (lastRow & 1 ? (1u << lay.a) : 0u)); } -// Seen on two chained 64x64 panels: a line at the seam (row 31) and along the bottom edge (row 63), brighter below half gray and nearly dark just above it. Row 0's words lit the last row with the plane being clocked in, while the latch held the plane before it, so that row weighed its bits double and its top bit almost nothing. A uniform white sums to the same total, which is why the wrap test above passes either way. +// Seen on two chained 64x64 panels: a line at rows 31 and 63, the last row lit with the next plane's window while the latch held the plane before. White sums the same, so the wrap test above passes either way. TEST_CASE("HUB75 encode: every row of a value is lit as long as every other, the last row of each half included") { mm::Hub75Geometry geo; geo.width = 64; geo.height = 64; geo.scanRate = 32; geo.bitDepth = 6; geo.brightness = 255; @@ -221,8 +221,7 @@ TEST_CASE("HUB75 encode: every row of a value is lit as long as every other, the std::vector out(geo.frameBytes(), 0); REQUIRE(mm::hub75Encode(rgb.data(), out.data(), geo, lay) == geo.frameBytes()); - // The panel's view: a row lights what the row before it latched, so each latched row's brightness is the lit words - // of the row that follows, counted where its data bit is set, summed over the planes. The buffer wraps. + // As the panel sees it: a row lights what the row before it latched, so a row's brightness sums the next row's lit words over the planes where its bit is set. const uint16_t rows = geo.scanRows(); const size_t rowsInFrame = static_cast(geo.bitDepth) * rows; std::vector upper(rows, 0), lower(rows, 0); From 6a52d45eb1888dec3b1b9273c06e62c9376bb48b Mon Sep 17 00:00:00 2001 From: James Hutchinson Date: Thu, 29 Oct 2026 18:52:15 -0700 Subject: [PATCH 3/4] Say which code the HUB75 example's line numbers name The example cites the encoder and its test by line, and the fix moved those lines: the page now says its line numbers are the code before the fix. **Docs/CI** - docs/work/present/atlas/example-hub75-seam-row.md: one clause in the intro. --- docs/work/present/atlas/example-hub75-seam-row.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/work/present/atlas/example-hub75-seam-row.md b/docs/work/present/atlas/example-hub75-seam-row.md index af1ba2f4..3f490e79 100644 --- a/docs/work/present/atlas/example-hub75-seam-row.md +++ b/docs/work/present/atlas/example-hub75-seam-row.md @@ -1,6 +1,6 @@ # MoonAtlas example: a bright row at a HUB75 panel's seam -A report from the bench: two 64x64 HUB75 panels chained side by side, 128x64 on an ESP32, show a bright line along row 32 or 33. The maintainer knows the driver's name and nothing of its wire format. Three queries and one 60-line read take them from the symptom to a hypothesis with predictions to check on the panels, and to the test that missed it, and a MoonLive script then puts the prediction on the panels with no firmware build. Output from the tree on 2026-10-29; the transcript at the end is the session as it ran. +A report from the bench: two 64x64 HUB75 panels chained side by side, 128x64 on an ESP32, show a bright line along row 32 or 33. The maintainer knows the driver's name and nothing of its wire format. Three queries and one 60-line read take them from the symptom to a hypothesis with predictions to check on the panels, and to the test that missed it, and a MoonLive script then puts the prediction on the panels with no firmware build. Output from the tree on 2026-10-29, and line numbers name the encoder and its test as they were before the fix; the transcript at the end is the session as it ran. ## What it is From 5887a629940d146e9e9e62c1ed31163817e2cc03 Mon Sep 17 00:00:00 2001 From: ewowi Date: Fri, 30 Oct 2026 08:43:11 +0100 Subject: [PATCH 4/4] Keep the HUB75 fix to the code: the example page goes, and the encoder's comment says what is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Light domain** - Hub75Slots.h: the paragraph on the lit window's earlier behavior goes; git holds it. **Docs/CI** - docs/work/present/atlas/example-hub75-seam-row.md removed: it needs the MoonAtlas branch, which is not upstream. - docgen.json: warnings 2050 → 2038. Co-Authored-By: Claude Opus 5.5 --- docs/reference/metrics/docgen.json | 8 +- .../present/atlas/example-hub75-seam-row.md | 166 ------------------ src/light/drivers/Hub75Slots.h | 2 - 3 files changed, 4 insertions(+), 172 deletions(-) delete mode 100644 docs/work/present/atlas/example-hub75-seam-row.md diff --git a/docs/reference/metrics/docgen.json b/docs/reference/metrics/docgen.json index 6d6ab8dd..47b9dcb3 100644 --- a/docs/reference/metrics/docgen.json +++ b/docs/reference/metrics/docgen.json @@ -1,10 +1,10 @@ { - "(appendix lines)": 4335, - "(total)": 2050, - "appendix sections nothing refers to": 192, + "(appendix lines)": 4325, + "(total)": 2038, + "appendix sections nothing refers to": 186, "member deep dives": 37, "multi-line comment blocks": 290, "over-wide comment lines": 718, "over-wide doc lines": 34, - "split paragraphs": 779 + "split paragraphs": 773 } diff --git a/docs/work/present/atlas/example-hub75-seam-row.md b/docs/work/present/atlas/example-hub75-seam-row.md deleted file mode 100644 index 3f490e79..00000000 --- a/docs/work/present/atlas/example-hub75-seam-row.md +++ /dev/null @@ -1,166 +0,0 @@ -# MoonAtlas example: a bright row at a HUB75 panel's seam - -A report from the bench: two 64x64 HUB75 panels chained side by side, 128x64 on an ESP32, show a bright line along row 32 or 33. The maintainer knows the driver's name and nothing of its wire format. Three queries and one 60-line read take them from the symptom to a hypothesis with predictions to check on the panels, and to the test that missed it, and a MoonLive script then puts the prediction on the panels with no firmware build. Output from the tree on 2026-10-29, and line numbers name the encoder and its test as they were before the fix; the transcript at the end is the session as it ran. - -## What it is - -``` -$ uv run moondeck/docs/show_atlas.py what Hub75Driver -module:Hub75Driver: Drives HUB75 LED panels straight from the board's own pins, with no receiving card in between. - defined_in -> src/light/drivers/Hub75Driver.h - documented_by -> docs/moonmodules/light/drivers.md#hub75 - … eleven has_control lines, bitDepth to whiteMode … - is -> Hub75Driver src/light/drivers/Hub75Driver.h:34 - tests <- test/unit/light/unit_Hub75Driver_pins.cpp - tests <- test/unit/light/unit_Hub75Slots.cpp -class:Hub75Driver src/light/drivers/Hub75Driver.h:34 - derives -> DriverBase src/light/drivers/DriverBase.h:24 - defines <- src/light/drivers/Hub75Driver.h [34] - is <- module:Hub75Driver - … three mentions lines: two shipped plans and the MoonCore plan … -``` - -The driver, its doc section and its two test files, one of them named for the encoder. - -## Where the wire format lives - -``` -$ uv run moondeck/docs/show_atlas.py impact Hub75Bus.h -included by (4) - src/light/drivers/Hub75Driver.h - src/light/drivers/Hub75Slots.h - src/module_types.cpp - src/platform/esp32/platform_esp32_hub75.cpp -unit tests (2) - test/unit/light/unit_Hub75Driver_pins.cpp - test/unit/light/unit_Hub75Slots.cpp -scenarios: none -MoonWalk steps (1) - step:driver-setup/add-hub75 Add the HUB75 driver. -docs (1) - docs/work/present/Plan-20261025 - MoonCore under other apps (2 of 12).md -claims (2) - docs/work/present/atlas/areas/platform-fable.json#plat-014 A HUB75 geometry encodes only with 2 to 6 bit planes, a height that divides by its scan rate, and an even number of rows per address step, since each step drives an upper and a lower row together. - docs/work/present/atlas/areas/platform-fable.json#plat-016 In the 16-bit HUB75 bus word the OE bit is set where the panel is lit and the platform inverts the pin, since OE is active low and a peripheral idles its lines low. -``` - -The bus contract has one encoder, `Hub75Slots.h`, and one platform port. A search of the claims for HUB75 adds the one that matters most: - -> `drv-101`: hub75Encode lays the frame plane-major with the latch riding the last column's word, the data words naming the row latched one step earlier, and brightness as the number of lit words per row, doubling per plane. - -Two facts now frame the symptom. On a 64-row panel at 1/32 scan, address step r lights row r and row r + 32 (`plat-014`), so row 31, the last of the upper half, sits at the seam. And the row a panel lights is the one latched a step earlier (`drv-101`), so the first row of each pass lights whatever the previous pass left in the latch. - -## The read - -`hub75Encode` (`src/light/drivers/Hub75Slots.h:40-102`) loops plane, then scan row, then column. Its comment at line 68 says what row 0 shows: "for row 0 the last row of the previous plane or frame". The lit window at lines 75-77 is computed from the current `plane`. - -## The hypothesis - -The last scan row is lit with the next plane's on-time. While row r ≥ 1 of plane p clocks in, the panel lights row r − 1 of the same plane p, for plane p's window: correct. While row 0 of plane p clocks in, the latch holds row 31 of plane p − 1, lit for plane p's window, twice as long as its own. Row 31's top plane carries into the next frame's plane 0 and lights for plane 0's window. - -So rows 31 and 63 show each lower bit at twice its weight and the top bit at a fraction of it. Row 31 is row 32 counted from one: the seam the bench reports. - -The panels confirm it (On the bench, below). Its predictions: - -1. The bottom edge row of each panel, row 63, is off the same way. -2. The error depends on the value. Full white (255) looks right, since the doubled lower bits and the shrunken top bit sum to the same total; a value below 128 shows about twice as bright (0x7F reads as 254); a value just above 128 shows nearly dark. -3. One panel alone shows it, so the chain plays no part. - -## Why the tests pass - -`test/unit/light/unit_Hub75Slots.cpp:185`, "the DMA wrap lights the last row once, from row 0's data words", fills every pixel with 255, the one value where the error cancels, and checks that the last row is lit, not for how long per plane. The comment below it records an earlier bug of the same family on the same panel: "row 63 ghosting onto row 32, rows 0 and 32 brighter, flashing", fixed then through the output-enable polarity. - -## The fix - -Row 0 of each plane takes the lit window of the plane its latched data came from: plane p − 1, and the top plane for plane 0. The test that pins it sums each row's on-time per plane for a mid value such as 0x7F and requires every row to agree; it fails against the old encoder, where rows 31 and 63 light 123 words against every other row's 62. - -## Where the graph stops - -The graph pointed at the right file and handed over the two facts that frame the symptom; the timing argument came from reading the encoder, since the graph holds file, class and test edges and no data flow. The earlier ghosting bug sat in a test comment, which the graph indexes as a test of the module and not as a lesson. - -## On the bench - -`ramp.mle` (in the transcript below) runs on the MatrixPortal S3 driving the two chained panels, loaded as a script with no firmware build. - -1. **Before the fix, in sync with the panel:** a short dark segment on the seam row, right of the middle, darkest at its left end and fading to the right. That is prediction 2 with the driver's brightness curve on, which maps the ramp's values there to just above 128 on the wire. -2. **Before the fix, the camera pushed out of sync:** the shutter separates the bit planes' time slots, and a bright line stands out on the last row of each half, the seam and the bottom edge, rows 31 and 63. It lines up with the upper section's bottom row, not the lower section's top row, which rules out rows 0 and 32. -3. **After the fix, flashed over the air through MoonBase:** the seam and the bottom edge match their neighbors across the whole ramp. - -## Transcript - -The session as it ran, from the product owner's report to the test pattern that checks it on the panels. Other work in the same session is left out; the tool steps are summarized in italics. - -**James:** example scenario. A multi panel hub75 with 64x64 panels, 2 wide 1 deep on esp32, the display shows a brightness along the 32 or 33 row. - -*Claude runs `what Hub75Driver`, `why Hub75Driver` and `impact Hub75Bus.h`, searches the claims for HUB75, reads `hub75Encode` in `Hub75Slots.h` and the geometry in `Hub75Bus.h`, and lists the encoder's tests.* - -**Claude:** Here's how the graph got me to a hypothesis, which fits your symptom closely. It's a hypothesis, not a confirmed root cause, until it's checked on the panels. - -How the graph got there, in three queries and one read: - -- `what Hub75Driver` named the header, its doc section and its two unit tests. -- `impact Hub75Bus.h` showed the encoder, `Hub75Slots.h`, is the one place the wire format lives. -- Two claims gave the key facts. `drv-101`: row r's data words are shown while the panel is still addressed to row r−1. `plat-014`: each address step lights an upper and a lower row together. - -Then I read `hub75Encode` itself, about 60 lines. - -The hypothesis: the last scan row is lit with the wrong bit plane's on-time. Your chain is 128×64 at 1/32 scan, so there are 32 scan rows, and step r lights row r and row r+32. - -- Why the address lags: the panel shows the row last latched, so while row r's data clocks in, row r−1 is lit. For r ≥ 1, row r−1 of plane p is lit for plane p's window, which is correct. -- The wrap: at row 0 of plane p, the latch still holds row 31 from plane p−1, the last row of the plane before. `Hub75Slots.h:69-77` computes the lit window from the current plane p, not from p−1. -- The effect on row 31: every lower bit shows for twice its correct time. The top bit, carried into the next frame's plane 0, shows for only plane 0's short window. -- That row's position: 0-based row 31 is your 1-based row 32. It's the last row of the upper half, right at the seam between the halves. Its partner is row 63, the panel's bottom edge. - -Predictions you can check on the panels: - -1. The bottom edge row of each panel (row 64, 1-based) should be off in the same way as the seam row. -2. It depends on color value. Full white (255) should look correct, because the doubled lower bits and the shrunken top bit happen to add up to the same total. Values below 128 should look about twice as bright (0x7F comes out as roughly 254). Values just above 128 should look nearly dark. -3. It shouldn't depend on the chain. One 64×64 panel alone should show it too. - -Why the tests miss it: the wrap test at `unit_Hub75Slots.cpp:185` fills every pixel with 255, the one value where the error cancels out. It checks that the last row is lit once, not for how long per plane. The comment just after it records an earlier bug in the same family: "row 63 ghosting onto row 32, rows 0 and 32 brighter, flashing", fixed then through the output-enable polarity. - -If it's confirmed, the fix is small. For row 0 of each plane, compute the lit window from the plane the latched data came from: plane p−1, or the top plane for plane 0. The test adds up each row's on-time per plane for a mid value such as 0x7F and requires every row to come out the same; that test fails against today's code. - -Your MatrixPortal S3 drives HUB75, so it could check predictions 1 and 2 directly. - -**James:** make a test pattern that highlights the hub75 issue - -*Claude reads the MoonLive README and three of the basic effect scripts, then `gradient.mle`, and checks the driver's brightness curve.* - -**James:** could mmlive be used? - -**Claude:** Yes, this is exactly what MoonLive is for. A MoonLive effect compiles on the device, so the pattern can be pasted into a MoonLive effect card on the MatrixPortal and runs with no firmware build or flash. - -`gradient.mle` won't do. Its value follows the light index, so every row has a different color and an odd row can't stand out. What the diagnosis needs is a ramp across the width that is identical on every row. Then a row whose bit planes are mis-timed shows up as a stripe that doesn't match the rows around it. - -*Claude writes `moonlive/effects/basics/ramp.mle`:* - -```cpp -// Ramp: gray rising from black on the left to white on the right, the same on every row, the test pattern for a panel's bit planes. -// Every row matches its neighbors, so a row that differs is a fault in how the panel is driven, not in the picture: -// a HUB75 row lit with the wrong plane's on-time reads brighter than its neighbors below half gray and darker just above it. - -class RampEffect { - - int dimensions() { return 2; } - - string tags() { return "💫"; } - - void tick() { - int span = width - 1; - if (span < 1) { span = 1; } - for (int x = 0; x < width; x = x + 1) { - int v = div(x * 255, span); - for (int y = 0; y < height; y = y + 1) { - setRGB(y * width + x, v, v, v); - } - } - } -} -``` - -**Claude:** The ramp draws gray from black at the left to white at the right, identical on every row and spanning both chained panels. If the hypothesis holds, rows 31 and 63 (rows 32 and 64 counting from one) show a brighter stripe in the left half of the ramp, below mid-gray. Just right of the middle, the stripe turns dark, and at the far right, white, it disappears. - -It also works with gamma or CIE correction on: the ramp still crosses every bit boundary, just at different x positions. For the cleanest reading, set the driver's brightness curve to linear. - -To try it on the MatrixPortal before any release carries it: add a MoonLive effect, create a new script, paste the file's contents and save. It compiles on the next tick. diff --git a/src/light/drivers/Hub75Slots.h b/src/light/drivers/Hub75Slots.h index 484077cc..d635308c 100644 --- a/src/light/drivers/Hub75Slots.h +++ b/src/light/drivers/Hub75Slots.h @@ -32,8 +32,6 @@ namespace mm { /// /// The lit window follows the data in the latch: row 0's words light the last row of the plane before, so they take that plane's window, and plane 0's take the top plane's, where the buffer wraps. /// -/// With the current plane's window, the last row of each half weighed its bits double and its top bit almost nothing: a line at the seam and the bottom edge, hidden by white. -/// /// ## Brightness is time /// /// A panel's chips only switch a column on or off, so plane p lights 2^p as long as plane 0 and brightness shortens every plane's window.