Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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 |
Expand All @@ -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.
Expand All @@ -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
Expand Down
158 changes: 158 additions & 0 deletions rpcs3/Emu/Io/Dimensions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,161 @@ std::optional<std::array<u8, 32>> 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<u8, 3> 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::led_pad_state, 3> 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<u8, 7>& location)
: usb_device_emulated(location)
{
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 23 additions & 0 deletions rpcs3/Emu/Io/Dimensions.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@ class dimensions_toypad
void get_model(const u8* buf, u8 sequence, std::array<u8, 32>& reply_buf);
std::optional<std::array<u8, 32>> 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<led_pad_state, 3> 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);
Expand All @@ -53,6 +69,7 @@ class dimensions_toypad
static u32 get_figure_id(const std::array<u8, 0x2D * 0x04>& 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{};
Expand All @@ -61,6 +78,12 @@ class dimensions_toypad

u8 m_figure_order = 0;
std::queue<std::array<u8, 32>> 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<led_pad_state, 3> m_led_state{};
u8 m_led_serial = 0;
};

extern dimensions_toypad g_dimensionstoypad;
Expand Down
43 changes: 42 additions & 1 deletion rpcs3/Emu/Io/DimensionsListener.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<const char*>(data), static_cast<int>(len), 0);
if (sent <= 0)
return false;
data += sent;
len -= static_cast<usz>(sent);
}
return true;
}

u16 listener_port()
{
if (const char* env = std::getenv("RPCS3_TOYPAD_PORT"))
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading