plugin_runner — stock-plugin shim for MQTT reverse engineering
plugin_runner is a tiny standalone CLI that pretends to be Bambu Studio
just enough to load any libbambu_networking.so (a specific upstream
release), drive its print entry points with a user-supplied
BBL::PrintParams, and let the real MQTT publish hit your printer. It
exists so we can diff the wire output of the stock plugin against
our own open-bamboo-networking build without firing up the whole IDE.
The wrapper script (tools/plugin_runner.sh) is
the only thing humans should call. It picks the right ABI, builds the
C++ bridge against it, downloads the matching plugin from Bambu's CDN
(or reuses a cached copy), and exec's the binary with all your other
flags forwarded.
1. Install dependencies
Debian/Ubuntu:
sudo apt install build-essential cmake ninja-build pkg-config \
libcurl4-openssl-dev libminizip-dev libssl-dev \
nlohmann-json3-dev python3
python3 is only used by the wrapper to parse the downloader's JSON
output (one tiny python3 -c); the C++ binary itself has no Python
dependency.
2. Layout
| File | Role |
|---|---|
../plugin_runner.sh |
Bash wrapper, the only user-facing entry point |
CMakeLists.txt |
Standalone CMake project (independent of the top-level build) |
main.cpp |
CLI parsing, init order, callback wiring, action dispatch, completion wait |
plugin_loader.{hpp,cpp} |
dlopen + dlsym for the ~30 entry points we use |
print_params_io.{hpp,cpp} |
JSON ↔ BBL::PrintParams (the --params-json schema) |
plugin_downloader.{hpp,cpp} |
libcurl + minizip against Bambu's /slicer/resource API |
Build artefacts land in tools/plugin_runner/build-0xMMmmpp/ (one dir
per ABI). Plugin downloads are cached in
~/.cache/obn-plugin-runner/<full-version>/libbambu_networking.so
(override with --cache-dir).
3. CLI reference
Wrapper-only flags
--abi MM.mm.pp target ABI version (required)
--plugin-path PATH use this .so, skip download
--use-backup-fallback also try ~/.config/BambuStudio/plugins/backup/
--force-download ignore cache, refetch from CDN
--cache-dir DIR override ~/.cache/obn-plugin-runner
Forwarded to the C++ binary
--params-json FILE BBL::PrintParams JSON (see §4 for the schema)
required for the print actions; ignored for
send_raw / none / http_probe / mw_probe
--action ACTION send_gcode_to_sdcard | local_print
| sdcard_print | local_print_with_record
| cloud_print | send_raw | none | http_probe
| mw_probe | filament_probe
--gcode-3mf PATH path to the *.gcode.3mf file Studio would have
generated; used as `filename` and `ftp_file`
fallbacks if your params JSON omits them
--dev-id ID printer serial (required except http_probe|mw_probe)
--dev-ip IP printer LAN IP (required except http_probe|mw_probe)
--access-code CODE printer LAN access code (required except http_probe|mw_probe)
--country US countries influence routing in some plugins
--use-ssl-mqtt 0|1 default 1; matches Studio's LAN flow
--cert-file PATH override slicer_base64.cer location (best-effort)
--timeout SECONDS max wait for PrintingStageFinished/ERROR (default 90)
--connect-settle-ms MS *cap* on the wait for set_on_local_connect_fn
(status=ConnectStatusOk) before the publish
channel is considered ready. Default 800; bump
to 15000+ on slow handshakes (see §9).
--client-name NAME X-BBL-Client-Name. Default OpenBambooNetworking.
POST /my/task answers 403 for anything other
than `BambuStudio`, so pass that when you need a
faithful stock cloud-print capture.
--client-version VER X-BBL-Client-Version. Default 02.05.03.99.
--cert-lan-only 0|1 lan_only argument for install_device_cert during
bring-up; Studio passes is_lan_mode_printer().
Default 1. A cloud-paired printer needs 0: only
that branch publishes app_cert_install and gets
printer_cert back, and without it every print:*
publish is rejected with -4030 (see §9).
--log-out PATH also mirror every JSON event line to this file
--keep-tmpdir leave the per-run /tmp/obn-plugin-runner-* alone
--fast-exit flush logs then `_Exit(0)` instead of letting
destroy_agent drain its worker pool. Default ON
for `--action none` / `http_probe` / `mw_probe`,
OFF otherwise.
Stock plugins keep boost::asio / mqtt-cpp threads
alive past destroy_agent so a graceful shutdown
blocks ~60s; fast exit lets the kernel reap the
mapping.
--no-fast-exit opposite, useful if you want destroy_agent to
flush a final status/event after the action.
--user-info JSON|@FILE change_user payload. Studio
`{"data":{"token":…,"user":{…}}}` envelope, or
OBN `obn.auth.json` (auto-converted). Required
for `http_probe` / `mw_probe` / `filament_probe`.
# --action send_raw flags
--raw-json FILE JSON payload to publish verbatim (required)
--raw-qos N MQTT QoS (default 1)
--raw-flag N plugin-specific publish flag (default 0)
--raw-settle SECONDS sleep after the last publish to let
local_message replies land (default 5)
--raw-repeat N publish the payload N times (default 1)
--raw-repeat-interval-s S gap between repeats (default 5)
# --action http_probe / mw_probe flags (no printer; cloud HTTP only)
--task-id ID get_task_plate_index / get_subtask (default 1114566547)
--project-id ID for get_slice_info
--profile-id ID for get_slice_info (default 894049654)
--plate-index N for get_slice_info (default 1)
--msg-type / --msg-after / --msg-limit
args for get_my_message (defaults 0 / 0 / 20)
--instance-id N get_model_mall_rating (default 390100)
--design-id ID get_model_mall_detail_url (default 478834)
--mw-seed / --mw-limit get_mw_user_4ulist (defaults 0 / 10)
--mw-put-rating also call put_model_mall_rating (off by default)
--rating-id / --rating-score for optional put
# --action filament_probe flags (no printer; cloud HTTP only)
--slot-dev-id ID devId for sync_slot_mappings (default --dev-id)
--slot-ams-sn SN amsSn of the tray
--slot-id N slotId — a decimal string ("0".."3")
--slot-spool-id N spoolId — cloud spool id; 0 means unbind
--slot-rfid HEX rfid — empty on unbind
--slot-ams-id N amsId — AMS unit index
--slot-ams-type N amsType — DevAmsType (0 EXT, 1 AMS, 2 Lite,
3 N3F, 4 N3S, 5 Lite-mixed)
--slot-mappings-json JSON|@F full mappings array (or {"devId":…,
"mappings":[…]}), overrides the flags above;
the only way to send a batch or an empty array
--fil-sync-only skip get_filament_spools, only run the syncs
--soft-match-dev-id ID devId for get_soft_match_pending (falls back
to --slot-dev-id, then --dev-id)
--soft-match-ams-sn SN amsSn for the same (falls back to --slot-ams-sn)
--soft-match-actions LIST comma-separated post_soft_match_pending calls:
accept | link_other | create_new. Empty by
default — each one writes to the catalogue
--soft-match-spool-id N spoolId carried by those POSTs
--soft-match-target-spool-id N targetSpoolId (only link_other uses it)
--soft-match-only skip the catalogue read and the slot-mapping
sync, leaving just the soft-match calls
--device-region alias for --action device_region
--device-region-device-id ID DeviceId (stock does not put this on the wire)
--device-region-client-type T ClientType; omitted means "slicer"
--device-region-country CC country field (stock does not put this on the wire)
--device-region-x-client-country CC
XClientCountry (same)
http_probe calls get_studio_info_url, get_my_message, check_user_task_report,
get_task_plate_index, and get_slice_info after change_user.
mw_probe calls get_subtask, get_model_mall_detail_url, get_model_mall_rating,
get_mw_user_preference, get_mw_user_4ulist (and optionally put_model_mall_rating).
filament_probe calls get_filament_spools and sync_slot_mappings (the latter
needs --abi 02.08.02 or newer). Studio drives sync_slot_mappings from the
Filament Manager AMS sync in two flavours: a bind carries spoolId + rfid,
an unbind zeroes both and keeps the pre-eject mount fields. Both hit
POST /my/filament/v2/slot-mappings/sync (research §8.15).
With --abi 02.08.03 or newer it also calls get_soft_match_pending, the
read-only AMS auto-match queue, and — for every name in
--soft-match-actions — post_soft_match_pending. The POST is a catalogue
write, so point --soft-match-spool-id at a row that does not exist to
capture the request shape without touching the account; the server answers
404 and the plugin returns −35
(research §8.15.10).
device_region (ABI 02.08.04 or newer) calls post_device_region once and
prints the return code plus the raw body. --user-info is optional.
--device-region is an alias for the action. ClientType defaults to
slicer; pass an empty string to send the field blank. The other three
fields default to empty. --country still selects the API host via
set_country_code (CN → api.bambulab.cn)
(research §8.10.12).
camera_url makes one get_camera_url call with Studio's packed key
<dev-id>|<--camera-dev-ver>|<--camera-protocols> (default protocols
"tutk","agora", as MediaPlayCtrl::Play sends them). With
--camera-cloud 1 (default) it first runs connect_server +
add_subscribe(dev-id), so the call happens with the printer online over
the cloud, the way Studio makes it. The event log shows the return and
callback latency, whether the callback fired before the call returned, the
query keys, and a masked URL. Cloud push_status frames are reduced to
tutk_server transitions; every other cloud frame is logged verbatim, so a
follow-up command the plugin publishes on its own shows up through its
reply. --camera-url-out FILE writes the raw URL (mode 0600) for
tools/tutk_probe, which opens it through a
libBambuSource.so and counts the video samples:
./tools/plugin_runner.sh --abi 02.08.01 \
--plugin-path ~/.config/BambuStudio/plugins/libbambu_networking.so \
--action camera_url --client-name BambuStudio \
--user-info @$HOME/.config/BambuStudio/obn.auth.json \
--data-dir $HOME/.config/BambuStudio \
--dev-id <serial> --camera-dev-ver <fw> --camera-url-out /tmp/cam.url
cmake -S tools/tutk_probe -B tools/tutk_probe/build && cmake --build tools/tutk_probe/build
tools/tutk_probe/build/tutk_probe --url-file /tmp/cam.url --device <serial> --seconds 10
The credentials in the URL are stable, but the printer only accepts TUTK
connections for ~9.5 minutes after a mint: each /user/ttcode POST makes the
cloud push liveview.prepare to the printer, which starts its tutk_server.
Re-run camera_url if tutk_probe fails with -90 (device offline).
With --ctrl JSON (repeatable) tutk_probe opens the file-browser channel
instead of video and prints each reply; --dump FILE then stores every reply
as a LE32 length followed by the payload.
ft_job opens one ft_* tunnel on the URL in --ft-url-file (a LAN
bambu:///local/<ip>?port=6000&user=bblp&passwd=<code> or a TUTK URL from
camera_url with Studio's &device=…&cli_id=… suffix) and runs each
--ft-job params JSON on it in order, the way Send to Printer runs ability
then upload. It logs the tunnel status, sync_connect time, every
ft_job_msg and ft_job_result, and writes binary results to
<--ft-out>.<index>. The stock ft_* symbols crash without an agent, which
is why this runs inside plugin_runner rather than standalone:
./tools/plugin_runner.sh --abi 02.08.04 \
--plugin-path ~/.config/BambuStudio/plugins/libbambu_networking.so \
--action ft_job --data-dir $HOME/.config/BambuStudio \
--ft-url-file /tmp/ft.url --ft-out /tmp/ft_out \
--ft-job '{"cmd_type":7}' \
--ft-job '{"cmd_type":4,"path":"mem:/26","is_mem_file":true,"target_path":""}'
update_cert calls bambu_network_update_cert (Studio check_cert) — no printer,
--user-info optional. Under MITM this is the shared app-cert fetch
(research §10.2): agents ≤ 02.08.03 use
GET …/applications/{enc_secret}/cert?aes256=…&ver=1 (response includes key);
agents ≥ 02.08.04 use
GET …/applications/GLOF3813734089-c0cf542a0000/status (cert + CRL, no key).
The stock agent ignores HTTPS_PROXY.
Capture stock HTTPS under mitmproxy:
HTTPS_PROXY=http://127.0.0.1:8888 HTTP_PROXY=http://127.0.0.1:8888 \
CURL_CA_BUNDLE=$HOME/.mitmproxy/mitmproxy-ca-cert.pem \
SSL_CERT_FILE=$HOME/.mitmproxy/mitmproxy-ca-cert.pem \
./tools/plugin_runner.sh --abi 02.08.01 \
--plugin-path ~/.config/BambuStudio/plugins/libbambu_networking.so \
--action mw_probe \
--user-info @$HOME/.config/BambuStudio/obn.auth.json \
--data-dir $HOME/.config/BambuStudio \
--task-id 1114566547 --instance-id 390100
If the stock plugin ignores HTTPS_PROXY, use SSLKEYLOGFILE + Wireshark
or Frida SSL hooks (tools/frida_ft_*).
plugin_runner --help prints the same reference at any time.
4. PrintParams JSON
The flat JSON object is fed straight into BBL::PrintParams. The full
list of accepted keys (and their types) is the source of truth:
tools/plugin_runner/print_params_io.cpp —
load_print_params_overrides() walks PrintParams field-by-field with
typed setters. Missing keys keep BBL::PrintParams{} defaults; unknown
or wrong-typed keys log a params_warning event and are ignored.
A ready-to-edit reference covering every field this loader knows
about — including ams_mapping* JSON-array-as-string conventions,
default values from a real Studio session, and the task_* toggles
that map straight into the MQTT project_file payload — lives at
tools/plugin_runner/example.params.json. Copy
it, point filename at your local 3mf, drop fields you don't want to
override, and pass the result via --params-json.
Minimal experiment that toggles just task_timelapse_use_internal:
{
"filename": "/path/to/your-job.gcode.3mf",
"task_name": "obn-runner-test",
"task_record_timelapse": true,
"task_timelapse_use_internal": true,
"task_bed_type": "auto"
}
dev_id, dev_ip, username (bblp), password (your
--access-code) and use_ssl_for_mqtt only fall back to their CLI
values when the JSON leaves them blank — encode them only if you want
to override the wrapper.
Version-gated keys are only accepted when the bridge is built for a new
enough --abi; passing one to an older ABI logs a params_warning and
skips it: extruder_cali_manual_mode (02.04.00),
task_timelapse_use_internal (02.05.03), svc_context (02.07.01),
slicer_uid (02.08.01), queue_plate_id (02.08.02).
5. Worked examples
The examples below use the placeholders
<DEV_ID>(15-character serial from the printer's Settings → About screen),<PRINTER_IP>(LAN IPv4 of the printer), and<ACCESS_CODE>(the 8-digit code under Settings → Network → LAN-only mode). Substitute your own values before running anything.
Auto-download for ABI 02.05.03
tools/plugin_runner.sh \
--abi 02.05.03 \
--params-json experiment.json \
--action send_gcode_to_sdcard \
--gcode-3mf /tmp/job.gcode.3mf \
--dev-id <DEV_ID> \
--dev-ip <PRINTER_IP> \
--access-code <ACCESS_CODE> \
--log-out /tmp/runner.jsonl
First call configures + builds tools/plugin_runner/build-0x020503/
(~10 s on a cold machine), fetches
https://public-cdn.bblmw.com/.../linux_02.05.03.63.zip, and caches
the extracted .so under ~/.cache/obn-plugin-runner/02.05.03.63/.
Subsequent runs against the same ABI start in well under a second.
Custom .so (e.g. an old build you have backed up)
tools/plugin_runner.sh \
--abi 02.05.02 \
--plugin-path ~/.config/BambuStudio/plugins/backup/libbambu_networking.so \
--params-json experiment.json \
--action local_print \
--gcode-3mf /tmp/job.gcode.3mf \
--dev-id <DEV_ID> --dev-ip <PRINTER_IP> --access-code <ACCESS_CODE>
--plugin-path skips the CDN; the wrapper still rebuilds the bridge
against --abi 02.05.02 so the PrintParams layout matches the .so.
6. Diff workflow with an MQTT sniffer
The point of this tool is wire-level diffs, not "does it print". The
recommended pairing is one terminal sniffing MQTT against the printer
while another runs plugin_runner.sh:
Terminal A — subscribe to the printer's MQTT broker. Any MQTT client
with TLS support works; just point it at mqtts://<PRINTER_IP>:8883,
authenticate as bblp / <ACCESS_CODE>, and disable certificate
validation (the printer presents a self-signed cert with its serial as
CN, so chain validation will fail in any client that checks). A few
options:
-
GUI: MQTT Explorer — open the app, add a connection with
Protocol: mqtts,Host: <PRINTER_IP>,Port: 8883,Username: bblp,Password: <ACCESS_CODE>, and under Advanced → Certificate validation turn off "Validate certificate". Topic tree and per-topic diff are very handy for spotting which field actually changed between two runs. -
CLI (
mosquitto_subdoesn't talk to a self-signed broker out of the box, so bridge the TLS withsocatfirst):socat -d TCP-LISTEN:18883,bind=127.0.0.1,reuseaddr,fork \ OPENSSL:<PRINTER_IP>:8883,verify=0,commonname=ignore & mosquitto_sub -h 127.0.0.1 -p 18883 \ -u bblp -P <ACCESS_CODE> -t '#' -v -F '%I %t %p' \ | tee /tmp/mqtt-stock.logBest for scripted diffs since the output is plain text.
Terminal B — run the stock plugin once with your override:
tools/plugin_runner.sh --abi 02.05.03 \
--params-json experiment.json \
--action send_gcode_to_sdcard --gcode-3mf job.gcode.3mf \
--dev-id <DEV_ID> --dev-ip <PRINTER_IP> --access-code <ACCESS_CODE>
Then re-run the same job using our build in place of the stock plugin
and diff the captures. Anything that differs in the project_file
publish is something the override / experiment.json actually
changed; anything else is a wire bug in our plugin to investigate.
7. Operational notes
plugin_runnerwrites one JSON line per plugin event (local_message,update_status,local_connect, …) to stdout; copy with--log-out.- The bridge spawns no threads itself — every callback invocation
happens on whichever worker thread the plugin called us back on.
set_queue_on_main_fnis wired to inline-execute, which is fine for this LAN-only scope but would deadlock under a real UI loop. - Cloud printing (
bambu_network_start_print) is--action cloud_print. It needs--user-infofor the bambu-cloud auth dance, and still needs--dev-id/--dev-ip/--access-code/--cert-file: the stock plugin gates its send stage on the device certificate and answers-3140ENC_FLAG_NOT_READYwithout a live LAN session. Unlike the LAN actions it uploads to S3 and dispatches viaPOST /my/taskwithmode=cloud_file, so the printer starts downloading on its own — pair it with--auto-stop. - A
--timeoutexceeded run returns exit code75(EX_TEMPFAIL); a finished-but-failed action returns1; a clean success returns0.
7. Connect timing (--connect-settle-ms) and -4030
connect_printer is asynchronous: it returns rc=0 immediately
and the actual TLS handshake + first MQTT subscribe happens on a
worker thread several seconds later (typically 5–6 s on a quiet
network). The set_on_local_connect_fn callback firing with
status=ConnectStatusOk is the only reliable signal that the LAN
publish channel is ready. Publishing earlier deterministically returns
-4 (paho's MQTTASYNC_DISCONNECTED); the print actions surface that
as update_status code=-4030 msg="send msg failed" at the Sending
stage.
plugin_runner waits for that callback automatically and only then
proceeds. --connect-settle-ms is a cap on that wait, not a fixed
sleep — bump it (e.g. --connect-settle-ms 20000) only if your
printer is slow to handshake, otherwise the default 800 ms is fine
once the wait turns into an event-driven block. The matching log
event is session_ready {got: true|false, cap_ms: N} followed by a
kickstart_pushall (verbatim copy of Studio's
MachineObject::command_request_push_all payload, used both to mimic
Studio's keep_alive loop and as a smoke test for the publish
channel).
If your action still fails with -4030 after kickstart_pushall rc=0, the publish channel was healthy at action time — the failure
is happening inside the plugin's print state machine, not in the
MQTT layer. Use --action send_raw (§10) to bisect which payload the
printer actually rejects.
8. Raw publish (--action send_raw)
Direct call into bambu_network_send_message_to_printer with a
verbatim JSON file. Useful for replicating Studio's exact pre-print
command sequence, probing what the printer accepts, or stress-testing
the LAN MQTT session.
# pushall-as-a-keepalive: mimics Studio's MachineObject::command_request_push_all
echo '{"pushing":{"sequence_id":"0","command":"pushall","version":1,"push_target":1}}' \
> /tmp/pushall.json
tools/plugin_runner.sh --abi 02.05.03 \
--action send_raw --raw-json /tmp/pushall.json \
--raw-repeat 6 --raw-repeat-interval-s 5 --raw-settle 2 \
--dev-id <DEV_ID> --dev-ip <PRINTER_IP> --access-code <ACCESS_CODE> \
--connect-settle-ms 15000 --fast-exit \
--log-out /tmp/runner-raw.jsonl
Watch the send_raw_rc events: rc=0 means the publish hit the wire
(the printer's reply, if any, lands as a local_message); rc=-4
means the LAN MQTT session is not actually up yet — increase
--connect-settle-ms or check certs. --raw-repeat plus a few-second
interval is the easiest way to confirm the session stays alive over
the lifetime of an action (e.g. across an FTPS upload).
9. Diagnostics: --action none
Use this to sanity-check the init/connect path without actually printing:
tools/plugin_runner.sh --abi 02.05.03 \
--action none --timeout 6 \
--dev-id <DEV_ID> --dev-ip <PRINTER_IP> --access-code <ACCESS_CODE> \
--log-out /tmp/runner-diag.jsonl
A healthy run looks like (event kinds in order):
startup → plugin_loaded → data_dir → cert_resolved → extra_http_header → agent_start (rc=0) → post_start_init → change_user_clear (rc=0) → ssdp_msg → bind_detect → connect_printer_call (rc=0) → local_connect (status=0) → session_ready (got=true) → start_subscribe → kickstart_pushall (rc=0) → idle_done → shutdown.
Things to watch for:
local_connect status=1means the plugin's MQTT client failed to connect (printer rejected, TLS error, busy session). The most common cause is a missingslicer_base64.cer— the runner searches$HOME/.config/BambuStudio/cert/,$HOME/BambuStudio/resources/cert/and/usr/share/BambuStudio/resources/cert/; pass--cert-file PATHif yours lives elsewhere.session_ready got=falsemeans the cap fired before thelocal_connectcallback. Bump--connect-settle-ms.kickstart_pushall rc!=0aftersession_ready got=trueis pathological — the publish channel reports ready but rejects the first message. Re-check the cert and that no other LAN client is holding the access code session.agent_starttaking ~10 s on a cold cache is normal: the plugin initialises bambu-cloud TLS material even on the LAN-only path. Subsequent runs in the same process group are a few hundred ms.- A missing
local_connectevent entirely afterconnect_printer_callgenerally means the plugin was loaded but never spun up its worker — most often becauseset_on_local_connect_fnwas wired against the wrong typedef. Cross-check the typedefs inplugin_loader.hppagainst thetools/abi_snapshot/v<MM.mm.pp>/NetworkAgent.hppsnapshot for the ABI you're targeting.