htop for a HEN'd PS3 — talks to webMAN MOD over HTTP. Status, temps, running game, and mount/launch/eject of the game library, in one dashboard. Built 2026-08-09 against webMAN MOD 1.47.48 (sMAN skin) on FW 4.93 PS3HEN.
╭─ ps3top · 192.168.0.100 · online ─────────────────────────────────────── 4.93 HEN · wM 1.47.48q ─╮
│ CPU 58° ▁▁▂▃▄▅▅▆↗ RSX 61° ▃▃▃▃▄▄▄▄ FAN 26% ▄▄▄▄▄▄▄▄ HDD 123G MEM 1.1M ∞ 100d · up 1h24m │
│ ▶ Sample Game™ 2 v01.15 · MOCK30982 play 1h23m │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
── PSX · 1 │ PS3 · 21 ───────────────────────────────────────────────────────────────────── 1/3 ──
▌ 16 Sample Game™ 2 [MOCK30982] ● mounted 38h12m
17 Sample Quest: The Beginni… [MOCK00103] 6h04m
18 Fixture Storm™ [MOCK98137]
── ? help ──────────────────────────────────────────────────────────────────────────────────────────
(A real terminal also carries the cover art panel on the right, at ≥84 cols — and with the panel up, the per-row play total goes: it sits under the cover instead, and the column's width is worth more to the cover.) The status frame turns red while the temp alarm is active. Healthy metric values render green; the active console tab is highlighted in the brand blue.
go install github.com/lucasdaddiego/ps3top@latest # → ~/go/bin/ps3top
go install github.com/lucasdaddiego/ps3top/cmd/binmerge@latest # → ~/go/bin/binmerge
ps3top # auto-discovers the consoleTwo binaries: ps3top, the TUI, and binmerge, a CLI that preps
PS1 rips for the PSX tab. From a clone: make install puts stripped release
builds of both in ~/.bin, make build stamped ones in bin/ (gitignored);
make lists the other targets (run, test, lint, tidy, clean).
Flags: --host (default: auto-discover; env PS3TOP_HOST) · --interval
(15s, min 5s) · --alarm (80°C, 1–120) · --no-art · --no-patch (never ask
Sony about the running game) · --version. It's a TUI and
nothing else — no one-shot or scripting mode. --help prints the key
reference; ? inside the app shows the same table as a screen (the footer
used to carry a keybar, which never fit every key at any width and so always
hid the ones worth discovering).
With no --host, ps3top finds the console itself: it sweeps the machine's
private IPv4 /24s (TCP :80, then a GET /cpursx.ps3 that must answer with the
webMAN banner — anything else on port 80 is rejected), takes the first hit,
and caches it (~/Library/Caches/ps3top/host on macOS, XDG cache on Linux).
Later launches take the cached address on trust and go straight to the
dashboard — the first status poll is the verification, so there's no
probe-then-poll double request, and a console that's still off gets an
offline dashboard that comes alive when it boots rather than a refusal to
start. If that first poll fails, one background sweep runs per outage (the
header says scanning 192.168.0.0/24 for webMAN…); a console DHCP moved is
adopted and re-cached, a console that's simply off is retried on its old
address every 15s, and r asks for another sweep. A cold sweep takes a few
seconds; two consoles on one LAN → first answer wins, use --host to pick.
(Wake-on-LAN existed in 0.1.0 and was removed: the PS3 only listens for magic packets with Remote Start on, that toggle is gated behind registering a PSP/Vita, and on a Super Slim a standby wake is a cold boot where HEN — and therefore webMAN and this tool — is dead until two controller presses anyway. The DS3's PS button is the wake button.) Note: after a cold boot, webMAN only appears once HEN is active — with HEN auto-boot the dashboard recovers by itself, otherwise enable HEN from the XMB first.
Two things reach you when the window isn't in front, as a desktop
notification (terminal-notifier if installed, else osascript; notify-send
on Linux) on top of the bell and the red frame: a sensor crossing the alarm,
and the console going quiet in the middle of a game — a crash, a hard-off or
a lost network, all worth knowing from another window. A console going quiet
on the XMB is just switched off and says nothing. Same triggers as before,
one more sink, nothing periodic.
Metric scales (researched, values plain when healthy — color means attention):
- Temps (PSX-Place/GBAtemp consensus; webMAN's own fan target is 68°): 60s–low 70s is normal PS3 gaming → plain; 70–77 yellow; 78+ red; alarm (flash + bell) at 80 by default — console overheat-warns/shuts down ~85.
- FAN (webMAN wiki: 40% manual recommended on HEN, SYSCON fine "if under 70% in games"): plain <50, yellow 50–69, red 70+. In manual mode the speed is fixed and nothing ramps it for you, so a low percentage is fine on XMB and is the one way to cook a console mid-game; in dynamic mode webMAN ramps toward a target temperature you can raise past anything sane. The mode is rendered large on the thermal screen for exactly that reason.
- HDD free (dual-layer PS3 ISO ≤ ~45GB): plain ≥60G, yellow <60G, red <20G.
- MEM = free available memory (
meminfo.availin webMAN source) — ~1MB in-game is normal (the game owns the RAM): yellow <512K, red <256K.
A field the status page doesn't carry renders as a dim —, never as a number.
This matters more than it looks: 0 is a value webMAN legitimately reports
(FAN SPEED: 0%), so it can't double as "absent" — and a temperature of 0°
colors as perfectly healthy, meaning a webMAN markup change would otherwise
make a broken parser look like a cool console. Losing fields also raises a
flash: status page: N of 6 fields not found. It fires when the count gets
worse, not once per run — a steady break stays quiet after the first warning
instead of nagging every 15s, an outage and recovery into the same state don't
re-warn, and a page that later breaks harder still says so. The em dashes are
the standing signal; the flash is the explanation.
Header extras: game version (· v01.15) next to the running title — with
→ 01.17 available in yellow when Sony's title-update index lists a newer
patch (one HTTPS GET to a0.ww.np.dl.playstation.net per running title per
session, never to the console; its certificate is Sony's own SHA-1 CA, which
Go refuses, so the 2004–2037 root is pinned in patch.go and the leaf is
verified by hand; --no-patch skips the check, so no title ID leaves the
LAN) — and the syscon lifetime counters right of the metrics — ∞ 218d · 2709 boots · 65 hard-off (hard-off = power-ons minus clean power-offs).
CPU, RSX and FAN carry an inline sparkline over the last ~240 polls (an hour at the default cadence), bucketed so a full ring shows the whole hour rather than the last two minutes. It costs nothing on the wire — the samples come from the poll that was already happening.
The scale is the window's own min/max widened to a floor (10° for temps, 20
points for fan) and centered, so idle sensor jitter stays shallow and a steady
reading sits mid-height instead of pinned to the floor. A ↗ appears when the
last ~10 minutes moved by 3° or more (warn-colored, since a climb is the thing
you might have to act on); cooling gets a dim ↘; anything smaller is noise
and gets nothing. Offline stretches are recorded as gaps, so the x axis stays
honest instead of silently compressing across an outage.
Fan-vs-temp correlation ("is SYSCON actually ramping") is shown rather than told — the two plots sit side by side.
t swaps the game list for a full-width temperature screen: CPU and RSX as
block-font readouts colored by their thermal band (readable across a room), a
multi-row plot of the same history at a much bigger zoom, and fan control.
CPU RSX
██████ ██ ██ ██████ ██████ ██████ ██████
██ ██ ██ ██ ██ ██ ██ ██ ██ ██
██ ██████ ██████ ██████ ██████ ██████
██ ██ ██ ██ ██ ██
██ ██ ██████ ██████
warm normal
80 ┤· · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · ·
┤ ▄▄▄▄▄▄▄▄▄▄▄▄
70 ┤· · · · · · · · · · · · · · ·▄▄▄▄▄█████ · · · · · · · · · · · ·
┤ ▄▄▄▄▄▄▄▄▄▄▄▄█████ ▄▄▄▄▄▄▄▄▄▄▄███████████
56 ┼████████████
└ 1h00m ago ── CPU ── RSX now ┘
FAN 42% SYSCON ▁▂▂▂▃▃▃▄▄▄▅▅▅▆▆▆ +/− adjust · f mode
Two lines share one auto-scaled axis: color identifies the sensor, dotted gridlines mark the 70 / 78 / alarm thresholds, and position carries the value. (Recoloring the lines by band would have made the two sensors indistinguishable exactly when it matters.) The axis pulls in a threshold sitting just above the data so you can see the headroom rather than a full-height trace with nothing to read it against. Under ~21 terminal rows the big readouts drop and the plot takes their space; the fan row never drops, since it's where the controls are documented.
↑/↓ (or +/−) step the speed, f toggles the mode. Fan control uses
/cpursx.ps3?up · ?dn · ?mode — the links webMAN hangs on its own
temperature and fan readings. Those endpoints answer with the entire status
page, so the response is the new state: ps3top adopts it directly (one
request, not an action plus a re-poll) and flashes the delta that actually
happened — fan 26% → 31%, or nothing changed (fan 100%, manual) when the
console declines.
That self-verifying report earned its keep immediately. The page renders one slot three different ways depending on who is driving the fan:
| slot | mode | what ↑↓ moves |
|---|---|---|
<small>[Fan control: SYSCON]</small> |
console's own control | — |
(MAX: 86°C) |
webMAN ramps to hold a target temp | the target |
| nothing | manual fixed % | the percentage |
f cannot get you back to SYSCON. ?mode only cycles webMAN's own
strategies (Lowest / Manual / Auto on /setup.ps3), and each of those sets
fc.checked = 1 — SYSCON is that checkbox off ("Enable dynamic fan
control"), reachable only from the setup form. So leaving SYSCON is a one-way
door from in here, and f asks first when you're on it, labelled f take over
rather than f mode. Getting back: open http://<ps3>/setup.ps3, untick
Enable dynamic fan control, save. ps3top deliberately won't submit that
form itself — it's 143 fields and 103 checkboxes, and a partial GET submit
would silently clear every setting that isn't currently ticked.
Two consequences worth knowing. First, an absent [Fan control: …] marker is
not a parse failure — it's manual mode, so the mode is read from which marker
is present including neither (guarded on the fan percentage having parsed, so a
genuinely broken page still reports nothing rather than claiming manual).
Second, ?up means different things in different modes: pressing it in dynamic
mode moved the target 86°→98° while the fan sat at 31%. A report written around
the percentage would have said "unchanged" while the console's thermal ceiling
quietly rose 12°.
So fanReport diffs every field a fan command can move — mode, percentage,
target — and names whatever actually changed. The keybar hint follows the mode
too (↑↓ target vs ↑↓ speed), and the Fahrenheit copy of that line carries
its own (MAX: 186°F), so the pattern requires °C.
Presses are queued and sent one at a time: webMAN has ~4 session slots, and
one GET per keypress turns a quick 24%→40% adjustment into a pile-up that reads
as an unresponsive UI. The queue is capped, cleared on error, and shown live in
the fan row (⋯+3) so a press registers before the console has answered.
The queue is ordered, not netted, and that matters precisely because ?up
means different things in different modes. A step queued before a ?mode was
aimed at the old mode: press ↑↑ in manual to spin the fan up, then f, and
sending the mode change first would leave those two presses raising the
temperature ceiling on a console you were trying to cool. Press order is
preserved exactly; only an immediate ↑/↓ reversal cancels, and only against
the tail, so it can never reach across a ?mode.
For the same reason the fan keys do nothing until a status has established
the mode. With the mode unknown, ↑↓ can't say what they'd move and f
can't warn that it's a one-way door out of SYSCON — so they flash and wait
rather than guess.
No confirm on fan changes, deliberately: the in-game confirm elsewhere exists
because /mount_ps3 can pull a running game's disc, which loses progress. A
fan step has no such failure mode — it's small, immediately visible, and undone
by the opposite key. The mode is rendered large because leaving the console in
manual at a low percentage is the one way to get this wrong.
When the header can't hold everything, it gives up the least live thing first:
full lifetime counters → ∞ Nd only → sparklines → metric readings last.
The one-hour rings can't answer "is it running hotter than in May", so every
minute of polling appends one line to ~/Library/Application Support/ps3top/ thermal.ndjson (XDG data dir on Linux): the minute's peak CPU, RSX and fan.
Downsampled on the way in — ~60 bytes a minute, no compactor — and read only
when the page is opened. The page lays the log out by week, newest first:
hours logged, CPU and RSX peak / average, and how the fan split its minutes
across the header's colour bands (<50 · 50–69 · 70+), with the all-time
peaks dated above. The open minute is written on quit.
The PS3 keeps no record of how long you've played anything. ps3top appends one
NDJSON line per session to ~/Library/Application Support/ps3top/history.ndjson
(XDG data dir on Linux) — deliberately the data dir and not the cache dir,
since covers are disposable and this file isn't.
Session length comes from webMAN's own PlayTime field rather than a clock
ps3top starts, so a record is right even when ps3top joins a game already in
progress. That's also why there's no open-session sidecar file to recover after
a crash. A session that both starts and ends while ps3top isn't running is
simply never seen.
The catch is that PlayTime is cumulative for the whole game process: quit
ps3top mid-game and it writes the total so far; the next run writes the total
again when the game finally ends. Both lines describe one session. They're
recognised as one by their start time (end − secs, stable across
restarts) and merged on load, keeping the longest reading and counting it once.
The start time is derived rather than stored, so a log written by an older
ps3top is repaired the same way. Two genuinely separate plays still count
twice — only readings starting within five minutes of each other merge.
What it buys you: s toggles the list between alphabetical and
recently-played (so the handful of games you actually play float above the
alphabetical wall), rows carry a dim play total when the art panel is
hidden, the art panel gains
38h12m · 9 sessions · last 2h ago, and quitting a game flashes
Borderlands™ 2 — 2h14m (peak 71°/74°).
↑↓/jk move (smooth scroll, 2-row margin) · pgup/pgdn/ctrl+u/d page ·
home/end jump · tab/←→/hl switch console tab · ⏎ mount (already
mounted → launch) · p play = mount+launch · u eject · / fuzzy filter
(esc clears, scoped to the active tab) · s sort alphabetical → recently
played → largest ISO first (the size sort is the one that puts the size on
the row; the others give that width to the cover) · ? help screen · x
quit the running game to the XMB · X restart it (both only
while a game is known to be running, both behind the red confirm) · t
thermal screen · m popup message on the TV · r refresh now
(status and game list) · g rescan the library · S/R shutdown/restart
(confirmed) · q quit.
r vs g matters when you've just FTP'd an ISO over: mygames.xml is static
until webMAN rebuilds it, so r shows you what webMAN already knows, while g
fires /refresh.ps3?xmb (the sMAN skin's own Refresh action) to make webMAN
re-scan the ISO folders and regenerate the XML, then reloads the list once
webMAN has had a beat to finish writing it. The rescan holds the same one-at-a-
time action gate as mount/eject — the console is genuinely busy while it scans —
and gets a 30s timeout instead of the usual 6s, since it walks every ISO
directory. Neither key loses your place: a reload re-finds the selected game by
identity instead of dumping the cursor back to row 1.
Inside the thermal screen: +/− (or ↑↓) fan speed in manual mode, target
temperature in dynamic — the footer names whichever it currently is · f fan
mode · h the long-term history page (and back) · r refresh · esc (or
t, or q) back. q closes the screen rather than quitting ps3top
— quitting out of a subscreen on the key that everywhere else means "back" is
a nasty surprise mid-session.
The separator line is a tab strip, one tab per console present, in release
order — ⟨ PSX · 1 ⟩⟨ PS3 · 21 ⟩ — with cursor position (and filter state) on
the right, plus recent when that sort is active. Games are numbered per
console and sorted alphabetically; starts on the PS3 tab. s keeps the cursor
on whatever game it was on, so you can flip sorts without losing your place.
While a game is running, mount/play/launch/eject/power ask for a red confirm
first — deliberately, because the /mount_ps3 silent variant bypasses webMAN's
own in-game mount protection.
They ask equally hard when ps3top can't tell: before the first status has landed, and after any failed poll, the last reading is stale and "no game is running" would be a guess. The prompt says the state is unknown rather than naming a title it doesn't trust. Only a console known to be sitting on the XMB skips the confirm.
- One
GET /cpursx.ps3per 15s on the XMB (webMAN's own web-UI refresh cadence; the page is ~6.6KB and carries every field server-rendered) and every 30s while a game is running — that's when the console is busiest, so that's where ps3top asks least — dropping back to 15s the moment a sensor reaches 70°, since alarm latency is the one reason to poll a running game fast. At the slow cadence each reading fills two sparkline slots, so the x axis stays one slot per 15s and a full ring stays an hour. Immediate extra poll after an action. Launch is that one poll, alone: the game list is fetched once the first status has answered (and again whenever the console comes back from an outage), so startup never has more than two connections open and a host that turns out not to be webMAN is never asked for a list. One console-state request in flight at a time — status polls, actions and fan commands share one gate, so a scheduled poll can't race a fan reply and overwrite the newer reading with the older one; a poll that comes due while busy is deferred, not dropped. Fresh connection each time (webMAN's server has ~4 session slots — never hold one). Timeouts: 2s dial, 6s per request (8s for games and covers, 30s for a rescan), and a 35s client backstop behind them all. Offline → retry every 15s. Game-list and cover fetches are outside that gate by design — they're idempotent reads that don't carry console state; the ISO-folder listings that give the size column go out one at a time, and the PARAM.SFO reads behind the installed games are remembered for the session, so a reload re-reads only folders it hasn't seen. --hostis parsed as a barehost[:port]authority and rejected otherwise: a value with a path silently prefixed every endpoint. Redirects to a different host are refused, so an action can't be aimed somewhere other than the address on screen.- Every string webMAN sends — titles, firmware, paths — is stripped of terminal control sequences at the parser, before it can reach the screen. Covers are checked for a real PNG header and sane dimensions before being cached or handed to the terminal's image decoder.
- Game list from the static
mygames.xml(/dev_hdd0/xmlhost/game_plugin/), fetched at startup and onr. It's XMB pseudo-XML (<>value</>), hence the regex parser. Its shape follows the Add game-ID to game-title box on/setup.ps3: ticked, the title ID is a[BLES00455]suffix on the title; unticked, it's in theinfofield ashdd0/PS3ISO | BLES00455 | v01.90, version included. Both parse, and the version shows in the art panel.gfirst asks webMAN to rebuild it (/refresh.ps3?xmb, 30s timeout — a rescan walks every ISO directory) and reloads 1.5s after the reply, in case the XML is still being written when webMAN answers. - Library sizes: after every games load, one
GET /dev_hdd0/<folder>/per ISO folder the library draws from (two for a PS3ISO + PSXISO library). webMAN's listing hangs the exact byte size on each row's mount link, keyed by the path it mounts by —Game.Path— so the join is by identity. The art panel gains· 9.3G, the header's HDD figure gains~27 more(free space over the median PS3 ISO), and two files of identical size are flashed once as probably the same ISO twice. - Covers follow the covers source on
/setup.ps3: ICON0.PNG is the ISO's own 320×176 icon (/dev_hdd0/tmp/wmtmp/), MM COVERS is multiMAN's folder of 260×300 JPEGs (/dev_hdd0/game/BLES80608/USRDIR/covers/, filled by whatever cover pack you installed). Either is fetched once per game and cached as PNG in~/Library/Caches/ps3top/covers/(the kitty transmission only carries PNG, so a JPEG is converted on the way in). ONLINE COVERS hasn't been captured; an icon that isn't a console path is refused rather than guessed at. Each cover is placed at its own aspect inside the art box rather than stretched to it, since kitty scales an image to exactly the cells it's given — a portrait cover stays portrait. The box takes every column the list doesn't need for its widest row (24–96 columns) and as many rows as the three text lines under it leave and the placement is shaped at the terminal's measured cell aspect (TIOCGWINSZpixel fields; kitty and Ghostty fill them in, 1:2 assumed otherwise) — the terminal letterboxes inside a placement of the wrong shape, and that strip can't be centered from outside. A resize re-places the covers the terminal already holds without touching the console. The panel is centered in the list's height. A cached cover shows at once; an uncached one is requested only after the cursor has rested on its row for 150ms, and never more than one at a time — holdingjthrough a fresh library used to open one connection per row it passed, at a server with ~4 slots. A cover webMAN can't serve is remembered for the session (no coverin the panel) instead of being re-requested on every visit;r/gretry it. - Actions:
/mount_ps3/<path>·/mount_ps3/unmount·/play.ps3(launch mounted) ·/play.ps3/<path>(mount+launch) ·/popup.ps3/<text>·/refresh.ps3?xmb(library rescan) ·/xmb.ps3$exit(quit game to XMB) ·/xmb.ps3$reloadgame(restart game) ·/shutdown.ps3·/restart.ps3·/cpursx.ps3?up|dn|mode(fan; the reply is the new status page, so these cost one request and no follow-up poll). - webMAN also runs a PS3MAPI text protocol on port 7887 (temps, IDPS, process list, memory peek/poke) — unused here, but it's the door for any future tool that needs live memory access.
Meant to sit open all day: the renderer (bubbletea v2's cell-diffing one) is capped at 30fps, a frame is only built on a keypress or a poll, and an idle flush tick is a pointer-equal string compare and nothing else. The only periodic work is the one 15s status GET plus its ~15 regex matches over 6.6KB. Cover PNGs are handed to the terminal and not retained in the model; the terminal keeps each browsed cover (~225KB decoded) until ps3top exits and deletes them all. Idle: ~12–14MB RSS (Go runtime floor), CPU rounds to zero.
Sparklines and play history don't move any of that: the samples come from the poll that already runs (three fixed 240-int rings, ~6KB total; the window each frame reads is materialized once per poll, not once per read), and the history file is appended once per finished session — a few hundred bytes a day at worst, read once at startup.
Kitty graphics protocol in Unicode-placeholder mode (kitty & Ghostty; detected
via TERM/TERM_PROGRAM). Images are transmitted once with q=2 and rendered
as placeholder cells, which survive bubbletea redraws. The transmission goes
out through tea.Raw, which bubbletea flushes from the same ticker as the
frame and ahead of it, and the panel only references an image once that write
has been sequenced — so the bytes are always in the terminal before the cells
that point at them. art_test.go drives a headless renderer and checks the
placeholder, its diacritics and the 38;5;id foreground come out the other
side intact, since the cell renderer re-encodes everything it draws.
Unsupported terminal or --no-art → text-only, no errors.
A separate binary in this repo, because ps3top is a TUI and nothing else and
this is a batch job. It merges a cue sheet's bin files into a single bin and
writes a new cue with corrected offsets; --split reverses it.
That's the prep step the PSX tab needs. Redump dumps a multi-track PS1 disc as
one bin per track, and the PS3's PS1 emulator mounts one bin per disc — so a
freshly downloaded rip is a set webMAN will list and fail to boot. Merge it,
copy the pair to /dev_hdd0/PSXISO/, mount it from the PSX tab:
# a Redump rip: Game.cue + "Game (Track 1..N).bin"
binmerge Game.cue Game --psx -o ~/psx/out
# → out/Game.bin + out/Game.cue, ready for /dev_hdd0/PSXISO/--psx is checks, not magic — the merge is byte-identical without it. It
refuses --split (a split set is a multi-file cue, the one thing the emulator
can't mount), warns when the sector size isn't 2352 (a cooked rip won't boot)
or when the basename carries #, ? or % (webMAN mounts by URL path), and
prints the filenames to copy. Everything else about the tool is
console-agnostic and works on any cue.
Flags: -s/--split · -o/--outdir · -f/--force · -n/--dry-run · --psx ·
-v/--verbose · -V/--version. Positionals and flags interleave in either
order. -n validates and prints the cue it would write to stdout, touching
nothing — not even the output directory.
What it protects, since a merge reads the only copy of someone's disc:
- inputs are never overwritten,
--forceincluded. Paths aren't identity — a hardlink, or a case-only difference on the case-insensitive filesystem macOS ships by default, names the same inode under a name that compares unequal — so inodes are compared too, and the comparison happens against the resolved path (a../NAMEbasename only aliases an input once..is collapsed, and the output directory it escapes may not exist yet). - two outputs never land on one path. Track filenames come from track numbers, so a cue repeating one would write the same file twice, lose the first track's sectors, and report success.
- nothing partial survives a failure. A merge or split that dies halfway — Ctrl-C included, which the copy loops watch for rather than leaving to the process — removes what it had written. A half-merged bin is the right length to look plausible and the wrong bytes to boot.
- metadata is carried through.
FLAGS,PREGAP,POSTGAP,ISRC,CATALOG, disc and trackTITLE/PERFORMER,REM— every line the parser doesn't act on comes out where it went in, on the right side of the indexes. The regexes are anchored, so a keyword inside aREMstays a comment. - non-UTF-8 cues decode as cp1252, with latin-1 only as the last resort. The rewritten cue goes out as UTF-8, so a wrong decode is permanent: 0x92 is a right single quote in a Windows-made cue, and latin-1 would bake a C1 control character into the title. A sheet that holds one of the five bytes cp1252 leaves undefined (0x81, 0x8D, 0x8F, 0x90, 0x9D) is not cp1252, so that sheet decodes as latin-1, whole.
It descends from a Python tool of the same name (2.1.0, the version -V still
reports) that lived in its own repo until 2026-08-24; the Go port passes that
tool's full black-box suite unchanged.
main.go at the root is ten lines — the build stamp handed into
ps3top.Run — so go install …/ps3top@latest keeps working. cmd/binmerge
is the same shape for the other binary: flags and exit codes, with the work in
internal/binmerge (cue.go parse + generation, validate.go the sector and
output guards, merge.go the copying and its cleanup, run.go the
orchestration). The TUI is
internal/ps3top, one flat package deliberately: the tests live in-package
and exercise internals directly, and a split by concern would churn five
thousand lines of tests for nothing. The files carve it by concern instead
(the same map lives in doc.go):
| file | owns |
|---|---|
run.go |
flags, discovery hand-off, program start |
model.go |
the bubbletea model, its messages, list mechanics |
update.go |
Init/Update/handleKey, network commands, the action gate |
view.go |
every frame of the main screen, styles, shared formatters |
session.go |
what each poll feeds: metric samples, play sessions |
fan.go |
the serialized fan-command queue and its delta reports |
thermal.go |
the thermal screen (t) that fan.go's queue drives |
webman.go |
HTTP client + parsers for webMAN's pages |
discover.go |
the LAN sweep behind auto-discovery |
patch.go |
Sony's title-update index, behind a pinned root |
help.go / notify.go |
the key table behind ? and --help / desktop notifications |
history.go |
the NDJSON play log behind the play totals |
spark.go |
metric rings + sparkline renderer |
thermallog.go |
the per-minute thermal log and its weekly fold |
art.go / cover.go |
kitty-graphics plumbing / cover fetch + cache |
And beside it, the annexed tool:
| file | owns |
|---|---|
cmd/binmerge/main.go |
flags, the positional/flag interleave, exit codes |
internal/binmerge/cue.go |
decode, parse, timestamps, both cue generators |
internal/binmerge/validate.go |
sector size, bin sizes, index bounds, output guards |
internal/binmerge/merge.go |
the copying, and the cleanup after a failed one |
internal/binmerge/run.go |
Options/Run, the logger, the --psx checks |
Each *_test.go matches its file, plus state_test.go/edges_test.go for
model transitions and end-to-end key→wire paths, and ui_test.go for whole
frames.
make test (go vet + go test -race ./...) covers both binaries, and CI runs
the same with gofmt, staticcheck and govulncheck on every push. binmerge's suite needs no fixtures on
disk — it builds a three-track Redump set in a temp dir per test — and pins the
round trip (merge then split gives back byte-identical bins and the original
cue), the metadata passthrough, every fatal path and the message it prints,
and the three output guards, including the hardlink and case-only aliases that
--force must still refuse. The port was landed against the Python original's
black-box suite driving the Go binary, which passed unchanged before a line of
Go test was written; that suite lives on in internal/binmerge/*_test.go.
The rest of this section is ps3top's own. go test runs the parsers against
the fixtures in internal/ps3top/testdata/ (in-game
cpursx.ps3, mygames.xml with the game-ID-in-title setting on, and
mygames_info_ids.xml with it off plus MM COVERS). The fixtures are synthetic: byte-faithful to
real webMAN (sMAN skin) output in structure, but every game title, title ID,
and counter is invented (MOCK…/DEMO… IDs), chosen to exercise the sort
rules — word-before-number, roman numerals, V2-style numbers, embedded
digits, ™/® noise, newline titles, and the franchise-ID hint.
Beyond the parsers: spark_test.go pins the sparkline's scaling floor and
trend noise band, history_test.go covers session recording (including that
length comes from webMAN's counter, not ps3top's uptime) and the recency sort,
and ui_test.go renders whole frames in every state ps3top sits in all day —
asserting each line lands on exactly the terminal width, since a wrapped line
pushes everything below it down a row and shears the frame. It also pins the
header's degradation order, which is a judgment call worth not regressing.
thermal_test.go covers the glyph font and plot geometry, that the fan keys
bind only inside the thermal screen, that q there means "back" and not
"quit", and — via httptest — that the fan endpoints are addressed correctly
and their response is parsed as the new status. Nothing in the suite talks to
a real console — every fan test runs against httptest. Fan commands have
been fired at hardware by hand, which is where the three marker shapes and the
dynamic-mode ?up behaviour were observed; it's the automated suite that never
touches the console.
testdata/cpursx_xmb.html is derived from the in-game fixture (game block
and PlayTime removed), not captured. It proves the parser handles an absent
game block — which feeds the mount/eject guard — but not that this is
byte-for-byte what a console emits on the XMB. Replace it with a scrubbed real
capture when one is to hand.
If webMAN gets updated and parsing breaks: capture the fresh pages to
somewhere outside the repo (curl -s http://$PS3/cpursx.ps3 and
…/dev_hdd0/xmlhost/game_plugin/mygames.xml), diff the structure, and port
the structural changes into the synthetic fixtures. Never commit a raw
capture — cpursx.ps3 embeds the console's PSID, IDPS, and MAC address (in
the hidden id='ht' span), and mygames.xml is your actual library.
InGame keys off three markers — the pid= link, the title-ID link and the
play clock — so a Lite build, which has no pid link, still reads in-game, and
cpursx_xmb.html pins the negative case.