Dual-source reverse-engineering:
- gklib β Anycubic Kobra S1 "Klipper-Go" firmware, version 2.7.0.9.
ARM 32-bit ELF binary, imagebase0x10000. Analysis via IDA Pro decompilation. - ACE2 MCU firmware β
ACE2_V1.1.31_20260306.bin, STM32F1 Cortex-M3.
Loaded at0x08008000. Analysis via IDA Pro disassembly/decompilation.
The Kobra S1 runs a custom Go port of Klipper (gklib). It communicates with the ACE 2 Pro multi-material
unit over USB-UART (CH343 chip) at 230400 baud using a custom framed Protocol Buffers protocol.
Frame format:
FF AA [flags] [seq_lo seq_hi] [cmd] [payload_len] [protobuf payload] [crc16_lo crc16_hi] FE
CRC is CRC16-Kermit (polynomial 0x8408, init 0xFFFF) computed over flags through end of payload.
Key Go structs in the firmware:
FilamentHubβ orchestrates all multi-material operationsACEProxyV2β represents a single connected ACE 2 deviceV2Managerβ manages multiple ACE 2 devices (daisy-chain)FilamentStuckCheckerβ monitors encoder feedback to detect clogs
Error handling is driven by FilamentHub.update_status (0x644768), which polls slot states and
transitions the state machine.
if slot.Status == "assist_error"
AND feed_assist is currently active
AND ACE workstate == BUSY:
log "ace %d filament %d hardware error detected during feed assist"
pause_on(ASSIST_ERROR)
return
The printer pauses immediately. There is no automatic retry. The operator must physically resolve the hardware issue and resume the print.
| Condition | Behavior |
|---|---|
FilamentStuckChecker.checkClog() detects excess feed_assist_count |
Log "filament stuck, go check ace %d filament %d feed_assist_count %d", pause_on(stuck_error) |
ContAssistTime > 8.0 s (ACE 2.0 firmware) |
Multi-stage tangle detection: increments filament_tangle_stage (0 β 1 β 2), pauses on stage 2 |
ContAssistTime > 8.0 s (ACE 1.0 firmware) |
Direct pause_on (no staging) |
status == "home_err" or "gear_err" |
ace_err = status_string, pause_on(ace_error) |
ROLLBACK_ERROR (0x82), PRELOAD_ERROR (0x84) |
Handled by the same state machine; result in print pause |
STUCK_ERROR (0x85) |
Caught by stuck checker before ACE reports it |
TANGLED_ERROR (0x86) |
Caught by tangle timer logic above |
MOTOR_ERROR (0x87) |
Falls through to home_err/gear_err path |
All error paths terminate in a print pause β there is no silent recovery.
- Printer pauses; user resolves the physical issue
- User resumes print (GCode
M24or UI button) update_statusobserves slot state returned toIDLE/READYand clears the internal error flag- Feed sequence restarts from the current position
To trigger programmatic recovery without a full resume: send STOP_FEED_OR_ROLLBACK (cmd 9) to
cancel current motion, then issue a new FEED_OR_ROLLBACK (cmd 8). The ACE state machine resets
to IDLE on a successful stop.
The ACE 2 supports in-application firmware updates over the same UART protocol. The GCode entry
point is OTA_FILAMENT_START. The update is a 3-step IAP sequence.
Implemented in ACEProxyV2.OtaStart (0x62f8d0).
Command: 2 (IAP_UPGRADE)
Timeout: 2 s
Payload: UpgradeRequest {
size: uint32 // total firmware image size in bytes
crc: uint32 // CRC of full firmware image
version: string // target firmware version string
}
Log: "ACEProxyV2 device %d OtaStart: size=%d, crc=%d, version=%s"
Implemented in ACEProxyV2.OtaSendChunk (0x62fe00). Called repeatedly until all chunks are sent.
Command: 3 (IAP_FIRMWARE)
Timeout: 2 s per chunk
Payload: FirmwareRequest {
address: uint32 // byte offset of this chunk in the image
firmware: bytes // chunk data
}
Log on failure: "ACEProxyV2 device %d OtaSendChunk failed (addr=0x%08X, size=%d): %v"
Implemented in ACEProxyV2.OtaFinish (0x6301cc).
Command: 4 (IAP_UPGRADE_FINISH)
Timeout: 5 s
Payload: GenericRequest {} // empty protobuf β zero payload bytes
Log: "ACEProxyV2 device %d OtaFinish"
After OtaFinish the ACE reboots. The printer polls GET_INFO (cmd 7) and IAP_VERSION (cmd 5)
to confirm the new version is running.
Note: The
crcfield inUpgradeRequestis a CRC of the complete firmware image. It is separate from the per-packet CRC16-Kermit framing used on every UART frame.
SWITCH_FILAMENT is a GCode command implemented in the firmware.
SWITCH_FILAMENT ACE=<ace_id> INDEX=<filament_index>Handler: FilamentHub.Cmd_SWITCH_FILAMENT (0x65481c). Reads ACE and INDEX parameters,
calls switch_filament(ace_id, filament_id), logs "switch finish" on completion.
The standard T<n> toolchange GCode also routes through this path via the gcode-to-filament
mapping table.
1. Set target_ace_id, target_filament_id
2. If currently cleaning or refilling:
stop current operation + run Cmd_CLEAN_PIPE
3. If already loaded with the requested filament AND filament_present sensor is true:
invoke completion callback, return (no-op β already done)
4. If filament_tracker_enable AND filament is currently present:
Cmd_UNWIND_ALL_FILAMENT (retract the current filament fully)
5. If !bootup_calibrated AND filament_calibrate==1 AND gcodeMapping > 1:
filament_recalibrate()
bootup_calibrated = true
6. If recalibrate_period has elapsed since last calibration:
filament_recalibrate() for the current slot
7. set_current_filament(-1, -1) // mark: no filament active
8. feed_filament(ace_id, filament_id) // drive new filament forward
9. On success: update multi_material_temp if a per-material temperature is configured
Feed check is a motion-monitoring safety feature. The ACE measures actual filament movement via its optical encoder and raises an error if the movement deviates beyond set thresholds.
Implemented in ACEProxyV2.SetFilamentFeedCheckParam (0x62f108).
Command: 19 (SET_FEED_CHECK)
Timeout: 200 ms
Payload: SetFeedCheckRequest {
check_length: uint32 // minimum encoder reading required to pass the check (mm)
error_length: uint32 // commanded feed distance at which the check is evaluated (mm)
}
Log: "ACEProxyV2 device %d set feed check params: check_length=%d, error_length=%d"
Default values (gklib initializeDevice at 0x6292B0):
check_length = 100 // sent as 0x64
error_length = 90 // sent as 0x5A
Both values are stored inside the ACE MCU as single bytes at byte_20000096 (check_length)
and byte_20000097 (error_length). Valid range is 1β255 per field.
How the check works:
The feed check logic runs entirely inside the ACE 2's own MCU firmware β gklib only sends
these two parameters and then monitors the slot state for FEED_ERROR. The evaluation is
performed in sub_8009D74:
- After the ACE has been commanded to feed
error_lengthunits, it samples the encoder count. - A fixed scale factor of 1.2342 (double constant at
0x8009EAC) converts the commanded distance into the expected encoder reading:expected = error_length Γ 1.2342 - If
encoder_count < check_length, the ACE transitions toFEED_ERROR (0x81).
With the gklib defaults (error_length=90, check_length=100): expected β 111 encoder units; the slip window before an error is raised is β 11 encoder units (~8.9 mm equivalent).
Tuning guidance β minimising assist errors from encoder deficit:
During buffer-assist cycles the buffer spring back-pressure can cause the encoder to under-read
relative to the commanded distance, producing spurious FEED_ERROR / ASSIST_ERROR transitions.
To widen the tolerance without masking real jams:
- Decrease
check_length(lower the minimum encoder threshold) β directly increases the acceptable slip window with no change to when the check fires. - Do not increase
error_lengthbeyond the actual assist stroke β the check would then fire after the assist has already completed, making it useless. - Example:
check_length=80, error_length=90raises the tolerance from ~8.9 mm to ~24 mm while keeping the evaluation point the same.
The field name check_length is the pass threshold (minimum acceptable encoder reading),
not the distance at which the check fires. error_length is the commanded distance that
triggers the evaluation.
Accepts a POST with ace_id (float), check_len, and error_len. Looks up the ACE proxy by ID
and calls SetFilamentFeedCheckParam.
Returns per-slot feed statistics:
FeedInfoResponse {
feed_info: repeated FeedInfo {
steps: int32 // motor steps commanded
length: int32 // length commanded (mm)
decoder: int32 // length measured by encoder (mm)
}
}
get_and_record_feedinfo (0x62a998) reads this data into a circular diagnostic log formatted as
[idx] %08d,%08d,%08d (steps, length, decoder). gklib does not evaluate the thresholds itself;
it only logs the raw values for diagnostics. The FEED_ERROR transition is triggered by the ACE 2
MCU when decoder < check_length at the error_length checkpoint.
There are two separate and independent calibration mechanisms.
This calibrates how far the klipper-side firmware unwinds filament for each slot. It runs
automatically inside switch_filament at first boot (step 5) and periodically thereafter (step 6).
Algorithm β for each non-empty slot:
1. move_to_throw_position() // move printhead to safe purge/discard position
2. get_nonempty_filaments() // enumerate all loaded slot indices
3. For each slot (skipping any excluded slots):
a. Start feed assist mode on this slot
b. Wait up to 100 s (polling every 1 s) for filament_present sensor to confirm
c. Issue unwind command:
length = safe_unwind_len + random(0, 50) mm
speed = getUnwindFilamentSpeed()
d. wait_ace_action_with_filament(timeout = 9 s)
e. If ACE workstate == BUSY: read encoder decoder feedback in a loop:
if decoder < safe_unwind_len * 8 / 4:
retry with length = 2 * safe_unwind_len
log "filament_recalibrate ace %d filament %d unwind success, unwind_length=%d"
The randomised unwind length + random(0, 50) mm prevents systematic under/overshoot
accumulating across repeated calibration runs.
This calibrates the ACE's internal optical slot sensors (filament insert, empty, and buffer sensors for each channel). It is a manufacturing and maintenance command.
Command: 15 (LINEAR_KEY_CALIBRATE)
Payload: LinearCalibrationRequest {
id: KeyIndex // which sensor to calibrate (see table below)
type: uint32 // calibration type parameter
}
KeyIndex sensor map:
| Value | Sensor |
|---|---|
| 0 | CH1_INSERT β channel 1 filament inserted |
| 1 | CH1_EMPTY β channel 1 filament empty |
| 2 | CH1_BUF_RST β channel 1 buffer reset |
| 3 | CH1_BUF_BACK β channel 1 buffer back |
| 4β7 | CH2 sensors (same pattern) |
| 8β11 | CH3 sensors |
| 12β15 | CH4 sensors |
| 16 | CHN_BUF_FEED β shared buffer feed sensor |
Normal print operation does not require triggering this command. It is only needed after replacing ACE sensor hardware or if sensors drift out of calibration over time.
Derived from both gklib (host-side, sections above) and ACE2 MCU firmware static analysis (see ACE2 MCU Firmware Analysis section below for handler addresses).
| ID | Hex | Name | Direction | Purpose |
|---|---|---|---|---|
| 0 | 0x00 | DISCOVER_DEVICE | host β ACE | Find ACE on bus; ACE replies with 96-bit STM32 UID |
| 1 | 0x01 | ASSIGN_DEVICE_ID | host β ACE | UID verification + seq counter assignment |
| 2 | 0x02 | IAP_UPGRADE | host β ACE | OTA start (size + crc32 + version string) |
| 3 | 0x03 | IAP_FIRMWARE | host β ACE | OTA data chunk (address + bytes) |
| 4 | 0x04 | IAP_UPGRADE_FINISH | host β ACE | OTA complete; ACE reboots |
| 5 | 0x05 | IAP_VERSION | host β ACE | Query boot/app version strings |
| 6 | 0x06 | GET_STATUS | host β ACE | Full status: slots, temps, motors, odometer |
| 7 | 0x07 | GET_INFO | host β ACE | Firmware version + device info (sets init flag) |
| 8 | 0x08 | FEED_OR_ROLLBACK | host β ACE | Start feed or retract on a slot |
| 9 | 0x09 | STOP_FEED_OR_ROLLBACK | host β ACE | Cancel active feed/retract |
| 10 | 0x0A | UPDATE_SPEED | host β ACE | Change feed speed on active operation |
| 11 | 0x0B | DRYING | host β ACE | Start drying cycle (temp + duration + fan) |
| 12 | 0x0C | SET_DRY_TEMP | host β ACE | Set PID target (temp+7Β°C offset) + auto-adjust setpoint. Does NOT stop drying β send DRYING(temp=0) to stop |
| 13 | 0x0D | GET_RFID_CACHE | host β ACE | Read last-cached RFID data for a slot |
| 14 | 0x0E | SET_RFID_ENABLE | host β ACE | Enable/disable RFID reading per slot |
| 15 | 0x0F | LINEAR_KEY_CALIBRATE | host β ACE | Set optical sensor ADC threshold |
| 16 | 0x10 | GET_MATERIAL_INFO | host β ACE | Read stored material name + status for a slot |
| 17 | 0x11 | SET_SLOT_STATUS | host β ACE | Write slot status byte |
| 18 | 0x12 | SET_MATERIAL_NAME | host β ACE | Write 21-byte material name to a slot |
| 19 | 0x13 | SET_FEED_CHECK | host β ACE | Set encoder feed-check thresholds |
| 20 | 0x14 | SET_PRINTER_STATUS | host β ACE | Notify ACE of current printer state |
| 64 | 0x40 | GET_TEMP | host β ACE | Detailed temperature + dry state readings |
| 65 | 0x41 | SET_DRY_POWER | host β ACE | Set heater power level |
| 66 | 0x42 | SET_VALVE | host β ACE | Control valves / binary actuators |
| 68 | 0x44 | GET_FILAMENT_INFO | host β ACE | Read live RFID tag (full decode). gklib uses CMD 13 instead; CMD 68 is test/debug only |
| 70 | 0x46 | FLASH_LED | host β ACE | LED animation pattern control |
| 71 | 0x47 | SET_FAN | host β ACE | Fan speed (0β100%) |
| 72 | 0x48 | MOTOR_MOVE | host β ACE | Direct motor step command |
| 73 | 0x49 | GET_SENSOR_STATE | host β ACE | 17-channel filament sensor bitmask |
| 75 | 0x4B | DRY_CMD | host β ACE | Dry subtask command |
| 76 | 0x4C | GET_FEED_INFO | host β ACE | Per-slot encoder/step feed diagnostics |
| 77 | 0x4D | MOTOR_TEST | host β ACE | Motor self-test command |
| 78 | 0x4E | GET_MOTOR_STATUS | host β ACE | Motor/drive status query |
| Value | Name | Meaning |
|---|---|---|
| 0x00 | READY | Idle, ready for commands |
| 0x01 | FEEDING | Actively feeding filament forward |
| 0x02 | ROLLBACK | Actively retracting filament |
| 0x03 | ASSISTING | Providing buffer assist |
| 0x04 | ROLLBACK_ASSISTING | Retracting while assisting |
| 0x05 | PRELOADING | Pre-loading filament to buffer |
| 0x06 | UPGRADING | Firmware update in progress |
| 0x81 | FEED_ERROR | Feed motion: encoder below check_length threshold |
| 0x82 | ROLLBACK_ERROR | Retract motion error |
| 0x83 | ASSIST_ERROR | Hardware error during buffer assist |
| 0x84 | PRELOAD_ERROR | Preload motion error |
| 0x85 | STUCK_ERROR | Filament jam detected |
| 0x86 | TANGLED_ERROR | Filament tangle detected |
| 0x87 | MOTOR_ERROR | Motor driver fault |
Static analysis of ACE2_V1.1.31_20260306.bin loaded at base address 0x08008000.
Performed via IDA Pro decompilation and disassembly of the ARM Thumb-2 binary.
| Field | Value |
|---|---|
| Firmware version | V1.1.31 (at 0x08018C20) |
| Build date | 2026-03-06 (from filename) |
| Binary base | 0x08008000 |
| Code range | 0x08008000β0x080197A8 (~71.6 KB) |
| SRAM | 40 KB at 0x20000000β0x20009A80 |
MCU: STM32F1-series (Cortex-M3), confirmed by peripheral base addresses.
| Peripheral | Address | Notes |
|---|---|---|
| UART4 | 0x40004C00 |
Main comm port to host via CH343 USB-UART |
| DMA2 | 0x40020400 |
Ch4=UART4 RX, Ch3=UART4 TX |
| ADC1 | 0x40012400 |
NTC temperature measurement |
| RCC | 0x40021000 |
Clock enable (APB1ENR, APB2ENR, AHBENR) |
| EXTI | 0x40010400 |
PA0 rising edge = encoder pulse interrupt |
| TIM7 | 0x40001400 |
Software PWM for motor (PSC=119, ARRβ20000) |
| IWDG | 0x40003000 |
Independent watchdog |
| STM32 UID | 0x1FFFF7E8β0x1FFFF7F0 |
96-bit factory UID (3Γ32-bit words) |
UART4 configuration:
- Baud rate: 230,400 (
BRR = 0x38400passed to BRR calculator atsub_80139F8) - Format: 8N1 (8-bit word, no parity, 1 stop bit)
- DMA2 channel 4: RX (circular buffer at
unk_20001E78, 0x802 bytes) - DMA2 channel 3: TX
| Task name | Body address | Purpose |
|---|---|---|
Command Processing |
0x8014488 |
UART4 frame parser + command dispatcher |
filament move |
0x8014EAC / 0x800A0B0 |
Motor control and feed/retract sequencing |
dry |
0x800BEC8 |
Heater PID + temperature monitoring |
rfid |
0x800EA14 |
RFID tag read/write (4 slots) |
IAP upgrade |
0x8013FB8 |
Firmware OTA receive + flash write |
misc / motor init |
0x80159B0 area |
Fan, LED, sensors, motor calibration |
OdometerTimer |
β | Encoder odometry tracking |
Confirmed from ACE2 MCU 8-state parser at 0x8014802 (inside Command Processing task):
FF AA [SEQ:1] [TYPE_HI:1] [TYPE_LO:1] [CMD:1] [LEN:1] [PAYLOAD:LEN bytes] [CRC16_LO:1] [CRC16_HI:1] FE
| Field | Size | Notes |
|---|---|---|
FF AA |
2 | Sync preamble (parser pre-state: scans until 0xFF then 0xAA) |
SEQ |
1 | Sequence number, validated against expected counter word_2000111C |
TYPE |
2 | Frame type / flags (big-endian 16-bit, stored at dword_200011EA+2) |
CMD |
1 | Command ID byte (dispatched via linked list) |
LEN |
1 | Payload length in bytes |
PAYLOAD |
LEN | Protobuf-encoded request data |
CRC16 |
2 | CRC-16/Kermit (poly 0x8408, init 0xFFFF) over TYPE..PAYLOAD |
FE |
1 | End-of-frame marker |
CRC function: sub_8010464 β initial value 0xFFFF, polynomial via lookup
i = (8*v5) ^ (v5>>4) ^ ((v5<<8)|(v4>>8)) where v5 = i ^ byte ^ (16*(i^byte)).
Sequence counter: Validated in state 1. After ASSIGN_DEVICE_ID, the host's chosen seq byte
is stored into word_2000111C. Subsequent frames must increment the seq counter.
The firmware uses a three-layer dispatch:
Layer 1 β Frame parser (state machine, dword_200011FC):
Eight states parse the incoming byte stream. On CRC-valid completion (state 8), the CMD byte
is used to look up the handler in the linked list.
Layer 2 β Command linked list (dword_200012D4):
Each registered command creates a 24-byte node via sub_800B7A4(cmd_id, thunk):
node[0] = prev_ptr
node[4] = next_ptr
node[8] = auto-increment ID
node[12] = cmd_id
node[16] = handler_thunk (Thumb function pointer)
node[20] = status byte
The dispatcher walks this list to find a matching cmd_id, then calls the thunk.
Layer 3 β Protobuf dispatch (sub_8009C5C):
Every thunk calls sub_8009C5C(a1, a2, a3, a4, a5, table_entry_ptr) which:
- Decodes the protobuf request payload using the table entry's request descriptor
- Calls the handler function with
(context, decoded_request, response_buffer) - Encodes the protobuf response using the table entry's response descriptor
- Queues the response frame for transmission
Static dispatch table at 0x8018C5C: 26 entries Γ 12 bytes each.
Each entry: [req_descriptor_ptr:4][resp_descriptor_ptr:4][handler_fn:4] (Thumb addresses).
Dynamic dispatch entries (7 entries): For motor/misc commands, the 12-byte entry struct is
built at runtime in SRAM (e.g., dword_20000E70, dword_20000FC8, etc.) with the same layout.
Complete list of all 33 registered commands extracted from ACE2 MCU firmware V1.1.31.
| CMD | Hex | Thunk | Table Entry | Handler | Function |
|---|---|---|---|---|---|
| 0 | 0x00 | sub_800FADE |
entry 8 | sub_800B9CE |
DISCOVER: writes 96-bit UID (0x1FFFF7E8) to response |
| 1 | 0x01 | sub_800FAFA |
entry 9 | sub_800B9E6 |
ASSIGN: verifies 3ΓUID words + sets seq counter |
| 6 | 0x06 | sub_800FB16 |
entry 7 | sub_800B840 |
GET_STATUS: slots, state, temps (Γ3), motors, odometer |
| 7 | 0x07 | sub_800FB32 |
entry 6 | sub_800B7F2 |
GET_INFO: version string + sets byte_20000D98 init flag |
| CMD | Hex | Thunk | Table Entry | Handler | Function |
|---|---|---|---|---|---|
| 2 | 0x02 | sub_800FBDA |
entry 15 | sub_800D4E4 |
IAP_UPGRADE: stores OTA metadata, sets byte_20001554 flag |
| 3 | 0x03 | sub_800FBF6 |
entry 17 | sub_800D564 |
IAP_FIRMWARE: data chunk receive |
| 4 | 0x04 | sub_800FC12 |
entry 18 | sub_800D5C4 |
IAP_UPGRADE_FINISH: verify + reboot |
| 5 | 0x05 | sub_800FC2E |
entry 16 | sub_800D528 |
IAP_VERSION: returns 11-byte version struct from 0x08018000 |
| CMD | Hex | Thunk | Table Entry | Handler | Function |
|---|---|---|---|---|---|
| 11 | 0x0B | sub_800FB4E |
entry 10 | sub_800BD1C |
DRYING: sets temp/duration/fan, starts heater state machine (dword_20000600=1) |
| 12 | 0x0C | sub_800FB86 |
entry 12 | sub_800BDF6 |
SET_DRY_TEMP: sets PID target = temp+7Β°C via sub_800D8AC; stores setpoint in word_20000670 for auto-adjust. Does not change dword_20000600 (dryer state) |
| 64 | 0x40 | sub_800FB6A |
entry 11 | sub_800BD7E |
GET_TEMP: returns current temps Γ 3 + heater mode + remaining time |
| 65 | 0x41 | sub_800FBBE |
entry 14 | sub_800BE9C |
SET_DRY_POWER: set heater power level |
| 75 | 0x4B | sub_800FBA2 |
entry 13 | sub_800BE0A |
DRY_CMD: dry subtask control (temp parameter) |
| CMD | Hex | Thunk | Table Entry | Handler | Function |
|---|---|---|---|---|---|
| 8 | 0x08 | sub_800FA36 |
entry 4 | sub_800B578 |
FEED_OR_ROLLBACK: validate slot/speed/mode, queue sub_800B22C |
| 9 | 0x09 | sub_800FA52 |
entry 5 | sub_800B644 |
STOP_FEED_OR_ROLLBACK |
| 10 | 0x0A | sub_800FA6E |
entry 1 | sub_800B4A0 |
UPDATE_SPEED |
| 19 | 0x13 | sub_800FAA6 |
entry 3 | sub_800B3AC |
SET_FEED_CHECK: encoder thresholds check_length / error_length |
| 76 | 0x4C | sub_800FA8A |
entry 2 | sub_800B2D8 |
GET_FEED_INFO: per-slot steps/length/decoder |
| 77 | 0x4D | sub_800FAC2 |
entry 0 | sub_800B400 |
MOTOR_TEST |
| CMD | Hex | Thunk | Table Entry | Handler | Function |
|---|---|---|---|---|---|
| 13 | 0x0D | sub_800FEC8 |
entry 24 | sub_800E910 |
GET_RFID_CACHE: returns cached 19+19 byte tag data from word_20000054[82*slot] |
| 14 | 0x0E | sub_800FEE4 |
entry 22 | sub_800E74C |
SET_RFID_ENABLE: enable/disable RFID per slot + motor-side effect |
| 20 | 0x14 | sub_800FF00 |
entry 25 | sub_800E9EA |
SET_PRINTER_STATUS: stores byte_20000098, triggers byte_20001E60 |
| 68 | 0x44 | sub_800FEAC |
entry 23 | sub_800E7A8 |
GET_FILAMENT_INFO: live RFID read via sub_800DEB6, full decode |
Dynamic SRAM dispatch entries β handler structs built at runtime in SRAM.
| CMD | Hex | Thunk | SRAM entry | Handler | Function |
|---|---|---|---|---|---|
| 15 | 0x0F | sub_800FD40 |
dword_20000E88 |
sub_800FC98 |
LINEAR_KEY_CALIBRATE: ADC threshold β unk_20001A14, word_20000054 |
| 16 | 0x10 | sub_800FDA6 |
static entry 20 | sub_800DA30 |
GET_MATERIAL_INFO: slot material name (21 bytes) + status byte |
| 17 | 0x11 | sub_800FDDE |
static entry 21 | sub_800DB44 |
SET_SLOT_STATUS: write byte_200003FF/447/478 per slot |
| 18 | 0x12 | sub_800FDC2 |
static entry 19 | sub_800DAE2 |
SET_MATERIAL_NAME: write 21-byte name, clears status, triggers NVM write |
| 66 | 0x42 | sub_80101CE |
dword_20000FBC |
sub_8010170 |
SET_VALVE: sub_800F0CC(64,β¦) + sub_800F0CC(128,β¦) binary actuators |
| 70 | 0x46 | sub_801025C |
dword_20000FB0 |
sub_80101EC |
FLASH_LED: stores 6-param pattern, creates "flash led" FreeRTOS timer |
| 71 | 0x47 | sub_8010154 |
dword_20000FA4 |
sub_8010038 |
SET_FAN: speed 0β100% β 5 levels, creates "fan_timer" FreeRTOS timer |
| 72 | 0x48 | sub_801001A |
dword_20000FC8 |
sub_8010004 |
MOTOR_MOVE: calls sub_800F278(move_type, param, 0) |
| 73 | 0x49 | sub_800FC7C |
dword_20000E70 |
sub_800FC4C |
GET_SENSOR_STATE: returns 17-bit mask of active unk_20001A14 channels |
| 78 | 0x4E | sub_800FD8A |
dword_20000E7C |
sub_800FD5C |
GET_MOTOR_STATUS |
| Address | Name | Purpose |
|---|---|---|
0x200011FC |
dword_200011FC |
Frame parser state (0=idle, 1β8=parsing states) |
0x2000111C |
word_2000111C |
Expected SEQ counter (hi byte) + assigned SEQ (lo byte) |
0x200012D4 |
dword_200012D4 |
Linked list head pointer (most-recently-registered command) |
0x200012DC |
dword_200012DC |
Linked list tail pointer |
0x20001000 |
dword_20001000 |
Per-slot state array (dword_20001000[slot]) |
0x20001014 |
unk_20001014 |
Per-slot structures (64 bytes each, 4 slots) |
0x20001A14 |
unk_20001A14 |
Filament sensor state array (32 bytes Γ 17 channels) |
0x20001BA0 |
dword_20001BA0 |
Motor vtable array (16 bytes Γ slot) |
0x20001600 |
dword_20001600 |
Motor controller array (slot control structs) |
0x20000600 |
dword_20000600 |
Dryer state machine state (0=off, 1=start, 2=running, 3=stop) |
0x20000604 |
dword_20000604 |
Drying duration remaining (seconds) |
0x20000608 |
dword_20000608 |
Drying start timestamp (tick count) |
0x20000010 |
dbl_20000010 |
Temperature reading 1 (double-precision FP) |
0x20000018 |
dbl_20000018 |
Temperature reading 2 |
0x20000020 |
dbl_20000020 |
Temperature reading 3 |
0x20000040 |
dbl_20000040 |
Temperature reading 4 |
0x20000054 |
word_20000054 |
RFID cached data (82 words Γ 4 slots) |
0x2000820C |
dword_2000820C |
System tick counter |
0x20000D98 |
byte_20000D98 |
GET_INFO "first call" init flag |
0x20001554 |
word_20001554 |
OTA in-progress flag (low byte) |
0x20000096 |
byte_20000096 |
check_length β SET_FEED_CHECK pass threshold (encoder units, 1 byte) |
0x20000097 |
byte_20000097 |
error_length β SET_FEED_CHECK evaluation point (commanded units, 1 byte) |
State values stored in dword_20001000[slot]:
| Value | State | Notes |
|---|---|---|
| 0x00 | IDLE / READY | Default resting state |
| 0x03 | ASSISTING | Buffer assist active |
| 0x81 | FEED_ERROR | Encoder measured less than check_length at error_length checkpoint |
| 0x82 | ROLLBACK_ERROR | Retract motion error |
| 0x83 | ASSIST_ERROR | Hardware error during assist |
| 0x84 | PRELOAD_ERROR | Preload failure |
| 0x85 | STUCK_ERROR | Jam: cont_assist_time exceeded or consecutive assist count too high |
| 0x86 | TANGLED_ERROR | Tangle: sustained feed resistance |
| 0x87 | MOTOR_ERROR | Motor driver fault (IRQ or overcurrent) |
Per-slot structures at unk_20001014 + 64 * slot (64 bytes each).
Sensor states at unk_20001A14 + 4 * channel (byte 1 = active flag).
Motor vtable at dword_20001BA0 + 16 * slot.
On first connection the host initiates:
Step 1 β DISCOVER (CMD 0): Host sends CMD 0 with empty payload. ACE responds with its 96-bit STM32 UID:
response[0..2] = MEMORY[0x1FFFF7E8], [0x1FFFF7EC], [0x1FFFF7F0] (3 Γ uint32)
Step 2 β ASSIGN (CMD 1):
Host sends CMD 1 with the UID it received plus a desired seq_byte:
request[0..2] = UID words (must match device UID exactly)
request[3] = desired initial SEQ counter value
ACE validates all three UID words. On match:
- Sets
HIBYTE(word_2000111C)= current expected seq - Sets
LOBYTE(word_2000111C)= host's chosenseq_byte - Returns success (1)
On mismatch: returns 0x100000000 (error).
After ASSIGN succeeds all subsequent frames must use incrementing SEQ values.
The ACE2 supports in-application firmware update over the same UART protocol. Three-step sequence registered by the IAP upgrade task:
Step 1 β CMD 2 (IAP_UPGRADE): handler sub_800D4E4
request: OTA metadata struct
[0] = firmware image pointer (address)
[1] = metadata word (size/CRC info)
[2..12] = version string (11 bytes)
Side effect:
dword_20001558 = context ptr
word_20001540 = metadata
dword_2000153C = image ptr
byte_20001554 = 1 (OTA active)
Step 2 β CMD 3 (IAP_FIRMWARE): handler sub_800D564
Receives firmware chunks. The IAP task flash-writes received data.
Step 3 β CMD 4 (IAP_UPGRADE_FINISH): handler sub_800D5C4
Verifies the written image and triggers reboot into new firmware.
Version query β CMD 5 (IAP_VERSION): handler sub_800D528
Returns 11 bytes from 0x08018000 (version struct in flash ROM constants area).
State variable dword_20000600:
| Value | State |
|---|---|
| 0 | OFF (idle) |
| 1 | STARTING (CMD 11 received, valid temp) |
| 2 | RUNNING (PID loop active) |
| 3 | STOPPING (CMD 11 received with temp=0 while running) |
CMD 11 (DRYING) handler sub_800BD1C:
request[0]= target temperature β validated bysub_800D8ACrequest[1]= duration in minutes β stored as60 * request[1]seconds indword_20000604request[8]= fan mode byte βbyte_2000061C- State transitions (the only command that drives the state machine):
- Valid temp + state==OFF(0) β state = STARTING(1)
- temp=0 + state==RUNNING(2) β state = STOPPING(3); dryer task then transitions 3βOFF(0)
CMD 12 (SET_DRY_TEMP) handler sub_800BDF6:
- Calls
sub_800D8AC(request[0]):- Valid temp:
dword_20000674 = temp + 7.0(PID target with +7Β°C headroom offset);word_20000670 = temp(setpoint for auto-adjust loop) - temp=0:
dword_20000674 = NaN(disables heater);word_20000670 = 0(disables auto-adjust)
- Valid temp:
- Does not modify
dword_20000600β the dryer state machine is unchanged. Sending this command with temp=0 stops heating but leaves the dryer in RUNNING(2) state.
CMD 75 (DRY_CMD) handler sub_800BE0A:
- Sets raw PID target directly:
dword_20000674 = temp(no +7Β°C offset) - Clears auto-adjust:
word_20000670 = 0 - Sets upper limit:
flt_20000000 = temp + 5.0 - temp=0:
dword_20000674 = NaN,flt_20001CA0 = 0.0 - Also does not modify
dword_20000600.
PID heater control runs inside the dry task (sub_800BEC8). NTC temperature via ADC1.
Motor PWM via TIM7 (PSC=119, ARRβ20000). Encoder interrupt at EXTI0 (PA0) for odometry.
The RFID task manages 4 slots via byte arrays at byte_20001A15/25/35/45.
sub_800DD5A= RFID initsub_80136B4= RFID read- Magic values
123and456appear in RFID data validation
GET_FILAMENT_INFO (CMD 68, sub_800E7A8):
- Real-time read via
sub_800DEB6 - Response: 2 Γ 19-byte tag strings, up to 10 data DWORDs, status/error codes
- Error codes: 0=success, 3=read error, 4=CRC error, 6=tag too short
GET_RFID_CACHE (CMD 13, sub_800E910):
- Returns last-read data from SRAM cache
word_20000054[82 * slot] - Same response structure as CMD 68, no hardware access
Host usage: gklib (ACEProxyV2.get_filament_info, 0x62B35C) sends CMD 13 (GET_RFID_CACHE), not
CMD 68. The ACE2 MCU continuously updates the cache from background RFID polling; the host simply
reads from it. CMD 68 is a test/debug live trigger only.
FilamentInfoResponse field notes (confirmed from gklib 0x62B35C):
diameter(proto field 8): stored as uint32 in 0.01 mm units (175 = 1.75 mm).remainder(proto field 11): stored as total remaining length in mm; gklib converts to percentage via100 * remainder / totalLength.- Both CMD 13 and CMD 68 share the same req/resp descriptor pair (
0x08019250/0x08018FE0) β the decoder is identical regardless of which command is used.
Thank you for this β it is the only reason any of what follows was possible. I have been running
an ACE 2 Pro on a Voron Trident under Klipper, and your protocol work, OTA updater and MCU
analysis were the entire foundation. Every address below was found by following your map.
I ended up building and running custom firmware on the unit, and picked up some findings that add
to (and in two places correct) the analysis. Full write-up and patches here:
https://github.com/Simon-CR/ace2-pro-firmware-research
1.
FEED_OR_ROLLBACKmode 3 is real: rollback-assistThe enum has four modes and the driver only ever sends 0 and 1. Mode 2 is feed-assist; mode 3
is rollback-assist and works β the device answers SUCCESS and the slot reports
rollback_assisting(state 4). Handler0x0800B578validatesindex β€ 3,speed β€ 100,mode β€ 3.The buffer gates them symmetrically. The runner at
0x0800A0B0reads a per-slot BUF_BACKindex from the table at
0x08019570 = {3,7,11,15}and a per-slot BUF_RST index from0x08019560 = {2,6,10,14}, plusCHN_BUF_FEED(KeyIndex 16); active flags at0x20001A14 + 4Β·KeyIndex + 1. Measured by hand on the buffer:Both are open-ended motion on a free strand, since neither stop condition can occur.
Mode 3 is genuinely useful: the ACE can only take up, never push, so with the strand in the
extruder nip it follows retraction ~1:1 and needs no pacing between the two motors. I used it to
recover a stuck lane β 30 mm of plain extruder retract cleared the toolhead sensor with no tandem
synchronisation. Admission is by slot state (feed also admitted from
assisting, rollback alsofrom
rollback_assisting, assists only fromready).2. What the IAP task validates before committing
At
0x080140B4onward it checks an 8-byte magic signature in the last 8 bytes of the stagedimage, byte by byte at
staging_base + announced_size - 8:then a checksum over the whole staged image against the announced CRC. So a patched image must
keep that magic last β append new code before it. I lost hours assuming size was the constraint.
3. Two silent-
SUCCESStraps in the OTA pathBoth let a flash report a flawless three-step success while the device does nothing:
IAP_UPGRADE(0x0800D4E4) startsldrb r2,[r5,#24]; cbnz r2, skipβ if an OTA is alreadypending the entire handler is skipped, and since the result code was zeroed at entry the host
still sees SUCCESS. One failed attempt poisons every later one until the device is power-cycled.
IAP_UPGRADE_FINISH(0x0800D5C4) only arms the commit if that state byte is exactly 2, andreturns 0 either way.
Practical rule: power-cycle before flashing, and verify by reading back a version string embedded
in your own image β a stock re-flash is unfalsifiable.
4. Two bugs in
ace2-ota-update.py(patch in the repo above)send_recvnever returns early β it loops to the full timeout even after the device answers. AtT_CHUNK = 2.0Γ ~1119 chunks that is exactly the 37 minute flash time, almost all idle.Returning on the ack gives a measured 26.5 s.
0x80response bit, but the bootloader answers withflags = 1. During recovery it therefore prints "No response. ACE may not be connected" anda perfectly healthy device looks bricked. I hit this twice and briefly thought I had destroyed
the unit. Matching on the command id fixes it.
5. The bootloader implements the protocol β recovery does not need SWD
In IAP mode
GET_INFOreturnsboot_version = V1.0.2(the application returns that field empty),status = upgrading, andversion= whatever string the host announced. Re-flashing a stock imagefrom that state restores the device.
6. RFID corrections and additions
7B 00 65 00=magic 123 + version 101;
456is the firmware's own "already validated" sentinel thatthe RFID cache task (
0x0800EA14) writes back into the slot record. Only 123 is externallycompared.
0x0E), nokey load, no SHA-256/HKDF constants. That is exactly why Bambu MIFARE Classic tags give
READFAILED (6): they select fine (the UID is available), then NAK the first unauthenticatedread.
RFID_TEST) is not registered at all in V1.1.31 β it exists in the ACEPRO driver'scatalog but the firmware has no handler. 67 and 74 are likewise absent.
0x20000054 + slot Γ 164: version (u16) at+286, sku[19] at+288, type[19] at+328, colours at+348(served by the cmd 13 handler0x0800E910). Itsurvives an eject; only the insert-triggered search rewrites it.
path drops the low index bit (
(index << 1) & ~2). Acode 4ANTICOLLISION result is thegiveaway. This invalidated a whole round of my testing before I noticed.
7. One bug in the ACEPRO driver, in case it is useful
FILAMENT_IDENTIFY(cmd 68) is declared as returning aGenericResponse(field 1 = code), butthe firmware returns a
FilamentInfoResponse(field 1 = index, field 12 = code). So thedriver reports back the index it sent β index 1 reads as "PARAM_ERROR", 2 as "FORBIDDEN", 3 as
"FAILED", and 0 as "SUCCESS" only because proto3 omits zero scalars.
Happy to move any of this into a fork of the gist if you would prefer it structured that way, or
to correct anything I have got wrong β several of the items above began as my own wrong
conclusions before the disassembly set me straight.