From c2f96f02c0689287e20dfdb989253e701dee7791 Mon Sep 17 00:00:00 2001 From: Shura Fedorovskyi Date: Fri, 16 Oct 2026 11:29:43 +0400 Subject: [PATCH 1/2] Describe a backlight strand the way Hyperion does The backlight layout took one count for the top and bottom and another for the sides, and placed its lights clockwise from the top-left corner. A real strand rarely matches either: it enters at whichever corner the wiring reaches, runs whichever way the installer ran it, and is often missing the stretch a TV stand covers. Mapped against those assumptions the picture arrives rotated, or wrapped around lights that are not there. Hyperion's classic layout already names all of this, so this follows it and a layout copied from there carries over: - `top`, `right`, `bottom` and `left`, a count per edge, replacing `across` and `down`. - `gapLength` and `gapStart`: the lights the strand does not have, and where they would have been. A length and a place rather than a dark edge, so the gap can sit mid-edge where a stand actually is. - `position` rotates the strand past its entry corner and `reverse` runs it the other way, Hyperion's own two names. - `startCorner` names that corner, which Hyperion leaves to `position` alone. `placeLights` walks the perimeter through `mod()` rather than four hard-coded segments, so these reorder INDICES only: the emitted coordinates stay a property of the box. A gap is the one thing that removes a light, and `perimeterAt` maps a strand position across it, which keeps the walk a plain rotation. Tests read the shipped script rather than a copy. Each of the eight wirings is written out as the sequence it emits on a 4x3 wall, which catches a walk visiting the right cells in the wrong order; `position` is checked as a rotation in both directions; a gap is checked to remove a run and leave every other light where it was; and a strand coarser than the wall still reaches its edge pixels. With the edges equal and no gap the walk is unchanged, so an existing frame keeps its order. Verified against a 60x34, 184-light strand. The golden IR line is regenerated, since main pins every shipped script's compiled form. Hyperion's `ledsHDepth` and `ledsVDepth` are deliberately absent: how far into the picture a light samples belongs to the effect, not to where the lights are. Co-Authored-By: Claude Opus 5 (1M context) --- docs/reference/metrics/docgen.md | 18 ++- moonlive/layouts/backlight.mll | 89 +++++++++++--- test/unit/light/moonlive_ir_golden.inc | 2 +- test/unit/light/unit_MoonLiveLayout.cpp | 153 ++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 16 deletions(-) diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index 4414954e8..bfc785856 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**0 error(s)** and **3080 warning(s)** across 290 page(s). +**1 error(s)** and **3080 warning(s)** across 291 page(s). **4398 appendix line(s)** after `@technical`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. @@ -16,6 +16,7 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| +| help.json stale | 1 | 0 | | over-wide comment lines | 0 | 1565 | | split paragraphs | 0 | 856 | | multi-line comment blocks | 0 | 311 | @@ -30,6 +31,7 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier | Summary page | Errors | Warnings | |---|---:|---:| +| `(unassigned)` | 1 | 0 | | `(tests, no card)` | 0 | 1956 | | `platform/index.md` | 0 | 645 | | `core/supporting.md` | 0 | 113 | @@ -230,3 +232,17 @@ By rule, warnings: 16 appendix sections nothing refers to, 8 split paragraphs. By rule, warnings: 14 appendix sections nothing refers to, 3 split paragraphs. +### (unassigned) + +**1 error(s)** and **0 warning(s)** across 1 file(s). + +| Findings | File | | +|---:|---|---| +| 1 | `docs/help/help.json` | error | +#### `docs/help/help.json` + +- help.json stale unchecked: dlsym(0x7c0d2f20, clang_CXXMethod_isDeleted): symbol not found. Please ensure that your python bindings are compatible with your libclang.so version. + + +By rule, errors: 1 help.json stale. + diff --git a/moonlive/layouts/backlight.mll b/moonlive/layouts/backlight.mll index 8bfe3eb62..afacaba78 100644 --- a/moonlive/layouts/backlight.mll +++ b/moonlive/layouts/backlight.mll @@ -1,10 +1,17 @@ -// A backlight strip around a wall: one strand clockwise from the top-left corner, its lights spread over the wall's edge pixels, each corner light shared by the two sides it joins. +// A backlight strand around a wall, its lights spread over the wall's edge pixels. Hyperion's shape, so a layout copied from there carries over: a count per edge, a gap, and the strand's entry and direction. class BacklightLayout { byte wallCols = 64; byte wallRows = 32; - byte across = 30; - byte down = 15; + byte top = 30; + byte right = 15; + byte bottom = 30; + byte left = 15; + int gapStart = 0; + int gapLength = 0; + byte startCorner = 0; + int position = 0; + byte reverse = 0; int dimensions() { return 2; } @@ -13,22 +20,76 @@ class BacklightLayout { void defineControls() { addControl("wallCols", wallCols, 2, 255); // the wall's width in pixels addControl("wallRows", wallRows, 2, 255); // the wall's height in pixels - addControl("across", across, 2, 255); // strip lights along the top, both corners included - addControl("down", down, 2, 255); // strip lights along a side, both corners included + addControl("top", top, 2, 255); // strand lights along the top, both corners included + addControl("right", right, 2, 255); // and along the right + addControl("bottom", bottom, 2, 255); // and along the bottom + addControl("left", left, 2, 255); // and along the left + addControl("gapLength", gapLength, 0, 2000); // lights the strand does not have, for the stretch a TV stand covers + addControl("gapStart", gapStart, 0, 2000); // where that gap begins, counted round from the top-left corner + addSelect("startCorner", startCorner, "TL|TR|BR|BL"); // the corner the strand enters at, clockwise from top-left + addControl("position", position, 0, 2000); // lights along the strand, past startCorner, where it actually begins + addControl("reverse", reverse, 0, 1); // 1 runs the strand the other way round } - void placeLights() { - for (int i = 0; i < across - 1; i = i + 1) { - addLight(i * (wallCols - 1) / (across - 1), 0, 0); + // One step along an edge of `count` lights spanning `span` pixels. A single light sits at the + // start corner, where the span would divide by zero. + int along(int i, int count, int span) { + if (count <= 1) { return 0; } + return i * span / (count - 1); + } + + // The light at perimeter position `k`: clockwise from the top-left, each edge one short of its + // own count, since the next edge opens with the corner they share. + void emitAt(int k) { + int j = k; + if (j < top - 1) { + addLight(along(j, top, wallCols - 1), 0, 0); + return; } - for (int i = 0; i < down - 1; i = i + 1) { - addLight(wallCols - 1, i * (wallRows - 1) / (down - 1), 0); + j = j - (top - 1); + if (j < right - 1) { + addLight(wallCols - 1, along(j, right, wallRows - 1), 0); + return; } - for (int i = 0; i < across - 1; i = i + 1) { - addLight(wallCols - 1 - i * (wallCols - 1) / (across - 1), wallRows - 1, 0); + j = j - (right - 1); + if (j < bottom - 1) { + addLight(wallCols - 1 - along(j, bottom, wallCols - 1), wallRows - 1, 0); + return; } - for (int i = 0; i < down - 1; i = i + 1) { - addLight(0, wallRows - 1 - i * (wallRows - 1) / (down - 1), 0); + j = j - (bottom - 1); + addLight(0, wallRows - 1 - along(j, left, wallRows - 1), 0); + } + + // The gap is a stretch of the frame the strand skips, so a strand position past it sits that many + // lights further round. This is what keeps the walk a plain rotation with a gap in it. + int perimeterAt(int i) { + if (gapLength > 0 && i >= gapStart) { return i + gapLength; } + return i; + } + + // Where index 0 sits, per corner, in STRAND positions: the corner's place on the frame, less the + // gap if the gap lies before it. + int strandStart() { + int k = 0; + if (startCorner == 1) { k = top - 1; } + else if (startCorner == 2) { k = top - 1 + right - 1; } + else if (startCorner == 3) { k = top - 1 + right - 1 + bottom - 1; } + if (gapLength > 0 && k >= gapStart + gapLength) { return k - gapLength; } + if (gapLength > 0 && k >= gapStart) { return gapStart; } + return k; + } + + void placeLights() { + int full = top - 1 + right - 1 + bottom - 1 + left - 1; + int n = full - gapLength; + if (gapLength < 1) { n = full; } + if (n < 1) { return; } + // Along the STRAND, not around the frame: on a reversed strand the entry moves the other way too. + int s = mod(strandStart() + position, n); + if (reverse == 1) { s = mod(strandStart() - position + n + n, n); } + for (int i = 0; i < n; i = i + 1) { + if (reverse == 1) { emitAt(perimeterAt(mod(s + n - mod(i, n), n))); } + else { emitAt(perimeterAt(mod(s + i, n))); } } } } diff --git a/test/unit/light/moonlive_ir_golden.inc b/test/unit/light/moonlive_ir_golden.inc index f2db03a89..dd64585ff 100644 --- a/test/unit/light/moonlive_ir_golden.inc +++ b/test/unit/light/moonlive_ir_golden.inc @@ -1,4 +1,4 @@ - {"layouts/backlight.mll", 0x45cad59768d0942aull}, + {"layouts/backlight.mll", 0xdcc9e2fe74378942ull}, {"layouts/diagonal.mll", 0x8d903da3498de21aull}, {"layouts/grid.mll", 0x19ba4f6c02fbec77ull}, {"layouts/lattice.mll", 0xbf932d2667cc1e2bull}, diff --git a/test/unit/light/unit_MoonLiveLayout.cpp b/test/unit/light/unit_MoonLiveLayout.cpp index af882f67b..7a79acee0 100644 --- a/test/unit/light/unit_MoonLiveLayout.cpp +++ b/test/unit/light/unit_MoonLiveLayout.cpp @@ -7,6 +7,7 @@ #include "doctest.h" #include "MoonLiveScriptFixture.h" +#include "moonlive_script_files.h" #include "../core/moonlive_script_wrap.h" #include "light/moonlive/MoonLiveLayout.h" #include "light/moonlive/MoonLiveModifier.h" // the cycle-break case below drives a modifier @@ -19,6 +20,12 @@ #include #include +#include +#include +#include +#include +#include +#include #include #include @@ -45,6 +52,37 @@ std::vector place(const char* script) { l.placeLights(sink); return out; } + +/// The shipped backlight layout run at one wiring, collecting what it places; the wiring is the JSON a device would apply, since the script declares its own controls. +std::vector placeBacklight(const char* wiring) { + const std::string src = readText(scriptRoot() / "layouts" / "backlight.mll"); + REQUIRE_FALSE(src.empty()); + + MoonLiveLayout l; + l.defineControls(); + l.setScript(mmWriteScript(src.c_str())); + l.prepare(); + auto& cs = l.controls(); + for (uint8_t i = 0; i < cs.count(); i++) + if (cs[i].name) applyControlValue(cs[i], wiring, cs[i].name, ApplyPolicy::Clamp); + l.prepare(); // the controls size the walk, so the layout is rebuilt against them + + std::vector out; + CoordSink sink{ + [](void* ctx, nrOfLightsType, lengthType x, lengthType y, lengthType z) { + static_cast*>(ctx)->push_back({x, y, z}); + }, + nullptr, &out}; + l.placeLights(sink); + return out; +} + +/// The distinct cells a walk visits, for the shape-is-invariant checks below. +std::set> cellsOf(const std::vector& p) { + std::set> out; + for (const auto& c : p) out.insert({c.x, c.y}); + return out; +} } // namespace TEST_CASE("the default script lays out a grid, one light per cell") { @@ -932,4 +970,119 @@ TEST_CASE("the loader accepts any role extension, and nothing else") { CHECK(std::string(l.status()).find("must end in") != std::string::npos); } + +// --- The shipped backlight layout: its controls choose an ORDER, not a shape ------------------ + +TEST_CASE("backlight.mll: each edge contributes its own count, each corner once") { + const auto p = placeBacklight("{\"wallCols\":60,\"wallRows\":34,\"top\":60,\"right\":34,\"bottom\":60,\"left\":34}"); + CHECK(p.size() == 184); // 59 + 33 + 59 + 33: every edge one short of its own count, the corners shared + + const auto cells = cellsOf(p); + CHECK(cells.size() == p.size()); // no cell twice, so no corner counted by both its sides + // On the edges and filling the box: a light off the perimeter would light the wall's middle. + for (const auto& c : p) { + CHECK(c.z == 0); + CHECK((c.x == 0 || c.x == 59 || c.y == 0 || c.y == 33)); + } + CHECK(cells.count({0, 0}) == 1); + CHECK(cells.count({59, 0}) == 1); + CHECK(cells.count({59, 33}) == 1); + CHECK(cells.count({0, 33}) == 1); +} + +// Written out, not compared to each other: "the eight differ" passes on a walk visiting the right cells in the wrong order. A 4x3 wall is ten lights, so each wiring is one line. +TEST_CASE("backlight.mll: each wiring starts at its corner and runs its own way round") { + struct Wiring { int corner; int reverse; const char* walk; }; + // Coordinates as "xy", clockwise meaning x rising along the top, as the canonical walk does. + const Wiring cases[8] = { + {0, 0, "00 10 20 30 31 32 22 12 02 01"}, // top-left, forward: the shipped default + {0, 1, "00 01 02 12 22 32 31 30 20 10"}, // and the same corner reversed + {1, 0, "30 31 32 22 12 02 01 00 10 20"}, + {1, 1, "30 20 10 00 01 02 12 22 32 31"}, + {2, 0, "32 22 12 02 01 00 10 20 30 31"}, + {2, 1, "32 31 30 20 10 00 01 02 12 22"}, + {3, 0, "02 01 00 10 20 30 31 32 22 12"}, + {3, 1, "02 12 22 32 31 30 20 10 00 01"}, + }; + for (const auto& c : cases) { + char wiring[160]; + std::snprintf(wiring, sizeof(wiring), + "{\"wallCols\":4,\"wallRows\":3,\"top\":4,\"right\":3,\"bottom\":4,\"left\":3," + "\"startCorner\":%d,\"reverse\":%d}", c.corner, c.reverse); + const auto p = placeBacklight(wiring); + REQUIRE(p.size() == 10); + std::string got; + for (const auto& k : p) { + if (!got.empty()) got += ' '; + got += static_cast('0' + k.x); + got += static_cast('0' + k.y); + } + CHECK(got == std::string(c.walk)); + } +} + +TEST_CASE("backlight.mll: offset slides the strand along itself, whichever way it runs") { + const auto at0 = placeBacklight("{\"wallCols\":60,\"wallRows\":34,\"top\":60,\"right\":34,\"bottom\":60,\"left\":34}"); + const auto at2 = placeBacklight("{\"wallCols\":60,\"wallRows\":34,\"top\":60,\"right\":34,\"bottom\":60,\"left\":34,\"position\":2}"); + REQUIRE(at0.size() == at2.size()); + CHECK(cellsOf(at0) == cellsOf(at2)); + + // A ROTATION by two: light i of the offset walk is light i+2 of the plain one. + for (size_t i = 0; i < at0.size(); i++) { + const auto& expect = at0[(i + 2) % at0.size()]; + CHECK(at2[i].x == expect.x); + CHECK(at2[i].y == expect.y); + } + + // Along the STRAND, so a reversed one moves the other way round the frame. Counted clockwise regardless, an offset would send a reversed strand backwards past its own corner. + const auto ccw0 = placeBacklight("{\"wallCols\":4,\"wallRows\":3,\"top\":4,\"right\":3,\"bottom\":4,\"left\":3,\"reverse\":1}"); + const auto ccw2 = placeBacklight("{\"wallCols\":4,\"wallRows\":3,\"top\":4,\"right\":3,\"bottom\":4,\"left\":3,\"reverse\":1,\"position\":2}"); + REQUIRE(ccw0.size() == 10); + REQUIRE(ccw2.size() == 10); + for (size_t i = 0; i < ccw0.size(); i++) { + const auto& expect = ccw0[(i + 2) % ccw0.size()]; + CHECK(ccw2[i].x == expect.x); + CHECK(ccw2[i].y == expect.y); + } +} + +// Hyperion's gap: the lights a strand does not have. A length and a place in the strand, not a dark edge, so the stretch a TV stand covers can sit mid-edge. +TEST_CASE("backlight.mll: a gap removes a run of lights and leaves the rest where they were") { + const char* full = "{\"wallCols\":8,\"wallRows\":6,\"top\":8,\"right\":4,\"bottom\":8,\"left\":4}"; + const char* gapped = "{\"wallCols\":8,\"wallRows\":6,\"top\":8,\"right\":4,\"bottom\":8,\"left\":4," + "\"gapStart\":11,\"gapLength\":3}"; + const auto whole = placeBacklight(full); + const auto p = placeBacklight(gapped); + REQUIRE(whole.size() == 20); + REQUIRE(p.size() == 17); // the three it does not have + + std::string got; + for (const auto& k : p) { + if (!got.empty()) got += ' '; + got += static_cast('0' + k.x); + got += static_cast('0' + k.y); + } + // The bottom loses its middle three; every other light sits exactly where it did. + CHECK(got == std::string("00 10 20 30 40 50 60 70 71 73 75 35 25 15 05 04 02")); + + // A gap REMOVES, it does not move: each remaining cell is one the whole strand also had. + const auto had = cellsOf(whole); + for (const auto& k : p) CHECK(had.count({k.x, k.y}) == 1); +} + +// A strand with fewer lights than the edge has pixels gets them spread along it, so one script serves a 30-light strip on a 128-pixel wall. The corners still land on the box. +TEST_CASE("backlight.mll: a strand coarser than the wall still spans its edges") { + const auto p = placeBacklight("{\"wallCols\":128,\"wallRows\":128,\"top\":30,\"right\":20,\"bottom\":30,\"left\":20}"); + CHECK(p.size() == 2 * 29 + 2 * 19); + + int maxX = 0, maxY = 0; + for (const auto& c : p) { + maxX = c.x > maxX ? c.x : maxX; + maxY = c.y > maxY ? c.y : maxY; + } + // The bounding box stays the wall's, so the strip sits on its edge pixels rather than inside them. + CHECK(maxX == 127); + CHECK(maxY == 127); +} + #endif // MM_MOONLIVE_HAS_HOST_JIT: the script must COMPILE for the count to mean anything. From ac4e13de30ee47f8d96df7c35d8bd28295084d3b Mon Sep 17 00:00:00 2001 From: ewowi Date: Sun, 18 Oct 2026 22:47:10 +0200 Subject: [PATCH 2/2] Keep every backlight light on the wall, whatever the settings A position past the strand's length now counts round it again, reversed strands too, and a gap that runs past the top-left corner wraps onto the top edge instead of skipping the wrong lights. The gap is capped at every light but one, so any setting places lights. Tests pin a large position on a reversed strand, a gap crossing the corner, a gap ending at the last light, and every light inside the wall; docgen.md is back to the base, since the libclang error it recorded was the machine's, not the tree's. --- docs/reference/metrics/docgen.md | 18 +-------- moonlive/layouts/backlight.mll | 38 ++++++++++++------ test/unit/light/moonlive_ir_golden.inc | 2 +- test/unit/light/unit_MoonLiveLayout.cpp | 53 ++++++++++++++++++------- 4 files changed, 67 insertions(+), 44 deletions(-) diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index bfc785856..4414954e8 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**1 error(s)** and **3080 warning(s)** across 291 page(s). +**0 error(s)** and **3080 warning(s)** across 290 page(s). **4398 appendix line(s)** after `@technical`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. @@ -16,7 +16,6 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| -| help.json stale | 1 | 0 | | over-wide comment lines | 0 | 1565 | | split paragraphs | 0 | 856 | | multi-line comment blocks | 0 | 311 | @@ -31,7 +30,6 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier | Summary page | Errors | Warnings | |---|---:|---:| -| `(unassigned)` | 1 | 0 | | `(tests, no card)` | 0 | 1956 | | `platform/index.md` | 0 | 645 | | `core/supporting.md` | 0 | 113 | @@ -232,17 +230,3 @@ By rule, warnings: 16 appendix sections nothing refers to, 8 split paragraphs. By rule, warnings: 14 appendix sections nothing refers to, 3 split paragraphs. -### (unassigned) - -**1 error(s)** and **0 warning(s)** across 1 file(s). - -| Findings | File | | -|---:|---|---| -| 1 | `docs/help/help.json` | error | -#### `docs/help/help.json` - -- help.json stale unchecked: dlsym(0x7c0d2f20, clang_CXXMethod_isDeleted): symbol not found. Please ensure that your python bindings are compatible with your libclang.so version. - - -By rule, errors: 1 help.json stale. - diff --git a/moonlive/layouts/backlight.mll b/moonlive/layouts/backlight.mll index afacaba78..af0b00832 100644 --- a/moonlive/layouts/backlight.mll +++ b/moonlive/layouts/backlight.mll @@ -12,6 +12,9 @@ class BacklightLayout { byte startCorner = 0; int position = 0; byte reverse = 0; + int g0 = 0; + int gl = 0; + int gw = 0; int dimensions() { return 2; } @@ -60,33 +63,44 @@ class BacklightLayout { addLight(0, wallRows - 1 - along(j, left, wallRows - 1), 0); } - // The gap is a stretch of the frame the strand skips, so a strand position past it sits that many - // lights further round. This is what keeps the walk a plain rotation with a gap in it. + // The gap is a stretch of the frame the strand skips, counted round from the top-left, so a + // strand position past it sits that many lights further round. One that runs past the top-left + // corner wraps onto the top edge: its part there, gw lights long, is skipped before the rest. int perimeterAt(int i) { - if (gapLength > 0 && i >= gapStart) { return i + gapLength; } + if (gw > 0) { return i + gw; } + if (gl > 0 && i >= g0) { return i + gl; } return i; } - // Where index 0 sits, per corner, in STRAND positions: the corner's place on the frame, less the - // gap if the gap lies before it. + // Index 0 sits at the start corner, in strand positions: the lights before it, or the first light + // past the gap when the corner falls inside it. int strandStart() { int k = 0; if (startCorner == 1) { k = top - 1; } else if (startCorner == 2) { k = top - 1 + right - 1; } else if (startCorner == 3) { k = top - 1 + right - 1 + bottom - 1; } - if (gapLength > 0 && k >= gapStart + gapLength) { return k - gapLength; } - if (gapLength > 0 && k >= gapStart) { return gapStart; } + if (gw > 0) { + if (k < gw || k >= g0) { return 0; } + return k - gw; + } + if (gl > 0 && k >= g0 + gl) { return k - gl; } + if (gl > 0 && k >= g0) { return g0; } return k; } void placeLights() { int full = top - 1 + right - 1 + bottom - 1 + left - 1; - int n = full - gapLength; - if (gapLength < 1) { n = full; } - if (n < 1) { return; } + // The gap reduced onto the frame, and at most every light but one, so any setting places lights. + g0 = mod(gapStart, full); + gl = gapLength; + if (gl > full - 1) { gl = full - 1; } + gw = g0 + gl - full; + if (gw < 0) { gw = 0; } + int n = full - gl; // Along the STRAND, not around the frame: on a reversed strand the entry moves the other way too. - int s = mod(strandStart() + position, n); - if (reverse == 1) { s = mod(strandStart() - position + n + n, n); } + int p = mod(position, n); + int s = mod(strandStart() + p, n); + if (reverse == 1) { s = mod(strandStart() - p + n, n); } for (int i = 0; i < n; i = i + 1) { if (reverse == 1) { emitAt(perimeterAt(mod(s + n - mod(i, n), n))); } else { emitAt(perimeterAt(mod(s + i, n))); } diff --git a/test/unit/light/moonlive_ir_golden.inc b/test/unit/light/moonlive_ir_golden.inc index dd64585ff..f2eb78d4c 100644 --- a/test/unit/light/moonlive_ir_golden.inc +++ b/test/unit/light/moonlive_ir_golden.inc @@ -1,4 +1,4 @@ - {"layouts/backlight.mll", 0xdcc9e2fe74378942ull}, + {"layouts/backlight.mll", 0x8fb9b3dac83279edull}, {"layouts/diagonal.mll", 0x8d903da3498de21aull}, {"layouts/grid.mll", 0x19ba4f6c02fbec77ull}, {"layouts/lattice.mll", 0xbf932d2667cc1e2bull}, diff --git a/test/unit/light/unit_MoonLiveLayout.cpp b/test/unit/light/unit_MoonLiveLayout.cpp index 7a79acee0..89a88cf0c 100644 --- a/test/unit/light/unit_MoonLiveLayout.cpp +++ b/test/unit/light/unit_MoonLiveLayout.cpp @@ -78,6 +78,17 @@ std::vector placeBacklight(const char* wiring) { } /// The distinct cells a walk visits, for the shape-is-invariant checks below. +// A walk written as "xy" cells in strand order, so an expected order reads as one line. +std::string walkOf(const std::vector& p) { + std::string got; + for (const auto& k : p) { + if (!got.empty()) got += ' '; + got += static_cast('0' + k.x); + got += static_cast('0' + k.y); + } + return got; +} + std::set> cellsOf(const std::vector& p) { std::set> out; for (const auto& c : p) out.insert({c.x, c.y}); @@ -983,6 +994,7 @@ TEST_CASE("backlight.mll: each edge contributes its own count, each corner once" for (const auto& c : p) { CHECK(c.z == 0); CHECK((c.x == 0 || c.x == 59 || c.y == 0 || c.y == 33)); + CHECK((c.x >= 0 && c.x < 60 && c.y >= 0 && c.y < 34)); // on the wall too: (60, 0) is on an edge's line but off the wall } CHECK(cells.count({0, 0}) == 1); CHECK(cells.count({59, 0}) == 1); @@ -1011,13 +1023,7 @@ TEST_CASE("backlight.mll: each wiring starts at its corner and runs its own way "\"startCorner\":%d,\"reverse\":%d}", c.corner, c.reverse); const auto p = placeBacklight(wiring); REQUIRE(p.size() == 10); - std::string got; - for (const auto& k : p) { - if (!got.empty()) got += ' '; - got += static_cast('0' + k.x); - got += static_cast('0' + k.y); - } - CHECK(got == std::string(c.walk)); + CHECK(walkOf(p) == std::string(c.walk)); } } @@ -1056,20 +1062,39 @@ TEST_CASE("backlight.mll: a gap removes a run of lights and leaves the rest wher REQUIRE(whole.size() == 20); REQUIRE(p.size() == 17); // the three it does not have - std::string got; - for (const auto& k : p) { - if (!got.empty()) got += ' '; - got += static_cast('0' + k.x); - got += static_cast('0' + k.y); - } // The bottom loses its middle three; every other light sits exactly where it did. - CHECK(got == std::string("00 10 20 30 40 50 60 70 71 73 75 35 25 15 05 04 02")); + CHECK(walkOf(p) == std::string("00 10 20 30 40 50 60 70 71 73 75 35 25 15 05 04 02")); // A gap REMOVES, it does not move: each remaining cell is one the whole strand also had. const auto had = cellsOf(whole); for (const auto& k : p) CHECK(had.count({k.x, k.y}) == 1); } +// The controls allow a position past the strand's own length; it counts round the strand again, whichever way the strand runs, rather than leaving the wall. +TEST_CASE("backlight.mll: a position past the strand's length wraps round it, reversed too") { + const char* wall = "{\"wallCols\":4,\"wallRows\":3,\"top\":4,\"right\":3,\"bottom\":4,\"left\":3,\"reverse\":1,\"position\":%d}"; + char at5[160], at25[160]; + std::snprintf(at5, sizeof(at5), wall, 5); + std::snprintf(at25, sizeof(at25), wall, 25); // 25 = two rounds of 10, then 5 + const auto p5 = placeBacklight(at5); + const auto p25 = placeBacklight(at25); + REQUIRE(p25.size() == 10); + CHECK(walkOf(p25) == walkOf(p5)); + for (const auto& c : p25) CHECK((c.x >= 0 && c.x < 4 && c.y >= 0 && c.y < 3)); +} + +// A gap counts round from the top-left like the strand, so one starting near the end of the frame runs on past the corner onto the top edge. +TEST_CASE("backlight.mll: a gap crossing the top-left corner skips its lights on both sides of it") { + // A 4x3 wall walks 00 10 20 30 31 32 22 12 02 01; a gap of two from the last light covers 01 and 00. + const auto p = placeBacklight("{\"wallCols\":4,\"wallRows\":3,\"top\":4,\"right\":3,\"bottom\":4,\"left\":3,\"gapStart\":9,\"gapLength\":2}"); + CHECK(walkOf(p) == std::string("10 20 30 31 32 22 12 02")); +} + +TEST_CASE("backlight.mll: a gap ending at the last light leaves the walk up to it") { + const auto p = placeBacklight("{\"wallCols\":4,\"wallRows\":3,\"top\":4,\"right\":3,\"bottom\":4,\"left\":3,\"gapStart\":8,\"gapLength\":2}"); + CHECK(walkOf(p) == std::string("00 10 20 30 31 32 22 12")); +} + // A strand with fewer lights than the edge has pixels gets them spread along it, so one script serves a 30-light strip on a 128-pixel wall. The corners still land on the box. TEST_CASE("backlight.mll: a strand coarser than the wall still spans its edges") { const auto p = placeBacklight("{\"wallCols\":128,\"wallRows\":128,\"top\":30,\"right\":20,\"bottom\":30,\"left\":20}");