From dfe6a7e4bdc17cec31097aec4cb248679bcc023b Mon Sep 17 00:00:00 2001 From: harrysof <73562755+harrysof@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:28:03 +0100 Subject: [PATCH] Add Toypad LED mirroring and GET_LED support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement LED mirroring for the emulated LEGO Dimensions Toypad: parse HID LED commands (0xC0–0xC8) in handle_led_command, store per-region led_pad_state behind m_led_mutex, and only bump m_led_serial when a region actually changes. Call handle_led_command from interrupt_transfer (no change to game acknowledgements). Add GET_LED handling in the TCP listener to return the 30-byte LED snapshot, plus a send_all helper and slot-validation exception for GET_LED. Update README with protocol and behavior notes and logging details. --- README.md | 35 +++++- rpcs3/Emu/Io/Dimensions.cpp | 158 ++++++++++++++++++++++++++++ rpcs3/Emu/Io/Dimensions.h | 23 ++++ rpcs3/Emu/Io/DimensionsListener.cpp | 43 +++++++- 4 files changed, 256 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5d63f2a..d881ac7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # RPCS3 Seamless Toypad Build -A modified [RPCS3](https://github.com/RPCS3/rpcs3) build that adds a loopback TCP listener for the emulated LEGO Dimensions Toypad, so figures can be loaded, removed or moved from a controller-driven companion app instead of the mouse-driven **Manage → Dimensions Manager** dialog. +A modified [RPCS3](https://github.com/RPCS3/rpcs3) build that adds a loopback TCP listener for the emulated LEGO Dimensions Toypad, so figures can be loaded, removed or moved from a controller-driven companion app instead of the mouse-driven **Manage → Dimensions Manager** dialog — and so that app can read back the Toypad's LED state and render the pads glowing like the real hardware. It is the RPCS3 counterpart of [harrysof/Cemu-2.6-Remote-Toypad-Build](https://github.com/harrysof/Cemu-2.6-Remote-Toypad-Build) and speaks the same wire protocol, so [**LegoToypad**](https://github.com/harrysof/LegoToypad) works with it **unchanged** — the app does not even know whether Cemu or RPCS3 is on the other end of the socket. @@ -26,6 +26,7 @@ LegoToypad --TCP 127.0.0.1:9191--> RPCS3 listener --> g_dimensionstoypad - RPCS3 starts a TCP listener bound to `127.0.0.1` only, as soon as the emulated Toypad device is created by the game. - LegoToypad scans your tag library, lets you pick a figure and a Toypad slot with a controller, and sends it over the socket. - RPCS3 decodes the message and calls the same `load_figure` / `remove_figure` / `move_figure` methods the built-in dialog uses. +- The emulated Toypad also mirrors the game's own LED commands, and the listener hands that state back on request, so the picker can light its pads in step with the game. - While the picker overlay is on screen, RPCS3 mutes gamepad input to the game, so navigating the overlay never presses buttons in LEGO Dimensions. No authentication, no encryption — it is loopback-only by design. @@ -61,7 +62,7 @@ Every message starts with a 5-byte header: | Offset | Field | Value | |---|---|---| -| 0 | Command | `0x01` LOAD, `0x02` REMOVE, `0x03` MOVE | +| 0 | Command | `0x01` LOAD, `0x02` REMOVE, `0x03` MOVE, `0x04` GET_LED | | 1 | Dest pad | 1 = left, 2 = center, 3 = right | | 2 | Dest slot | 0–6 | | 3 | Source pad (MOVE only) | 0 for LOAD/REMOVE | @@ -72,17 +73,45 @@ Every message starts with a 5-byte header: | LOAD `0x01` | 180 raw tag bytes, then a `u16` little-endian path length, then that many UTF-8 path bytes (length may be 0) | `remove_figure(pad, index, true, true)` then `load_figure(tag, file, pad, index, true)` | | REMOVE `0x02` | none | `remove_figure(pad, index, true, true)` | | MOVE `0x03` | none | `move_figure(pad, index, old_pad, old_index)` | +| GET_LED `0x04` | none | Replies with a 30-byte LED snapshot; header bytes 1–4 are ignored and skip slot validation | If LOAD carries a path, the file is opened read/write/locked and handed to the Toypad, so writes the game makes to the tag are persisted back into that `.bin` — exactly as if the figure had been loaded through the Dimensions Manager. With a zero-length path the figure lives in memory for the session only. LOAD deliberately overwrites an occupied slot (remove first, then load), matching the Cemu listener's contract. Out-of-range pad/slot values and unknown commands are logged and the connection is dropped. +### LED mirror (`GET_LED`) + +LOAD/REMOVE/MOVE are fire-and-forget; `GET_LED` is the one command that answers. It returns a fixed 30-byte snapshot of what the game has the three LED regions doing: + +``` +[0] 0x4C 'L' magic +[1] serial increments whenever any region actually changes +[2] 0x03 region count +[3..] 3 x 9 bytes: pad, mode, r, g, b, on_ms, off_ms, count, speed_ms +``` + +| Field | Meaning | +|---|---| +| `pad` | LED region — `1` centre, `2` left, `3` right | +| `mode` | `0` off, `1` solid, `2` flash, `3` fade | +| `r`, `g`, `b` | The colour the game set, verbatim | +| `on_ms` / `off_ms` | Flash on and off durations, in Toypad ticks (~40ms each) | +| `count` | Cycle count; `0` means "until the next command" (`0xFF` on the wire is normalized to `0`) | +| `speed_ms` | Fade tick time | + +The emulated Toypad builds this snapshot in `handle_led_command`, parsing the game's own HID LED commands as they pass through `interrupt_transfer` — `0xC0` Color, `0xC2` Fade, `0xC3` Flash, `0xC4` Fade Random, and the `0xC6`/`0xC7`/`0xC8` "All" variants, which carry a per-region on/off byte. A `pad` of `0` ("all pads") fans out to all three regions. `0xC1` (Get Pad Color) is a query and changes nothing. + +The **serial** only advances when a command actually changes a region's state — re-sending an identical command leaves it alone. A polling client can therefore skip snapshots it has already applied and never restart a flash mid-cycle. + +This is byte-for-byte the same snapshot the Cemu fork returns, so LegoToypad's LED mirror works against either emulator with no changes. + ## Code map | Path | Change | |---|---| | `rpcs3/Emu/Io/DimensionsListener.h` / `.cpp` | New. Listener thread, TCP framing, validation, picker input-mute watcher | | `rpcs3/Emu/Io/Dimensions.cpp` | Starts the listener when `usb_device_dimensions` is constructed, stops it on destruction | +| `rpcs3/Emu/Io/Dimensions.h` / `.cpp` | LED command parsing (`handle_led_command`) and the mirrored per-region state behind `m_led_mutex` | | `rpcs3/Emu/CMakeLists.txt`, `rpcs3/emucore.vcxproj(.filters)` | Add the new sources to the build | Everything else is stock upstream RPCS3 — around 300 added lines, nothing removed. @@ -92,6 +121,8 @@ Everything else is stock upstream RPCS3 — around 300 added lines, nothing remo - The listener only exists while the emulated Toypad exists, so it is inactive unless a game actually attached the device — and it never competes with a physical Toypad, which takes priority in RPCS3. - `load_figure` / `remove_figure` are called with `lock = true`, so the listener takes the Toypad's own lock and shares the threading model of the GUI dialog. No new locking was introduced. - **Input hand-off:** LegoToypad keeps a named event (`Local\CemuToypadPickerInputActive`, the same contract as the Cemu fork) signaled while its overlay is visible. A watcher thread polls it and holds RPCS3's regular pad interception (`input::SetIntercepted`) for as long as it is signaled. If interception is already held by an RPCS3 dialog, the watcher leaves it alone and does not release it afterwards. The handle is re-opened once a second so a restarted companion app is picked up again. Windows only; on other platforms the listener works and only the input muting is absent. +- LED mirroring is read-only and passive: `handle_led_command` runs off the existing `0xC0`–`0xC8` acknowledgement path and changes nothing about the response the game receives. A client that never sends `GET_LED` sees no difference at all. LED state lives behind its own `m_led_mutex`, written on the emulation thread by `interrupt_transfer` and read by the listener's `GET_LED` handler. +- LED commands and state changes are logged on the `dimensions` channel at notice level (`Toypad LED command 0x..` / `Toypad LED set: ...`), which makes it easy to tell "the game is not driving the pads" apart from "the mirror is broken". - Error messages inside LegoToypad mention "Cemu" — that is only text, the app is protocol-identical and works fine here. ## Building from source diff --git a/rpcs3/Emu/Io/Dimensions.cpp b/rpcs3/Emu/Io/Dimensions.cpp index 0843d3b..21db687 100644 --- a/rpcs3/Emu/Io/Dimensions.cpp +++ b/rpcs3/Emu/Io/Dimensions.cpp @@ -551,6 +551,161 @@ std::optional> dimensions_toypad::pop_added_removed_response( return response; } +namespace +{ + // Region index (center/left/right) for a wire pad value: 1=center, 2=left, 3=right. + u8 led_pad_index(u8 pad) + { + switch (pad) + { + case 1: return 0; // center + case 2: return 1; // left + case 3: return 2; // right + } + return 0; + } + // Wire pad value for a region index (the "All" commands enumerate center, left, right). + u8 led_region_pad(u8 region) + { + static constexpr std::array pads = {1, 2, 3}; + return pads[region]; + } +} // namespace + +void dimensions_toypad::set_led_state(u8 pad, u8 mode, u8 r, u8 g, u8 b, u8 on_ms, u8 off_ms, u8 count, u8 speed_ms) +{ + std::lock_guard lock(m_led_mutex); + auto apply = [&](u8 target_pad) + { + led_pad_state& state = m_led_state[led_pad_index(target_pad)]; + if (state.mode == mode && state.r == r && state.g == g && state.b == b && + state.on_ms == on_ms && state.off_ms == off_ms && state.count == count && state.speed_ms == speed_ms) + return; // unchanged - don't bump the poll serial + state.pad = target_pad; + state.mode = mode; + state.r = r; + state.g = g; + state.b = b; + state.on_ms = on_ms; + state.off_ms = off_ms; + state.count = count; + state.speed_ms = speed_ms; + ++m_led_serial; + dimensions_log.notice("Toypad LED set: pad %d mode %d rgb %d,%d,%d (serial %d)", + state.pad, state.mode, state.r, state.g, state.b, m_led_serial); + }; + if (pad == 0) // all pads + { + apply(1); + apply(2); + apply(3); + } + else + { + apply(pad); + } +} + +dimensions_toypad::led_pad_state dimensions_toypad::get_led_state(u8 pad) +{ + std::lock_guard lock(m_led_mutex); + return m_led_state[led_pad_index(pad)]; +} + +std::array dimensions_toypad::get_led_states() +{ + std::lock_guard lock(m_led_mutex); + return m_led_state; +} + +u8 dimensions_toypad::get_led_serial() +{ + std::lock_guard lock(m_led_mutex); + return m_led_serial; +} + +// Parses the game's HID LED commands (0xC0..0xC8) and mirrors the per-region +// state into m_led_state. Same byte layout as the Cemu fork (shared +// reverse-engineering): header {0x55, len, command, messageId, ...}, args from +// buf[4]; "All" commands enumerate center, left, right, each with an on/off +// byte first. +void dimensions_toypad::handle_led_command(const u8* buf, u32 buf_size) +{ + if (buf_size < 25) + return; + const u8 command = buf[2]; + dimensions_log.notice("Toypad LED command 0x%02x seq %d bytes %02x %02x %02x %02x %02x %02x", + command, buf[3], buf[4], buf[5], buf[6], buf[7], buf[8], buf[9]); + switch (command) + { + case 0xC0: // Color: pad, r, g, b + set_led_state(buf[4], 1, buf[5], buf[6], buf[7], 0, 0, 0, 0); + break; + case 0xC1: // Get Pad Color - query only, no state change + break; + case 0xC2: // Fade: pad, tickTime, tickCount, r, g, b + set_led_state(buf[4], 3, buf[7], buf[8], buf[9], 0, 0, buf[6], buf[5]); + break; + case 0xC3: // Flash: pad, on, off, count(0xFF=forever), r, g, b + { + const u8 count = (buf[7] == 0xFF) ? 0 : buf[7]; + set_led_state(buf[4], 2, buf[8], buf[9], buf[10], buf[5], buf[6], count, 0); + break; + } + case 0xC4: // Fade Random: pad, tickTime, tickCount (colour left as-is) + { + const led_pad_state existing = get_led_state(buf[4]); + set_led_state(buf[4], 3, existing.r, existing.g, existing.b, 0, 0, buf[6], buf[5]); + break; + } + case 0xC6: // Fade All: per-region on/off, tickTime, tickCount, r, g, b + { + for (u8 region = 0; region < 3; ++region) + { + const u32 off = 4 + region * 6; + if (buf[off] == 0) + { + set_led_state(led_region_pad(region), 0, 0, 0, 0, 0, 0, 0, 0); + continue; + } + set_led_state(led_region_pad(region), 3, buf[off + 3], buf[off + 4], buf[off + 5], 0, 0, buf[off + 2], buf[off + 1]); + } + break; + } + case 0xC7: // Flash All: per-region on/off, on, off, count, r, g, b + { + for (u8 region = 0; region < 3; ++region) + { + const u32 off = 4 + region * 7; + if (buf[off] == 0) + { + set_led_state(led_region_pad(region), 0, 0, 0, 0, 0, 0, 0, 0); + continue; + } + const u8 count = (buf[off + 3] == 0xFF) ? 0 : buf[off + 3]; + set_led_state(led_region_pad(region), 2, buf[off + 4], buf[off + 5], buf[off + 6], buf[off + 1], buf[off + 2], count, 0); + } + break; + } + case 0xC8: // Color All: per-region on/off, r, g, b + { + for (u8 region = 0; region < 3; ++region) + { + const u32 off = 4 + region * 4; + if (buf[off] == 0) + { + set_led_state(led_region_pad(region), 0, 0, 0, 0, 0, 0, 0, 0); + continue; + } + set_led_state(led_region_pad(region), 1, buf[off + 1], buf[off + 2], buf[off + 3], 0, 0, 0, 0); + } + break; + } + default: + break; + } +} + usb_device_dimensions::usb_device_dimensions(const std::array& location) : usb_device_emulated(location) { @@ -659,6 +814,9 @@ void usb_device_dimensions::interrupt_transfer(u32 buf_size, u8* buf, u32 endpoi case 0xC7: // Flash All case 0xC8: // Color All { + // Mirror the pad-region LED state out to the network listener so the + // LegoToypad app can render the pads glowing like a real toypad. + g_dimensionstoypad.handle_led_command(buf, buf_size); // Send a blank response to acknowledge color has been sent to toypad g_dimensionstoypad.get_blank_response(0x01, sequence, q_result); break; diff --git a/rpcs3/Emu/Io/Dimensions.h b/rpcs3/Emu/Io/Dimensions.h index d25fb8e..f4ab7e1 100644 --- a/rpcs3/Emu/Io/Dimensions.h +++ b/rpcs3/Emu/Io/Dimensions.h @@ -30,6 +30,22 @@ class dimensions_toypad void get_model(const u8* buf, u8 sequence, std::array& reply_buf); std::optional> pop_added_removed_response(); + // Mirror a pad region's LED state ("keystone puzzle" glow). Same encoding + // and wire snapshot as the Cemu fork, so the LegoToypad controller app can + // render the pads glowing for RPCS3 too. mode: 0 off, 1 solid, 2 flash, + // 3 fade; pad: 1=center, 2=left, 3=right. + struct led_pad_state + { + u8 pad = 0; + u8 mode = 0; + u8 r = 0, g = 0, b = 0; + u8 on_ms = 0, off_ms = 0, count = 0, speed_ms = 0; + }; + void handle_led_command(const u8* buf, u32 buf_size); + std::array get_led_states(); + u8 get_led_serial(); + led_pad_state get_led_state(u8 pad); + bool remove_figure(u8 pad, u8 index, bool full_remove, bool lock); bool temp_remove(u8 index); bool cancel_remove(u8 index); @@ -53,6 +69,7 @@ class dimensions_toypad static u32 get_figure_id(const std::array& buf); u32 get_next(); dimensions_figure& get_figure_by_index(u8 index); + void set_led_state(u8 pad, u8 mode, u8 r, u8 g, u8 b, u8 on_ms, u8 off_ms, u8 count, u8 speed_ms); u32 m_random_a{}; u32 m_random_b{}; @@ -61,6 +78,12 @@ class dimensions_toypad u8 m_figure_order = 0; std::queue> m_figure_added_removed_responses; + + // LED mirror state, updated on the emulation thread by interrupt_transfer + // and read by the listener's GET_LED handler. + shared_mutex m_led_mutex; + std::array m_led_state{}; + u8 m_led_serial = 0; }; extern dimensions_toypad g_dimensionstoypad; diff --git a/rpcs3/Emu/Io/DimensionsListener.cpp b/rpcs3/Emu/Io/DimensionsListener.cpp index 55a9741..f664ca7 100644 --- a/rpcs3/Emu/Io/DimensionsListener.cpp +++ b/rpcs3/Emu/Io/DimensionsListener.cpp @@ -119,6 +119,19 @@ namespace return true; } + bool send_all(socket_t s, const u8* data, usz len) + { + while (len != 0) + { + const auto sent = ::send(s, reinterpret_cast(data), static_cast(len), 0); + if (sent <= 0) + return false; + data += sent; + len -= static_cast(sent); + } + return true; + } + u16 listener_port() { if (const char* env = std::getenv("RPCS3_TOYPAD_PORT")) @@ -140,7 +153,9 @@ namespace const u8 pad = header[1]; const u8 index = header[2]; - if (pad < 1 || pad > 3 || index >= 7) + // GET_LED (0x04) carries no pad/index (they're 0), so skip the slot + // validation for it - it would otherwise be rejected below. + if (cmd != 0x04 && (pad < 1 || pad > 3 || index >= 7)) { dim_listener_log.error("Rejected message: cmd=0x%02x pad=%d index=%d", cmd, pad, index); return; @@ -205,6 +220,32 @@ namespace dim_listener_log.notice("MOVE %d/%d -> %d/%d", old_pad, old_index, pad, index); break; } + case 0x04: // GET_LED - return the current LED snapshot for the app's poll + { + const auto states = g_dimensionstoypad.get_led_states(); + const u8 serial = g_dimensionstoypad.get_led_serial(); + + u8 response[3 + 3 * 9] = {}; + response[0] = 0x4C; // 'L' magic + response[1] = serial; + response[2] = 0x03; // region count + for (usz i = 0; i < 3; ++i) + { + const usz off = 3 + i * 9; + response[off + 0] = states[i].pad; + response[off + 1] = states[i].mode; + response[off + 2] = states[i].r; + response[off + 3] = states[i].g; + response[off + 4] = states[i].b; + response[off + 5] = states[i].on_ms; + response[off + 6] = states[i].off_ms; + response[off + 7] = states[i].count; + response[off + 8] = states[i].speed_ms; + } + if (!send_all(client, response, sizeof(response))) + dim_listener_log.error("Failed to send GET_LED response"); + break; + } default: dim_listener_log.error("Unknown command 0x%02x", cmd); break;