ps-access 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROTOCOL.md +210 -0
- package/README.md +203 -0
- package/bridge.mjs +231 -0
- package/cli.mjs +339 -0
- package/lib/bridge-sinks.mjs +81 -0
- package/lib/hid-node.mjs +67 -0
- package/lib/uinput-helper.py +143 -0
- package/package.json +53 -0
- package/web/access-protocol.mjs +310 -0
- package/web/bridge-core.mjs +132 -0
- package/web/bridge-map.mjs +102 -0
- package/web/controller-render.mjs +79 -0
- package/web/hid-capture.html +142 -0
- package/web/hid-web.mjs +65 -0
- package/web/icon.svg +14 -0
- package/web/index.html +346 -0
- package/web/manifest.webmanifest +14 -0
- package/web/monitor.html +121 -0
- package/web/monitor.js +117 -0
- package/web/profile-library.mjs +181 -0
- package/web/serve.json +10 -0
- package/web/sw.js +39 -0
- package/web/xmb.js +1069 -0
package/PROTOCOL.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# PlayStation Access Controller — profile protocol
|
|
2
|
+
|
|
3
|
+
Protocol for reading and writing the Access Controller's on-device profiles over **USB-C**, with
|
|
4
|
+
**no PS5**. Verified end-to-end against real hardware (read, write, round-trip, restore) on macOS
|
|
5
|
+
via both node-hid and WebHID.
|
|
6
|
+
|
|
7
|
+
Credit: this work builds on the prior art of Jacek Fedoryński's client-side web editor
|
|
8
|
+
(<https://www.jfedor.org/ps-access/>), which first made PC-side profile editing possible. The
|
|
9
|
+
byte offsets and behaviour documented here were confirmed against a live device, and this note
|
|
10
|
+
records where this implementation differs (it preserves the UUID/timestamp/stick-tuning bytes on
|
|
11
|
+
round-trip).
|
|
12
|
+
|
|
13
|
+
## Device identity
|
|
14
|
+
|
|
15
|
+
| | value |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Vendor ID | `0x054C` (Sony Interactive Entertainment) |
|
|
18
|
+
| Product ID | `0x0E5F` |
|
|
19
|
+
| USB usage | Generic Desktop (`0x01`) / Game Pad (`0x05`) |
|
|
20
|
+
| Product string | `Access Controller` |
|
|
21
|
+
|
|
22
|
+
Note `0x0E5F` is absent from public USB-ID databases.
|
|
23
|
+
|
|
24
|
+
## Transport: feature reports
|
|
25
|
+
|
|
26
|
+
Two HID **feature reports** carry profile data, and are present **only over USB**:
|
|
27
|
+
|
|
28
|
+
- `0x60` (96) — host → device command/data channel
|
|
29
|
+
- `0x61` (97) — device → host data/status channel
|
|
30
|
+
|
|
31
|
+
Each `0x60` packet is **63 payload bytes** (after the report id). Each `0x61` response is the
|
|
32
|
+
full report with the **report id at byte 0**.
|
|
33
|
+
|
|
34
|
+
**USB vs Bluetooth detection:** over USB the feature-report set includes `0x60` and `0x61`
|
|
35
|
+
and does **not** include report `99` (`0x63`). Over Bluetooth, report `99` is present and the
|
|
36
|
+
profile channel is not usable. So: *USB-ready ⇔ has `0x60` & `0x61` and not `99`.*
|
|
37
|
+
|
|
38
|
+
A profile blob is **956 bytes** (`PROFILE_DATA_SIZE`). There are **3 profile slots** (1–3).
|
|
39
|
+
|
|
40
|
+
### Reading profile N (1–3)
|
|
41
|
+
|
|
42
|
+
1. Send feature report `0x60` with a 63-byte buffer where `buf[0] = 0x10 + (N-1)` (rest 0).
|
|
43
|
+
2. Read feature report `0x61` **18 times**. In each response, payload bytes are at **offset 4**
|
|
44
|
+
(`[0]=0x61, [1]=cmd echo, [2]=remaining-count, [3]=?, [4..59]=56 payload bytes`).
|
|
45
|
+
3. Concatenate `18 × 56 = 1008` bytes and keep the first **956**. Byte 0 must be `0x02`.
|
|
46
|
+
|
|
47
|
+
### Writing profile N (1–3)
|
|
48
|
+
|
|
49
|
+
1. Build 18 packets. Packet `i` (0–17), 63 bytes:
|
|
50
|
+
- `buf[0] = 0x08 + N`
|
|
51
|
+
- `buf[1] = i`
|
|
52
|
+
- `buf[2 + j] = profile[i*56 + j]` for `j` in 0..55 while `i*56+j < 956`
|
|
53
|
+
2. On the **final** packet (`i == 17`), write `crc32(profile, 956)` as **little-endian u32** at
|
|
54
|
+
**offset 6** (the real profile data only occupies offsets 2–5 of the last packet, so there
|
|
55
|
+
is no overlap).
|
|
56
|
+
3. Send all 18 packets via feature report `0x60`, in order.
|
|
57
|
+
4. **Drain status:** read feature report `0x61` until byte `[2]` (remaining-count) is `0`
|
|
58
|
+
(cap the loop). Skipping this desyncs the next read command.
|
|
59
|
+
|
|
60
|
+
### Setting the active profile (1–3)
|
|
61
|
+
|
|
62
|
+
Switch which profile the controller has **active** — the same thing its on-device profile
|
|
63
|
+
button does — with a single command:
|
|
64
|
+
|
|
65
|
+
1. Send feature report `0x60` with a 63-byte buffer where `buf[0] = 0x05`, `buf[1] = N` (rest 0).
|
|
66
|
+
|
|
67
|
+
No `0x61` response needs draining. The switch is immediate: input-report `byte 39` (see below)
|
|
68
|
+
updates to `N` within a frame, and the controller applies that profile's mapping. Verified by
|
|
69
|
+
sending `0x05 0x01/0x02/0x03` and watching `byte 39` track `1/2/3`; the stored slot contents are
|
|
70
|
+
untouched. Reverse-engineered by probing command opcodes while watching `byte 39` — opcode `0x05`
|
|
71
|
+
with `byte[1] = 0` set `byte 39` to `0` (no/default profile), which pinned the encoding. Implemented
|
|
72
|
+
as `buildSetActiveCommand(N)`.
|
|
73
|
+
|
|
74
|
+
### CRC
|
|
75
|
+
|
|
76
|
+
Standard zlib/IEEE CRC-32 (poly `0xEDB88320`, init `0xFFFFFFFF`, final XOR `0xFFFFFFFF`),
|
|
77
|
+
computed over the **956 profile bytes** and stored little-endian. Implemented in
|
|
78
|
+
`lib/access-protocol.mjs` (`crc32`).
|
|
79
|
+
|
|
80
|
+
## Profile blob layout (956 bytes)
|
|
81
|
+
|
|
82
|
+
| offset | size | field |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| 0 | 1 | sentinel, always `0x02` |
|
|
85
|
+
| 4 | 2×40 | profile name, UTF-16LE, NUL-terminated, ≤40 chars |
|
|
86
|
+
| 84 | 16 | UUID (random; PS5/editor regenerate it each save) |
|
|
87
|
+
| 100 | 10×5 | button table — see below |
|
|
88
|
+
| 150 | 2 | toggle bitfield (u16 LE) — see below |
|
|
89
|
+
| 152 | 5×45 | expansion-port records — see below |
|
|
90
|
+
| 948 | 8 | timestamp, i64 LE (`Date.now()` ms) |
|
|
91
|
+
|
|
92
|
+
### Buttons (offset 100, 10 entries × 5 bytes)
|
|
93
|
+
|
|
94
|
+
Entry `b` (0–9) at `100 + b*5`:
|
|
95
|
+
|
|
96
|
+
- byte 0 — primary action (`map1`)
|
|
97
|
+
- byte 1 — secondary action (`map2`, for combos)
|
|
98
|
+
- bytes 2–4 — unused/observed 0
|
|
99
|
+
|
|
100
|
+
Action codes:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
0 nothing 1 circle 2 cross 3 triangle 4 square
|
|
104
|
+
5 up 6 down 7 left 8 right
|
|
105
|
+
9 L1 10 R1 11 L2 12 R2 13 L3 14 R3
|
|
106
|
+
15 options 16 create 17 PS 18 touchpad
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Toggle bitfield (offset 150, u16 LE)
|
|
110
|
+
|
|
111
|
+
- bit `b` (0–9) → toggle enabled for button `b+1`
|
|
112
|
+
- bit `9 + p` → toggle enabled for expansion port `p` configured as a button
|
|
113
|
+
(the built-in stick is port 0 and is normally a stick, so its bit is not used as a toggle)
|
|
114
|
+
|
|
115
|
+
### Expansion ports (offset 152, 5 entries × 45 bytes)
|
|
116
|
+
|
|
117
|
+
Port 0 = built-in stick; ports 1–4 = the four 3.5mm expansion ports. Entry `p` at
|
|
118
|
+
`152 + p*45`, byte 0 = **type**:
|
|
119
|
+
|
|
120
|
+
- `0x00` — disabled / nothing
|
|
121
|
+
- `0x01` — **stick**
|
|
122
|
+
- byte 1: stick assignment (`1` = left stick, `2` = right stick)
|
|
123
|
+
- byte 2: orientation (`0` below, `1` right, `2` above, `3` left) — only the built-in
|
|
124
|
+
stick is affected, but every stick port carries the same value
|
|
125
|
+
- byte 5: sensitivity; bytes 8–13: deadzone/curve (3 pairs, X/Y).
|
|
126
|
+
**`0` means "firmware default"** — observed on a live, never-PS5-tuned device, all these
|
|
127
|
+
bytes are `0`. The PS5 only writes non-zero when you change them from default; jfedor's
|
|
128
|
+
editor writes `sensitivity=3` and deadzone `80 80 c4 c4 e1 e1` as its "default" preset.
|
|
129
|
+
The exact value→behaviour mapping is **not publicly documented** (set them experimentally).
|
|
130
|
+
- `0x02` — **analog** button; `0x03` — **digital** button
|
|
131
|
+
- byte 2: primary action (`map1`); byte 3: secondary action (`map2`)
|
|
132
|
+
|
|
133
|
+
A port’s stick assignment is encoded in the UI as `100 + code` (101 = left stick,
|
|
134
|
+
102 = right stick) to share one dropdown with the button actions.
|
|
135
|
+
|
|
136
|
+
## Input report — live button & stick state (report id `0x01`)
|
|
137
|
+
|
|
138
|
+
The controller streams a DualSense-style **input report** (report id `1`, ~250 Hz, 64 bytes
|
|
139
|
+
incl. report id / 63 bytes of data). The left stick is at the front and a sequence counter ticks
|
|
140
|
+
at byte 6; the last 8 bytes (55–62) are a volatile per-frame trailer (see *Unexposed* below).
|
|
141
|
+
Reverse-engineered with `web/hid-capture.html` (connect → idle baseline → press one button at a
|
|
142
|
+
time, watch which bits flip).
|
|
143
|
+
|
|
144
|
+
**Key finding:** the report exposes **raw physical button state — independent of the profile
|
|
145
|
+
remapping.** Pressing a button lights a fixed physical bit even when that button is mapped to
|
|
146
|
+
*Not assigned*. (The standard DualSense face/shoulder bits near bytes 7–8 instead carry the
|
|
147
|
+
*mapped action*, so two buttons mapped to the same action are indistinguishable there.)
|
|
148
|
+
|
|
149
|
+
Byte offsets below are in **`event.data` coordinates** (WebHID `inputreport`, which **excludes**
|
|
150
|
+
the report id). For `node-hid`, whose buffer includes the id at `[0]`, add 1.
|
|
151
|
+
|
|
152
|
+
| data byte | bits | meaning |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| 0–1 | — | left stick X / Y (≈`0x80` centered; byte 0 jitters) |
|
|
155
|
+
| 6 | — | sequence counter (increments once per report) |
|
|
156
|
+
| ~7–8 | — | standard mapped-action bits (reflect the *mapped* action, not the physical button) |
|
|
157
|
+
| **15** | **0–7** | **the 8 perimeter buttons** — one bit each (physical) |
|
|
158
|
+
| **16** | **0, 1, 3** | **center button** (bit 0), **stick-click** (bit 1), **profile-switch button** (bit 3, `0x08`) |
|
|
159
|
+
| **39** | — | **active on-device profile** — `1`, `2`, or `3` (which profile the controller has selected) |
|
|
160
|
+
|
|
161
|
+
Pinned via an ordered capture: **perimeter button _n_ (1–8) → `byte 15` bit _(n−1)_**
|
|
162
|
+
(bit 0 = button 1 … bit 7 = button 8); **`byte 16` bit 0 = center button**; **`byte 16` bit 1 =
|
|
163
|
+
stick-click**. So all 10 physical inputs are readable directly from `byte 15` (bits 0–7) and
|
|
164
|
+
`byte 16` (bits 0–1).
|
|
165
|
+
|
|
166
|
+
**Active profile.** `byte 39` holds the profile the controller currently has active (`1`–`3`),
|
|
167
|
+
and `byte 16` bit 3 (`0x08`) pulses while the on-device **profile-switch button** is held. This
|
|
168
|
+
lets a PC-side tool show, and react to, which profile the user has selected on the controller —
|
|
169
|
+
without a PS5. Found by capturing the report while cycling the profile button: `byte 39` stepped
|
|
170
|
+
`3 → 1 → 2 → 3` in lockstep with each press, while all other low-cardinality bytes stayed put.
|
|
171
|
+
|
|
172
|
+
This enables physical-button features regardless of mapping — e.g. navigating a UI with the
|
|
173
|
+
controller (perimeter = back, center/stick-click = confirm) and lighting only the button
|
|
174
|
+
actually pressed. Note: WebHID `inputreport` and the browser **Gamepad API** can't both read the
|
|
175
|
+
device reliably at once, and the Gamepad API only sees mapped actions — so physical-button work
|
|
176
|
+
must go through the raw input report.
|
|
177
|
+
|
|
178
|
+
### What the controller does *not* expose (verified)
|
|
179
|
+
|
|
180
|
+
A round of probing (capture + correlation against the DualSense layout) ruled these out, which is
|
|
181
|
+
useful to know before chasing them:
|
|
182
|
+
|
|
183
|
+
- **No analog/pressure on the buttons.** The 8 perimeter buttons, center, and stick-click are
|
|
184
|
+
**digital only**. Holding a button harder changes nothing — the only bytes that react are the
|
|
185
|
+
physical bits (`byte 15`/`byte 16`) and the mapped-action bits (`bytes 7–8`), and all of those
|
|
186
|
+
are flat on/off. (The profile's "analog" button type `0x02` is for **expansion-port** inputs —
|
|
187
|
+
analog sticks/triggers in the 3.5 mm AUX jacks — not the built-in buttons.)
|
|
188
|
+
- **No motion/IMU and no battery.** The gyro/accel region a DualSense fills with sensor noise is
|
|
189
|
+
constant/zero here (the device is meant to sit flat), and the DualSense battery byte reads `0`
|
|
190
|
+
(USB-powered, no battery to report).
|
|
191
|
+
- **Bytes 55–62 are an opaque 8-byte trailer.** They randomize every frame even when the rest of
|
|
192
|
+
the payload is static, the values are not a forward timestamp, and they do **not** match a
|
|
193
|
+
standard DualSense CRC-32 (seed `0xA1` over the payload). Most likely a device timestamp and/or
|
|
194
|
+
signature over hidden internal state — not a decodable field.
|
|
195
|
+
- **The profile light is firmware-controlled.** The device accepts output report `0x02` (31 data
|
|
196
|
+
bytes) without error but ignores it for lighting — the profile-button glow and its idle "wave"
|
|
197
|
+
animation are driven by firmware, not host-settable. (The standard DualSense player-LED/lightbar
|
|
198
|
+
bytes sit at offsets ~43–46, beyond this device's 31-byte output report, so the usual path
|
|
199
|
+
doesn't apply.)
|
|
200
|
+
- **The profile blob has no hidden fields.** Every non-zero byte in the 956-byte profile falls
|
|
201
|
+
inside a documented field; the rest is reserved/zero.
|
|
202
|
+
|
|
203
|
+
## Implementation notes
|
|
204
|
+
|
|
205
|
+
- `buildProfile()` starts from the previously-read raw bytes when available, so fields this
|
|
206
|
+
tool doesn’t model (UUID, stick sensitivity/deadzone) survive a round trip. jfedor’s editor
|
|
207
|
+
instead rebuilds from scratch, randomizing the UUID and resetting stick tuning to defaults.
|
|
208
|
+
- A naive read **immediately after** a write returns zeros — always drain `0x61` first.
|
|
209
|
+
- `node-hid` `getFeatureReport(id, len)` and WebHID `receiveFeatureReport(id)` both return the
|
|
210
|
+
report id at byte 0, so the payload offset (4 on read) is identical across platforms.
|
package/README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# ps-access
|
|
2
|
+
|
|
3
|
+
Read and write **PlayStation Access Controller** profiles from a PC over **USB-C — no PS5
|
|
4
|
+
required**. Includes a command-line tool and a multi-controller browser (WebHID) configurator,
|
|
5
|
+
plus a documented protocol.
|
|
6
|
+
|
|
7
|
+
The Access Controller normally can only be customized by plugging it into a PS5. This project
|
|
8
|
+
talks the same on-device profile protocol directly, so you can read, edit, back up, restore,
|
|
9
|
+
and clone the 3 on-device profiles (button remapping, the built-in stick, expansion ports)
|
|
10
|
+
yourself.
|
|
11
|
+
|
|
12
|
+
> Verified end-to-end against real hardware (read / write / round-trip / restore) on macOS.
|
|
13
|
+
> See [PROTOCOL.md](PROTOCOL.md) for the protocol. This project builds on the prior art of
|
|
14
|
+
> Jacek Fedoryński’s web editor (<https://www.jfedor.org/ps-access/>) — credit and thanks to him
|
|
15
|
+
> for first making PC-side profile editing possible.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- The controller connected by **USB-C** (the profile channel isn’t available over Bluetooth).
|
|
20
|
+
- CLI: Node.js (tested on v26) with `node-hid` (`npm install`).
|
|
21
|
+
- Web tool: Chrome or Edge (desktop) for WebHID.
|
|
22
|
+
- macOS may prompt for **Input Monitoring** permission for the terminal/Chrome on first use.
|
|
23
|
+
|
|
24
|
+
## CLI
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install
|
|
28
|
+
|
|
29
|
+
node cli.mjs list # list connected controllers
|
|
30
|
+
node cli.mjs dump # decode all 3 profiles
|
|
31
|
+
node cli.mjs backup # save all 3 profiles to captures/
|
|
32
|
+
node cli.mjs read-profile 1 --json # decode one profile as JSON
|
|
33
|
+
node cli.mjs set-active 2 # switch the active profile (like the profile button)
|
|
34
|
+
node cli.mjs set 1 button5=triangle # remap button 5
|
|
35
|
+
node cli.mjs set 1 port1=cross # expansion port 1 -> cross
|
|
36
|
+
node cli.mjs set 1 "port0=left stick" # built-in stick assignment
|
|
37
|
+
node cli.mjs set 1 orientation="stick on the right"
|
|
38
|
+
node cli.mjs write-profile 2 captures/backup-....json
|
|
39
|
+
node cli.mjs restore captures/backup-....json
|
|
40
|
+
node cli.mjs apply profile.json 1 # apply a web-app export / share code / URL / preset id to a slot
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`apply` is the bridge between the web tool and the CLI: feed it a **profile JSON exported from the
|
|
44
|
+
web Library**, a **share link/code**, or a built-in **preset id** (`ps-access presets`), and it
|
|
45
|
+
writes that mapping to a slot on the controller (reading the current profile first so uuid and
|
|
46
|
+
unmodeled fields survive, then round-trip verifying).
|
|
47
|
+
|
|
48
|
+
- `--device <index|path>` targets a specific controller when several are connected.
|
|
49
|
+
- **Every write auto-backs-up first** to `captures/` and round-trip re-reads to verify.
|
|
50
|
+
|
|
51
|
+
## Web tool (multiple controllers)
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm start # serves the project at http://localhost:3000
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Open **<http://localhost:3000/web/>** in Chrome/Edge.
|
|
58
|
+
|
|
59
|
+
### XMB view (`index.html`)
|
|
60
|
+
|
|
61
|
+
A full-screen XrossMediaBar-style interface: a horizontal ribbon of blades
|
|
62
|
+
(`Controllers · Profile 1 · 2 · 3 · Save · Library · Key Bridge · Monitor`, each profile rendered
|
|
63
|
+
as a live mini-controller), a vertical item list, and an enlarged "hero" render when you drill in.
|
|
64
|
+
Edit button/stick/port mappings with horizontal value spinners, then save to the controller.
|
|
65
|
+
|
|
66
|
+
The **Library** blade is for **sharing and presets** (all client-side, no account): apply a
|
|
67
|
+
curated starting-point preset (one-handed, toggle-triggers, external-switch D-pad…), **export**
|
|
68
|
+
a profile to a JSON file, **import** a file or a CLI backup, or **copy a share link** — a URL
|
|
69
|
+
whose `#p=…` hash encodes the profile, auto-detected when someone opens it. After applying or
|
|
70
|
+
importing, use **Save** to write it to the controller.
|
|
71
|
+
|
|
72
|
+
The **Key Bridge** blade is a visual editor for the PC input bridge: assign **any keyboard key
|
|
73
|
+
or chord** to each physical button and the stick by selecting a row, pressing Enter, then pressing
|
|
74
|
+
the key you want (press the physical button to find its row — it lights up live). Pick a stick
|
|
75
|
+
mode (keys / mouse / gamepad axis), then **Export** a `bridge.json` or **copy the run command**.
|
|
76
|
+
The browser can author and preview the mapping, but it can't inject input into other apps — you
|
|
77
|
+
run the exported config with the local bridge (`node bridge.mjs --config bridge.json`), which is
|
|
78
|
+
what actually drives the PC. (Macros — multi-step sequences — are edited in the exported JSON.)
|
|
79
|
+
|
|
80
|
+
**Accessibility:** the configurator is built to be used by the same people the controller is for.
|
|
81
|
+
Every section/option/value change is announced to screen readers (a polite live region), it's
|
|
82
|
+
fully keyboard- and controller-operable, **?** opens a controls reference, and it honors
|
|
83
|
+
`prefers-reduced-motion` (no animated wave), `prefers-contrast`, and Windows High Contrast /
|
|
84
|
+
`forced-colors`. A high-visibility focus ring on the selected item is available as an **opt-in
|
|
85
|
+
toggle in Help (off by default**, so it doesn't fight the XMB look); OS high-contrast / forced-colors
|
|
86
|
+
modes turn it on automatically.
|
|
87
|
+
|
|
88
|
+
It's driven by the controller's **raw HID input report**, so it reads *physical* buttons
|
|
89
|
+
regardless of remapping: tilt the **stick** to navigate, **center / stick-click = confirm**,
|
|
90
|
+
**any perimeter button = back**, and pressing any physical button lights it up on every render.
|
|
91
|
+
Keyboard works too (arrows / Enter / Backspace).
|
|
92
|
+
|
|
93
|
+
Unplugging and replugging the controller **reconnects automatically** (no refresh). The
|
|
94
|
+
**Controllers** blade also has a persistent **+ Connect a controller…** action (activate it with
|
|
95
|
+
Enter or a click) to grant or reconnect a controller on demand.
|
|
96
|
+
|
|
97
|
+
Under the controller name in the top bar, the **active on-device profile** is shown live (e.g.
|
|
98
|
+
`Profile 3 · stick on the right`) — it reflects whichever profile is selected on the controller
|
|
99
|
+
itself and **updates the moment you press the device's profile button**, independent of the UI
|
|
100
|
+
cursor (decoded from input-report `byte 39`; see [PROTOCOL.md](PROTOCOL.md)). You can also **switch
|
|
101
|
+
it from the app**: each Profile blade has a **Set active on controller** item (the active one is
|
|
102
|
+
marked `✓`), doing the same thing as the device's profile button — `set-active` on the CLI. The ambient
|
|
103
|
+
background wave echoes it too: its three curves fade their leading lines as the active profile
|
|
104
|
+
climbs (1 → all solid, 2 → first faded, 3 → first two faded), and fade out entirely when no
|
|
105
|
+
controller is connected.
|
|
106
|
+
|
|
107
|
+
The **Monitor** blade opens a full-screen live input view (big controller render + physical-button
|
|
108
|
+
chips + stick crosshair + the raw input report with the physical-button bytes highlighted). Because
|
|
109
|
+
the controller is purely *observed* here — navigation is suspended so every button and the stick can
|
|
110
|
+
be tested freely — opening it first shows a **confirm gate** warning that you'll need the **keyboard
|
|
111
|
+
(Esc)** or the **Done** button to leave (the controller can't exit on its own). The render follows
|
|
112
|
+
the **active on-device profile**, matching its **orientation**, and re-renders if you switch
|
|
113
|
+
profiles on the controller while watching. (Also available as a standalone page, `monitor.html`.)
|
|
114
|
+
|
|
115
|
+
### Diagnostics (`hid-capture.html`)
|
|
116
|
+
|
|
117
|
+
A developer tool that shows the live input report and logs which bits flip on each press —
|
|
118
|
+
used to reverse-engineer the physical-button layout (see PROTOCOL.md).
|
|
119
|
+
|
|
120
|
+
## PC input bridge (use the controller on any PC)
|
|
121
|
+
|
|
122
|
+
Beyond editing PS5 profiles, you can use the Access Controller as a **general PC input device** —
|
|
123
|
+
its stick and buttons driving keyboard/mouse or a virtual gamepad, so it controls *any* software,
|
|
124
|
+
not just a PS5. The bridge reads the controller's live USB input and maps it through a small,
|
|
125
|
+
platform-agnostic engine (`web/bridge-core.mjs`) to a pluggable output **sink**.
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
node bridge.mjs --sink dry-run # print mapped events, inject nothing (try it first)
|
|
129
|
+
node bridge.mjs --sink xdotool # stick -> arrow keys, buttons -> keys (X11; needs xdotool)
|
|
130
|
+
node bridge.mjs --sink uinput # virtual gamepad/keyboard via /dev/uinput (Linux)
|
|
131
|
+
node bridge.mjs --config my-map.json # custom mapping (see DEFAULT_MAPPING in bridge-core)
|
|
132
|
+
node bridge.mjs --simulate frames.json --sink dry-run # replay recorded frames, no hardware
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- **xdotool** sink (X11): no native deps; set `--display :0` if `$DISPLAY` isn't set.
|
|
136
|
+
- **uinput** sink (Linux, lowest latency): a stdlib-only Python helper creates the virtual device.
|
|
137
|
+
It needs access to `/dev/uinput` — run as root, or add a udev rule, e.g.:
|
|
138
|
+
```
|
|
139
|
+
# /etc/udev/rules.d/99-uinput.rules
|
|
140
|
+
KERNEL=="uinput", GROUP="input", MODE="0660" # then: add your user to the `input` group
|
|
141
|
+
```
|
|
142
|
+
- Mapping config example (`my-map.json`):
|
|
143
|
+
```json
|
|
144
|
+
{ "buttons": {
|
|
145
|
+
"8": "space",
|
|
146
|
+
"0": "mouse1",
|
|
147
|
+
"1": "ctrl+s",
|
|
148
|
+
"2": ["ctrl+c", "ctrl+v"]
|
|
149
|
+
},
|
|
150
|
+
"stick": { "mode": "mouse" }, "mouse": { "speed": 22 } }
|
|
151
|
+
```
|
|
152
|
+
`stick.mode` is `keys` (arrows/WASD), `mouse` (relative pointer), or `axis` (gamepad).
|
|
153
|
+
- A button value can be:
|
|
154
|
+
- a single key — **held** while the button is held (`"space"`, `"a"`, `"mouse1"`);
|
|
155
|
+
- a **chord** — `"ctrl+s"` — fired once on press (modifiers held around the key);
|
|
156
|
+
- a **macro** — `["ctrl+c", "ctrl+v"]` or `["g", "i"]` — a sequence fired once on press.
|
|
157
|
+
|
|
158
|
+
Chords and macros make a single accessible switch trigger a complex action that would
|
|
159
|
+
otherwise need several simultaneous or sequential presses.
|
|
160
|
+
|
|
161
|
+
### Building a mapping
|
|
162
|
+
|
|
163
|
+
Author the config visually in the web **Key Bridge** blade, or from the terminal:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
node bridge.mjs edit # interactive press-to-bind editor (TTY)
|
|
167
|
+
node bridge.mjs edit --config my-map.json --out my-map.json
|
|
168
|
+
node bridge.mjs set 0=ctrl+s 8=space 2=ctrl+c,ctrl+v stick.mode=mouse --out my-map.json
|
|
169
|
+
node bridge.mjs show --config my-map.json # print the resolved config
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- **edit** is the CLI twin of the web editor: ↑/↓ to select a button/stick row, **Enter** to
|
|
173
|
+
bind (then press the key you want), **Del** to clear, **s** to save, **q** to quit.
|
|
174
|
+
- **set** targets: `0`..`9` (buttons), `stick.mode`, `stick.up/down/left/right`, `mouse.speed`;
|
|
175
|
+
a comma-separated value (`2=ctrl+c,ctrl+v`) becomes a macro. These commands need no controller.
|
|
176
|
+
|
|
177
|
+
> Verified with simulated input on Linux; on-hardware verification is pending a physical unit.
|
|
178
|
+
|
|
179
|
+
## Layout
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
web/access-protocol.mjs shared, I/O-free protocol (parse/build/CRC/enums) — used by both tools
|
|
183
|
+
web/profile-library.mjs shared, I/O-free profile sharing + preset library (used by the web tool)
|
|
184
|
+
lib/hid-node.mjs node-hid transport (Node CLI only)
|
|
185
|
+
web/bridge-core.mjs PC bridge: pure input->output mapping engine
|
|
186
|
+
lib/bridge-sinks.mjs PC bridge: output sinks (dry-run, xdotool, uinput)
|
|
187
|
+
lib/uinput-helper.py PC bridge: stdlib-only virtual device for the uinput sink
|
|
188
|
+
cli.mjs command-line tool (profiles)
|
|
189
|
+
bridge.mjs command-line PC input bridge
|
|
190
|
+
web/index.html + xmb.js XMB-style configurator (the web UI) + Library + live Monitor, via hid-web.mjs
|
|
191
|
+
web/controller-render.mjs shared controller SVG render + physical-input decode
|
|
192
|
+
web/monitor.html + monitor.js standalone XMB-styled live input monitor
|
|
193
|
+
web/hid-capture.html input-report diagnostics / RE tool
|
|
194
|
+
captures/ profile backups (created on backup/auto-backup)
|
|
195
|
+
reference/ Jacek Fedoryński’s original web editor — third-party, see reference/NOTICE.md
|
|
196
|
+
PROTOCOL.md protocol documentation
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Safety
|
|
200
|
+
|
|
201
|
+
Profiles live in 3 on-device slots and are fully recoverable: take a `backup` first, and note
|
|
202
|
+
that writes auto-back-up. Connecting the controller to a PS5 will overwrite these profiles with
|
|
203
|
+
the console’s copies.
|
package/bridge.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ps-access bridge — use the PlayStation Access Controller as a PC input device.
|
|
3
|
+
//
|
|
4
|
+
// Reads the controller's live input over USB and maps it to keyboard/mouse (xdotool)
|
|
5
|
+
// or a virtual gamepad (uinput) so it can drive ANY PC software, not just a PS5.
|
|
6
|
+
//
|
|
7
|
+
// node bridge.mjs --sink xdotool # stick -> arrows, buttons -> keys (X11)
|
|
8
|
+
// node bridge.mjs --sink uinput # virtual gamepad (needs /dev/uinput)
|
|
9
|
+
// node bridge.mjs --sink dry-run # print events, inject nothing
|
|
10
|
+
// node bridge.mjs --config my-map.json # custom mapping
|
|
11
|
+
// node bridge.mjs --simulate frames.json --sink dry-run # replay (no hardware)
|
|
12
|
+
//
|
|
13
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import readline from "node:readline";
|
|
15
|
+
import { BridgeEngine, decodeInput, DEFAULT_MAPPING } from "./web/bridge-core.mjs";
|
|
16
|
+
import {
|
|
17
|
+
PHYS_LABELS, STICK_MODES, STICK_DIRS, defaultBridgeMap, displayValue, toConfigJSON, keypressToValue,
|
|
18
|
+
} from "./web/bridge-map.mjs";
|
|
19
|
+
import { makeSink } from "./lib/bridge-sinks.mjs";
|
|
20
|
+
|
|
21
|
+
// Don't crash if our output is piped into something that closes early (e.g. `| head`).
|
|
22
|
+
process.stdout.on("error", (e) => { if (e.code === "EPIPE") process.exit(0); });
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const o = { sink: "dry-run", rate: 60, _: [] };
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const a = argv[i];
|
|
28
|
+
if (a === "--sink") o.sink = argv[++i];
|
|
29
|
+
else if (a === "--config") o.config = argv[++i];
|
|
30
|
+
else if (a === "--simulate") o.simulate = argv[++i];
|
|
31
|
+
else if (a === "--device" || a === "-d") o.device = argv[++i];
|
|
32
|
+
else if (a === "--display") o.display = argv[++i];
|
|
33
|
+
else if (a === "--rate") o.rate = Number(argv[++i]);
|
|
34
|
+
else if (a === "--out" || a === "-o") o.out = argv[++i];
|
|
35
|
+
else if (a === "--help" || a === "-h") o.help = true;
|
|
36
|
+
else o._.push(a);
|
|
37
|
+
}
|
|
38
|
+
return o;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadMapping(file) {
|
|
42
|
+
if (!file) return DEFAULT_MAPPING;
|
|
43
|
+
const m = JSON.parse(readFileSync(file, "utf8"));
|
|
44
|
+
return m.mapping || m; // accept {mapping:{...}} or a bare mapping
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const HELP = `ps-access bridge — drive a PC with the Access Controller (USB-C)
|
|
48
|
+
|
|
49
|
+
Usage:
|
|
50
|
+
node bridge.mjs [--sink <name>] [options] run the bridge (live or --simulate)
|
|
51
|
+
node bridge.mjs edit [--config f] [--out f] interactive press-to-bind key editor
|
|
52
|
+
node bridge.mjs set <target=value>... [--out f] set mappings non-interactively
|
|
53
|
+
node bridge.mjs show [--config f] print the resolved config JSON
|
|
54
|
+
|
|
55
|
+
edit/set targets: 0..9 (buttons), stick.mode, stick.up/down/left/right, mouse.speed
|
|
56
|
+
e.g. node bridge.mjs set 0=ctrl+s 8=space 2=ctrl+c,ctrl+v stick.mode=mouse --out my-map.json
|
|
57
|
+
|
|
58
|
+
Sinks:
|
|
59
|
+
dry-run Print events only (default; no OS input)
|
|
60
|
+
xdotool Keyboard + mouse via xdotool (X11)
|
|
61
|
+
uinput Virtual gamepad/keyboard via /dev/uinput (Linux; needs access)
|
|
62
|
+
|
|
63
|
+
Options:
|
|
64
|
+
--config <file> JSON mapping (see DEFAULT_MAPPING in web/bridge-core.mjs)
|
|
65
|
+
--simulate <file> Replay recorded input frames instead of reading hardware
|
|
66
|
+
--device <i|path> Select controller (default 0)
|
|
67
|
+
--display <:N> X display for xdotool (default $DISPLAY or :0)
|
|
68
|
+
--rate <hz> Simulate playback rate (default 60)
|
|
69
|
+
-h, --help This help
|
|
70
|
+
|
|
71
|
+
Mapping config example (my-map.json):
|
|
72
|
+
{ "buttons": { "8": "space", "0": "mouse1" },
|
|
73
|
+
"stick": { "mode": "mouse" }, "mouse": { "speed": 22 } }`;
|
|
74
|
+
|
|
75
|
+
async function runSimulate(opts, engine, sink) {
|
|
76
|
+
// Frames file: JSON array of byte arrays (report id already stripped), each one input report.
|
|
77
|
+
const frames = JSON.parse(readFileSync(opts.simulate, "utf8"));
|
|
78
|
+
const delay = Math.max(0, Math.round(1000 / (opts.rate || 60)));
|
|
79
|
+
console.log(`simulate: ${frames.length} frames -> ${opts.sink}`);
|
|
80
|
+
for (const f of frames) {
|
|
81
|
+
sink.apply(engine.update(decodeInput(Uint8Array.from(f))));
|
|
82
|
+
if (delay) await new Promise((r) => setTimeout(r, delay));
|
|
83
|
+
}
|
|
84
|
+
sink.apply(engine.releaseAll());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function runLive(opts, engine, sink) {
|
|
88
|
+
let HID;
|
|
89
|
+
try { ({ HID } = await import("./lib/hid-node.mjs")); }
|
|
90
|
+
catch (e) { throw new Error("node-hid is required for live mode (run `npm install`). " + (e.message || e)); }
|
|
91
|
+
const { listControllers } = await import("./lib/hid-node.mjs");
|
|
92
|
+
const list = listControllers();
|
|
93
|
+
if (!list.length) throw new Error("No Access Controller connected (VID 054C / PID 0E5F). Connect it via USB-C.");
|
|
94
|
+
const sel = opts.device ?? 0;
|
|
95
|
+
const entry = (typeof sel === "string" && sel.startsWith("/")) ? list.find((d) => d.path === sel) : list[Number(sel)];
|
|
96
|
+
if (!entry) throw new Error(`No controller for --device ${sel}`);
|
|
97
|
+
const device = new HID.HID(entry.path);
|
|
98
|
+
console.log(`bridge: ${entry.product} [${entry.index}] -> ${opts.sink}. Ctrl-C to stop.`);
|
|
99
|
+
device.on("data", (buf) => {
|
|
100
|
+
// node-hid prefixes the report id for numbered reports; the decoder expects it stripped.
|
|
101
|
+
const d = buf[0] === 0x01 ? buf.subarray(1) : buf;
|
|
102
|
+
try { sink.apply(engine.update(decodeInput(d))); } catch (e) { console.error("map error:", e.message); }
|
|
103
|
+
});
|
|
104
|
+
device.on("error", (e) => { console.error("device error:", e.message); shutdown(); });
|
|
105
|
+
function shutdown() {
|
|
106
|
+
try { sink.apply(engine.releaseAll()); sink.close(); device.close(); } catch { /* ignore */ }
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
process.on("SIGINT", shutdown);
|
|
110
|
+
process.on("SIGTERM", shutdown);
|
|
111
|
+
await new Promise(() => {}); // run until signal
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- config editing (no controller / node-hid needed) ----
|
|
115
|
+
|
|
116
|
+
// Load a saved config into a full editable map (fills unset buttons, merges stick/mouse).
|
|
117
|
+
function loadEditMap(file) {
|
|
118
|
+
const base = defaultBridgeMap();
|
|
119
|
+
if (!file) return base;
|
|
120
|
+
try {
|
|
121
|
+
const j = JSON.parse(readFileSync(file, "utf8"));
|
|
122
|
+
const m = j.mapping || j;
|
|
123
|
+
if (m.buttons) for (const k of Object.keys(base.buttons)) if (m.buttons[k] != null) base.buttons[k] = m.buttons[k];
|
|
124
|
+
if (m.stick) base.stick = { ...base.stick, ...m.stick };
|
|
125
|
+
if (m.mouse) base.mouse = { ...base.mouse, ...m.mouse };
|
|
126
|
+
} catch { /* treat as a new file */ }
|
|
127
|
+
return base;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function applySet(map, target, value) {
|
|
131
|
+
const val = value.includes(",") ? value.split(",").map((s) => s.trim()) : value; // comma -> macro
|
|
132
|
+
if (/^\d+$/.test(target)) { map.buttons[target] = val; return; } // 0..9 -> button
|
|
133
|
+
if (target === "stick.mode") { map.stick.mode = value; return; }
|
|
134
|
+
if (target.startsWith("stick.")) { map.stick[target.slice(6)] = val; return; }
|
|
135
|
+
if (target.startsWith("mouse.")) { map.mouse[target.slice(6)] = isNaN(+value) ? value : +value; return; }
|
|
136
|
+
throw new Error(`unknown target "${target}" (use 0..9, stick.mode, stick.up.., mouse.speed)`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function showConfig(opts) {
|
|
140
|
+
console.log(toConfigJSON(loadEditMap(opts.config)));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function setConfig(opts) {
|
|
144
|
+
const assigns = opts._.slice(1);
|
|
145
|
+
if (!assigns.length) throw new Error('usage: set <target=value>... e.g. set 0=ctrl+s 8=space stick.mode=mouse');
|
|
146
|
+
const map = loadEditMap(opts.config);
|
|
147
|
+
for (const a of assigns) {
|
|
148
|
+
const m = a.match(/^([^=]+)=(.*)$/);
|
|
149
|
+
if (!m) throw new Error(`bad assignment "${a}" (want target=value)`);
|
|
150
|
+
applySet(map, m[1].trim(), m[2].trim());
|
|
151
|
+
}
|
|
152
|
+
const out = opts.out || opts.config || "ps-access-bridge.json";
|
|
153
|
+
writeFileSync(out, toConfigJSON(map));
|
|
154
|
+
console.log(`wrote ${out}\n`);
|
|
155
|
+
console.log(toConfigJSON(map));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Interactive press-to-bind editor — the CLI twin of the web Key Bridge blade.
|
|
159
|
+
function editConfig(opts) {
|
|
160
|
+
if (!process.stdin.isTTY) throw new Error("`edit` needs an interactive terminal — use `set`/`show` for scripting.");
|
|
161
|
+
const map = loadEditMap(opts.config);
|
|
162
|
+
const out = opts.out || opts.config || "ps-access-bridge.json";
|
|
163
|
+
let sel = 0, capturing = false, note = "";
|
|
164
|
+
|
|
165
|
+
const targets = () => {
|
|
166
|
+
const t = PHYS_LABELS.map((label, i) => ({ type: "button", i, label, get: () => map.buttons[i] }));
|
|
167
|
+
t.push({ type: "stickmode", label: "Stick mode", get: () => map.stick.mode });
|
|
168
|
+
if (map.stick.mode === "keys") for (const dir of STICK_DIRS) t.push({ type: "stick", dir, label: "Stick " + dir, get: () => map.stick[dir] });
|
|
169
|
+
return t;
|
|
170
|
+
};
|
|
171
|
+
const assign = (t, v) => { if (t.type === "button") map.buttons[t.i] = v; else if (t.type === "stick") map.stick[t.dir] = v; };
|
|
172
|
+
const render = () => {
|
|
173
|
+
const list = targets();
|
|
174
|
+
let s = "\x1b[2J\x1b[H\n ps-access bridge — key editor\n";
|
|
175
|
+
s += " ↑/↓ select · Enter bind (or cycle stick mode) · Del clear · s save · q quit\n\n";
|
|
176
|
+
list.forEach((t, idx) => { s += ` ${idx === sel ? "\x1b[36m▸" : " "} ${t.label.padEnd(13)} ${displayValue(t.get())}\x1b[0m\n`; });
|
|
177
|
+
s += capturing ? "\n \x1b[33m▶ press a key to bind… (Esc cancels)\x1b[0m\n" : `\n saving to: ${out}${note ? " " + note : ""}\n`;
|
|
178
|
+
process.stdout.write(s);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
readline.emitKeypressEvents(process.stdin);
|
|
182
|
+
process.stdin.setRawMode(true);
|
|
183
|
+
process.stdin.resume();
|
|
184
|
+
render();
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
const quit = () => { process.stdin.setRawMode(false); process.stdin.pause(); process.stdout.write("\n"); resolve(); };
|
|
187
|
+
process.stdin.on("keypress", (_str, key) => {
|
|
188
|
+
const list = targets();
|
|
189
|
+
const t = list[Math.min(sel, list.length - 1)];
|
|
190
|
+
if (capturing) {
|
|
191
|
+
if (key.name === "escape") { capturing = false; note = "cancelled"; }
|
|
192
|
+
else if (key.name === "delete") { assign(t, "nothing"); capturing = false; writeFileSync(out, toConfigJSON(map)); note = "cleared + saved"; }
|
|
193
|
+
else { const v = keypressToValue(key); if (v) { assign(t, v); capturing = false; writeFileSync(out, toConfigJSON(map)); note = "bound + saved"; } else return; }
|
|
194
|
+
render(); return;
|
|
195
|
+
}
|
|
196
|
+
if (key.name === "up") { sel = Math.max(0, sel - 1); note = ""; render(); }
|
|
197
|
+
else if (key.name === "down") { sel = Math.min(list.length - 1, sel + 1); note = ""; render(); }
|
|
198
|
+
else if (key.name === "return") {
|
|
199
|
+
if (t.type === "stickmode") { map.stick.mode = STICK_MODES[(STICK_MODES.indexOf(map.stick.mode) + 1) % STICK_MODES.length]; writeFileSync(out, toConfigJSON(map)); note = "saved"; }
|
|
200
|
+
else { capturing = true; note = ""; }
|
|
201
|
+
render();
|
|
202
|
+
}
|
|
203
|
+
else if (key.name === "s") { writeFileSync(out, toConfigJSON(map)); note = "saved " + out; render(); }
|
|
204
|
+
else if (key.name === "q" || (key.ctrl && key.name === "c")) quit();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function main() {
|
|
210
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
211
|
+
if (opts.help) { console.log(HELP); return; }
|
|
212
|
+
const sub = opts._[0];
|
|
213
|
+
try {
|
|
214
|
+
if (sub === "edit") return await editConfig(opts);
|
|
215
|
+
if (sub === "set") return setConfig(opts);
|
|
216
|
+
if (sub === "show") return showConfig(opts);
|
|
217
|
+
} catch (e) { console.error("error:", e.message); process.exit(1); }
|
|
218
|
+
|
|
219
|
+
// default: run the bridge
|
|
220
|
+
const engine = new BridgeEngine(loadMapping(opts.config));
|
|
221
|
+
const sink = makeSink(opts.sink, { display: opts.display });
|
|
222
|
+
try {
|
|
223
|
+
if (opts.simulate) { await runSimulate(opts, engine, sink); sink.close(); }
|
|
224
|
+
else await runLive(opts, engine, sink);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.error("error:", e.message);
|
|
227
|
+
try { sink.close(); } catch { /* ignore */ }
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
main();
|