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/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..d635308c 100644 --- a/src/light/drivers/Hub75Slots.h +++ b/src/light/drivers/Hub75Slots.h @@ -30,6 +30,8 @@ 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: 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. +/// /// ## 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. @@ -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..75806bc1 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,36 @@ 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 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; + 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()); + + // 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); + 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 +251,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 +273,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;