Skip to content

Instantly share code, notes, and snippets.

@hakimio
Last active September 9, 2026 16:04
Show Gist options
  • Select an option

  • Save hakimio/4916ff69add458fdc51aeea76f21efb9 to your computer and use it in GitHub Desktop.

Select an option

Save hakimio/4916ff69add458fdc51aeea76f21efb9 to your computer and use it in GitHub Desktop.
Anycubic ACE 2 Pro Firmware Behavior Analysis

Anycubic ACE 2 Pro β€” Firmware Behavior Analysis

Dual-source reverse-engineering:

  • gklib β€” Anycubic Kobra S1 "Klipper-Go" firmware, version 2.7.0.9.
    ARM 32-bit ELF binary, imagebase 0x10000. Analysis via IDA Pro decompilation.
  • ACE2 MCU firmware β€” ACE2_V1.1.31_20260306.bin, STM32F1 Cortex-M3.
    Loaded at 0x08008000. Analysis via IDA Pro disassembly/decompilation.

Background

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 operations
  • ACEProxyV2 β€” represents a single connected ACE 2 device
  • V2Manager β€” manages multiple ACE 2 devices (daisy-chain)
  • FilamentStuckChecker β€” monitors encoder feedback to detect clogs

1. Error Handling

Error handling is driven by FilamentHub.update_status (0x644768), which polls slot states and transitions the state machine.

ASSIST_ERROR (slot state 0x83)

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.

Other error states

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.

Error recovery

  1. Printer pauses; user resolves the physical issue
  2. User resumes print (GCode M24 or UI button)
  3. update_status observes slot state returned to IDLE/READY and clears the internal error flag
  4. 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.


2. Firmware Update (OTA / IAP)

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.

Step 1 β€” Announce update: IAP_UPGRADE (cmd 2)

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"

Step 2 β€” Send firmware chunks: IAP_FIRMWARE (cmd 3)

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"

Step 3 β€” Finalise: IAP_UPGRADE_FINISH (cmd 4)

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 crc field in UpgradeRequest is a CRC of the complete firmware image. It is separate from the per-packet CRC16-Kermit framing used on every UART frame.


3. GCode Filament Switching

SWITCH_FILAMENT is a GCode command implemented in the firmware.

GCode syntax

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.

Full switch logic: switch_filament (0x64efc8)

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

4. Feed Check

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.

Setting parameters: SET_FEED_CHECK (cmd 19)

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:

  1. After the ACE has been commanded to feed error_length units, it samples the encoder count.
  2. 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
  3. If encoder_count < check_length, the ACE transitions to FEED_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_length beyond the actual assist stroke β€” the check would then fire after the assist has already completed, making it useless.
  • Example: check_length=80, error_length=90 raises 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.

Web API handler: handle_set_feed_check (0x64c6b0)

Accepts a POST with ace_id (float), check_len, and error_len. Looks up the ACE proxy by ID and calls SetFilamentFeedCheckParam.

Diagnostics: GET_FEED_INFO (cmd 76)

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.


5. Calibration

There are two separate and independent calibration mechanisms.

A. Klipper-side filament length calibration: filament_recalibrate (0x653070)

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.

B. ACE-side optical sensor calibration: LINEAR_KEY_CALIBRATE (cmd 15)

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.


Quick Reference: ACE 2 Command IDs

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

Slot State Reference

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

ACE2 MCU Firmware Analysis

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.


Identity

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

Hardware

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 = 0x38400 passed to BRR calculator at sub_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

FreeRTOS Tasks

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

Frame Format

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.


Dispatch Architecture

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:

  1. Decodes the protobuf request payload using the table entry's request descriptor
  2. Calls the handler function with (context, decoded_request, response_buffer)
  3. Encodes the protobuf response using the table entry's response descriptor
  4. 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.


Command Handler Map

Complete list of all 33 registered commands extracted from ACE2 MCU firmware V1.1.31.

Registered by Command Processing task (0x8014488 init phase)

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

Registered by IAP upgrade task (0x8013FB8)

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

Registered by dry task (sub_800BEC8)

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)

Registered by filament task (sub_8014EAC)

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

Registered by RFID task (sub_800EA14)

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

Registered by misc/motor task (0x80159B0 init)

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

Key SRAM Variables

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)

Slot State Machine (filament task)

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.


DISCOVER / ASSIGN Handshake

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 chosen seq_byte
  • Returns success (1)

On mismatch: returns 0x100000000 (error).

After ASSIGN succeeds all subsequent frames must use incrementing SEQ values.


OTA / IAP Protocol

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).


Dryer State Machine

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 by sub_800D8AC
  • request[1] = duration in minutes β†’ stored as 60 * request[1] seconds in dword_20000604
  • request[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)
  • 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.


RFID

The RFID task manages 4 slots via byte arrays at byte_20001A15/25/35/45.

  • sub_800DD5A = RFID init
  • sub_80136B4 = RFID read
  • Magic values 123 and 456 appear 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 via 100 * 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.
@Simon-CR

Copy link
Copy Markdown

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_ROLLBACK mode 3 is real: rollback-assist

The 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). Handler 0x0800B578 validates index ≀ 3, speed ≀ 100,
mode ≀ 3.

The buffer gates them symmetrically. The runner at 0x0800A0B0 reads a per-slot BUF_BACK
index from the table at 0x08019570 = {3,7,11,15} and a per-slot BUF_RST index from
0x08019560 = {2,6,10,14}, plus CHN_BUF_FEED (KeyIndex 16); active flags at
0x20001A14 + 4Β·KeyIndex + 1. Measured by hand on the buffer:

  • BUF_RST stops mode 2, BUF_BACK stops mode 3
  • mode 2 = feed until the buffer is compressed forward, wait, resume when the extruder takes filament
  • mode 3 = rewind until the strand goes taut, wait, resume when slack appears

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 also
from rollback_assisting, assists only from ready).

2. What the IAP task validates before committing

At 0x080140B4 onward it checks an 8-byte magic signature in the last 8 bytes of the staged
image
, byte by byte at staging_base + announced_size - 8:

61 A5 63 5A 65 A5 32 5A

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-SUCCESS traps in the OTA path

Both let a flash report a flawless three-step success while the device does nothing:

  • IAP_UPGRADE (0x0800D4E4) starts ldrb r2,[r5,#24]; cbnz r2, skip β€” if an OTA is already
    pending 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, and
    returns 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_recv never returns early β€” it loops to the full timeout even after the device answers. At
    T_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.
  • More seriously: it filters replies on the 0x80 response bit, but the bootloader answers with
    flags = 1
    . During recovery it therefore prints "No response. ACE may not be connected" and
    a 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_INFO returns boot_version = V1.0.2 (the application returns that field empty),
status = upgrading, and version = whatever string the host announced. Re-flashing a stock image
from that state restores the device.

6. RFID corrections and additions

  • The "magic values 123 and 456" are not both tag checks. Page 4's header is 7B 00 65 00 =
    magic 123 + version 101; 456 is the firmware's own "already validated" sentinel that
    the RFID cache task (0x0800EA14) writes back into the slot record. Only 123 is externally
    compared.
  • There is no cryptography anywhere in the RFID path β€” no MFAuthent (CommandReg 0x0E), no
    key 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 unauthenticated
    read.
  • Cmd 69 (RFID_TEST) is not registered at all in V1.1.31 β€” it exists in the ACEPRO driver's
    catalog but the firmware has no handler. 67 and 74 are likewise absent.
  • The cached tag record lives at 0x20000054 + slot Γ— 164: version (u16) at +286, sku[19] at
    +288, type[19] at +328, colours at +348 (served by the cmd 13 handler 0x0800E910). It
    survives an eject; only the insert-triggered search rewrites it.
  • Slots 2 and 3 share one antenna that can see both bays' tags simultaneously β€” the identify
    path drops the low index bit ((index << 1) & ~2). A code 4 ANTICOLLISION result is the
    giveaway. 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 a GenericResponse (field 1 = code), but
the firmware returns a FilamentInfoResponse (field 1 = index, field 12 = code). So the
driver 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.

@Simon-CR

Copy link
Copy Markdown

Thanks β€” and noted on attribution, and on the flasher; I'll point people at DnG-Crafts' GUI.

On the Klipper side: partly. The firmware patches run (UID passthrough, RC522 register passthrough for read/write/transceive, and clearing the cached per-slot tag record), and I drive them from Klipper through a small raw-command extra (ACE_RAW_CMD / ACE_RAW_FEED) β€” but that's a Voron Trident on the Kobra-S1/ACEPRO driver, not a Snapmaker, and there is no packaged overlay. Full Bambu tag reads work end to end on ACE hardware, including Crypto1 auth with HKDF-SHA256 derived keys; I've also confirmed from the access bits that Bambu tags are permanently read-only, and written a non-Bambu NTAG. What's missing is everything that would make it usable day to day β€” scan a tag on demand, look a spool up by its RFID UID, write a tag with a prompt to flip the spool for the second one. Those are designed, not built. So: mechanism yes, overlay no.

Repo, if it's of use: https://github.com/Simon-CR/ace2-pro-firmware-research (MIT).

Three corrections to your driver from the V1.1.31 firmware analysis, which are probably the most useful thing I have for you:

  • GET_TEMP field decode is shifted by one. The descriptor has seven floats, not six, and the handler writes fields 1 and 2 as literal zero on every call. So box1_temp / box2_temp are always 0.0, every later name is off by one, and what you label env_humidity is actually a temperature.
  • 133 (stuck) and 134 (tangled) are unreachable. They get written into the feed-check object, then the operation handlers re-code them to 129 (feed) or 132 (preload) before the status is ever readable by a host β€” so anything branching on those two is dead code. Same for 130 (rollback_error): FEED_OR_ROLLBACK sets the feed-check bypass flag for the duration of a rollback, so the deficit check can't fire.
  • The tag UID is fabricated by the driver's RNG. The UID passthrough patch returns the real one.

A correction on my side too: I'd written up FEED_OR_ROLLBACK mode 3 as undocumented, but your ace-2 branch shipped FEED_MODE_UNWIND_ASSIST = 3 on 2026-08-09, before my repo existed. Yours first β€” I'm fixing my docs. The genuinely new part is that the firmware ignores the speed and length parameters for the assist modes (hardcoded to unbounded and 50 mm/s), and BUF_BACK is the stop condition for mode 3. Your UNWIND_ASSIST_SPEED = 0 is independent field evidence for exactly that.

Last thing: your note that ASSIST_ERROR fires about a second after the toolhead stops pulling matches the mechanism in firmware β€” both buffer limit switches asserting at once β€” which trips well before the 4000 ms continuous-assist timer. I'd been anchoring on that timer, wrongly.

@Simon-CR

Copy link
Copy Markdown

@decay71 β€” glad the patches were useful, and good to see read and write working end to end. That's further than I've got on the Klipper side yet, and OpenSpool support is the part most people actually want.

Two things from the firmware side that might save you some time.

The firmware's cached tag record survives an eject. Measured: after an eject, with the slot reporting empty, cmd 13 still returned the previous version/sku/type. Toggling SET_RFID_ENABLE doesn't clear it and a live identify doesn't repopulate it β€” only the insert-triggered background scan rewrites the record. The two caches then disagree, because the Klipper driver clears its own view immediately while the firmware keeps its, which is exactly what misled me. That's what op 8 in the patch set is for; if you'd rather handle it yourself, the record is at 0x20000054 + slot Γ— 164 (version u16 at +286, sku[19] at +288, type[19] at +328), served by the cmd 13 handler at 0x0800E910.

On the ~25 s / 600 mm search, offered as an idea rather than a correction. The spool and the feed gears run off one motor through a permanent coupling β€” there's no separate spool drive anywhere in the command set, and I confirmed it by detaching a lane's tip from the gears and commanding a rollback: the spool turned anyway. So rotating to find the tag is also feeding filament, which is why it's expensive and why you need the 20 mm restore. Two consequences:

  • On an empty lane the spool still turns with nothing in the gears β€” 299 mm commanded gave magnitude_mm 299, motor_counts 3699, moved_mm 0. A tag search on an unloaded lane therefore costs no filament movement at all; if the flow can search before load, it's free. (That's also the maximal motor-versus-filament divergence case, so it would trip the native jam detection if a host ever switched it on.)
  • GET_FEED_INFO (cmd 76) separates them: magnitude_mm/motor_counts track the motor, moved_mm tracks the filament from a separate sensor and is signed. Neither sees the spool β€” one binding in its cradle looks normal from the command side. The tag passing the antenna once per revolution is the honest rotation signal, if you ever want one.

Small point in your favour: one revolution of a 1 kg spool is roughly 600 mm of filament, so your 600 mm ceiling looks about right. I saw tags come into range anywhere between 15 and 510 mm.

@decay71

decay71 commented Aug 31, 2026

Copy link
Copy Markdown

@Simon-CR

Thanks, both points land, and the eject-cache one is good to know. On the Klipper side I clear my own view on eject, so the disagreement you hit is the firmware keeping its record while I've already dropped mine. I'll wire op 8 in for the cases where anything still trusts cmd 13.

The free-search-before-load consequence is the more useful one for me. Writing a blank or OpenSpool tag happens on a fresh spool that isn't loaded yet, so the search costs no filament and the restore moves nothing. That turns the "insert spool, set slot, write to tag" flow into something I don't have to charge the user for, and it's good to have it confirmed rather than assumed.

In return, a few things from getting WRITE working through your op tunnel, in case they save someone else time:

The NTAG WRITE ACK doesn't come back through the tunnel. After the op-3 transceive of an A2 frame, op 5 reports bits=0x00 and op 4 gives a stale buffer byte. The tag only sends its 4-bit ACK after the ~4 ms programming time, which outlasts the transceive's receive window. The write itself succeeds (proven by read-back), only the handshake is invisible. So I stopped trusting the ACK and verify every page (or 4-page chunk) by reading it back: ground truth over the handshake.
RX CRC has to be off for that WRITE transceive. The 4-bit ACK carries no CRC and the reader flags a protocol error otherwise; TX CRC stays on so the A2 frame gets its CRC_A.
A WRITE leaves the read pointer shifted. A verify READ of page 4 immediately after writing returned page-19 bytes, deterministically, twice. A re-SELECT (op 6) before the read resets it.
op 7 (bulk read) only ever returned a constant 0x90 with no payload in any response field (sku, tag, unparsed all empty), across every a1/a2 I tried. So I couldn't pull a whole page back in one call and stayed with the per-byte op-4 reads. Is that expected on 1.1.3O, or is there an arg shape that surfaces the FIFO?

@decay71

decay71 commented Sep 7, 2026

Copy link
Copy Markdown

@Simon-CR Just to inform you, i just released a first mUlt1ACE Version featuring you firmware. It's able to flash 1.1.31 directly from the webui or live-patch it to 1.1.3O. Read on insert and Write for Anycubic and Openspool are included. Thanks again for your awesome work!

@Simon-CR

Simon-CR commented Sep 8, 2026

Copy link
Copy Markdown

@decay71 Huge congratulations on the mUlt1ACE release! Live-patching from the web UI and end-to-end OpenSpool read/write on the ACE is an awesome milestone.

I had a draft queued for your earlier questions that got overtaken by a round of hardware testing, but several bugs we hit and fixed since V1.1.3O are directly relevant to mUlt1ACE. The repo is currently up to V1.1.43; here is what you will want to know:


1. The op 7 (bulk read) answer: why it returned 0x90

0x90 is decimal 144 β€” the byte count for pages 4–39 (36 pages Γ— 4 bytes). It was not an error or echo; op 7 actually succeeded on every call.

The catch is that the payload never crosses the protobuf boundary directly β€” the passthrough returns a single byte in code, and the 144 bytes land in device RAM at 0x20000704. In V1.1.3O, op 4 reads from BUF+64+arg1 with arg1 masked to 6 bits (0–63), meaning op 4 can only ever address dump bytes 64–127 (pages 20–35). Pages 4–19 (which hold magic, version, SKU, brand, and material) were physically unreachable.

  • The fix: We added op 9 using free bits 15–14 in the packed request word. It reads BUF + offset with a full 8-bit offset (0..255), giving direct access to the entire staging buffer.
  • Side-effect traps with op 7: The 144-byte write at BUF+0 clobbers anything staged in the TX region (BUF+0..63), and overwrites the bit-length byte at BUF+128 (so an op 5 immediately following returns page 36 byte 0 instead of a bit count).

2. Bugs & Safety Fixes in Firmware (V1.1.41 β†’ V1.1.43)

If you are live-patching or distributing builds, you will definitely want the patches from V1.1.41–V1.1.43:

  • V1.1.43: The Blind NAK / Cross-Lane Ghost Tag Bug (pageread_gate_stub.s)
    To read OpenSpool tag tails past page 39, we extended the stock page-read loop to 12 blocks (pages 4–51) and redirected NAK branches to the success return. That redirect turned out to be blind: a failure at any block (even block 0, a total read failure) still returned 144. Because 0x20000704 is a device-global buffer, a failed read left the previous tag's bytes in memory. A lane that failed to read would silently inherit its neighbour's identity and cache it until eject (measured on hardware: T1 repeatedly returning T2's SM24 depending on scan order).

    • Fix: Added pageread_gate_stub.s, which checks r7 (signed compare against 124 bytes actually read). Short reads branch to pageread_fail (returns 0) rather than returning 144 with stale RAM.
  • V1.1.42: The Heater Safety Trap (Sentinel 0x0203)
    When injecting an identity (SM<n>), firmware previously marked version = 101. But 101 means "native Anycubic decode: all fields valid". For foreign/OpenSpool tags, the other fields are the stock positional parse of raw JSON β€” yielding garbage like nozzle temp 28,770 Β°C and hotbed min > max. If an Anycubic printer or naive host trusts that decode, it could command extreme heater targets.

    • Fix: Replaced with sentinel 0x0203 ("identity injected: SKU is authoritative, all other fields are garbage"). Any consumer that does not know 0x0203 safely ignores the record. (Current sentinels: 0x0201 = UID, 0x0202 = raw image cached for op 9, 0x0203 = injected identity).
  • V1.1.41: Cmd 68 Live Read Bypassed Injected Identity
    rawtag_stub's non-Anycubic branch jumped straight to epilogue, skipping the sm_id injection on synchronous cmd 68 reads. Live reads returned sku=None and fell back to a slow 2.88s raw walk, while the background worker had resolved it. Fixed by searching 0x20000704 for sm_id on cmd 68 before committing the sentinel.


3. Recommendations for mUlt1ACE's OpenSpool Tag Writer

Two findings from our hardware testing that will make your tag writer much more robust:

  • Serialize spool_id / sm_id FIRST in the JSON payload:
    Stock firmware (and op 7) only reads 144 bytes (pages 4–39). Real OpenSpool NDEF payloads are typically ~175 bytes. Tag writers like FilaMan and SpoolmanScale append spool_id / sm_id at the end of the JSON (around byte 164), which falls outside the 144-byte window.
    If your writer places "spool_id" / "sm_id" first (right after "protocol":"openspool"), it lands at ~byte 59. That makes the spool ID readable by standard 144-byte readers without needing custom tail reads!
  • The 123-byte NDEF false opening brace:
    NDEF TLV format uses 0x03 followed by the length byte. If your JSON payload is exactly 123 bytes (0x7B), 0x7B is ASCII {. A naive parser searching for the first { will latch onto the length byte 2 bytes before the real JSON, throwing off brace matching.

4. Hardware Realities: Reader Pairs & Preload Interlock

  • Reader Sharing: Lanes 0+1 share Reader 0; Lanes 2+3 share Reader 1 (reader = lane >> 1).
  • Preload Interlock: Stock firmware will refuse to preload/feed an inserted spool if its paired sibling is busy moving. The gears never turn at all, which looks identical to a mechanical grab failure.
  • Preload State Trap: Slot state 5 (preload) is an intent flag set on the switch edge even if the motor never ran; state 1 (feeding) is the only state confirming active motor drive.
  • Bambu Tags on Background Scanner: The autonomous background scanner (0x0800FE28) only runs NTAG page reads (0x30). Bambu MIFARE Classic tags return 0 bytes and are silently dropped. Only host-initiated cmd 68 captures the UID via uid_stub.

All the updated stubs and build scripts are in the repo (https://github.com/Simon-CR/ace2-pro-firmware-research). Let me know if you run into anything unexpected or want to compare notes on any of the opcodes!

@Simon-CR

Simon-CR commented Sep 8, 2026

Copy link
Copy Markdown

two things, if some of the answers looks AI generated, it's because they are :)

In parallel, I just placed an order for a snapmaker U1 this morning, so I'll be able to test the ace 2 pro with it soon (which means I might have to order a second one, sigh :) ).

thanks

Simon

@Simon-CR

Simon-CR commented Sep 8, 2026

Copy link
Copy Markdown

oh, and if there are any features you'd want looked at, feel free to voice them, I might be able to help if you dont have the cycles.

@decay71

decay71 commented Sep 9, 2026

Copy link
Copy Markdown

Hi Simon,

first things first: if my last reply did not look AI generated, that is because I broke the formatting while replacing the em dashes πŸ™‚ No problem at all.

And congratulations on the U1. Two ACE 2 and two ACE Pro here, no regrets.

The op 7 answer closes a dead end for me. I probed op 7 on hardware and got a constant 0x90 in code with no payload in any field, so I wrote it off and left a do-not-re-probe note in my RC522 module. Your explanation makes it obvious in hindsight: the 144 bytes never cross the protobuf boundary at all, and with op 4 masked to 6 bits the interesting pages were simply unreachable. op 9 is exactly the piece that was missing. The two side effects are worth having documented as well - the 144-byte write clobbering the TX staging region, and the bit-length byte at BUF+128 being overwritten so a following op 5 returns page data instead of a bit count.

The ghost-tag bug is the one I fought hardest on the host side. A lane inheriting its neighbour's identity is exactly what I saw, and since the buffer is device-global there was no way to tell a stale record from a real read. What I ended up building, in case any of it is useful as a cross-check: I validate BCC0/BCC1 on every UID so a flipped byte cannot pass; I check the rx bit count on page reads, because a genuine reply is a full 0x80 frame and anything shorter is buffer remains; I hold off all tunnel operations while the firmware's own identify is running on that unit, since it corrupts reads in flight; retries deliberately change the transport position rather than repeating in place; and when a read is suspicious I run a rotation test - advance my own lane about 80 units and read again, and if the same card still answers it is stationary, so it belongs to the neighbour, and I rotate that neighbour out of the shared field before trying again. On top of that I resolve tag bindings in two phases: read the whole unit first, then decide, because a per-slot decision sees the neighbours with their previous code and a swapped pair heals only half.

All of that becomes unnecessary if the firmware simply stops returning 144 on a failed read, which is why I am looking forward to the new build.

The heater sentinel does not affect what I ship, but thank you for flagging it. My in-app patcher only applies the UID passthrough, so I never inject an identity and never emit a version = 101 record with garbage fields. If I move to a newer build I will honour 0x0203 as "SKU authoritative, ignore the rest".

On your writer recommendations: the 123-byte false opening brace cannot reach me because my decoder walks the TLV chain rather than searching for the first {. The spool_id ordering does not bite me yet either, since I do not write an id into the OpenSpool JSON at all - my per-spool key is the card UID, written into the SKU field of an Anycubic-format record so that every unit, including a stock ACE 2 and an ACE Pro, reports it through its normal read. If I ever emit OpenSpool ids I will put them first as you suggest.

Your hardware section explains two things I had measured but not understood. The reader pairing matches what I saw, and the preload interlock plus the state trap answers a question that cost me a full evening: my insert-time read kept getting refused with the slot sitting in preload, and I could not tell an intent flag from actual motion. State 1 as the only proof of motor drive is exactly the discriminator I was missing.

Two requests, if you have the time.

First, the pre-assembled patch.json. I ported your apply_patch.py to pure Python inside my web UI so users can flash ACE2-Open from the browser without a toolchain - the user supplies their own stock image, I verify the base md5 and every hook site, and your MIT attribution is in the file. My spec is byte-identical to the patch.json on master today, i.e. still the two-hook 1.1.3O set. Would you consider publishing an updated patch.json alongside the toolchain build when the newer stub set settles? That would let me ship the ghost-tag fix to users without taking on the build responsibility myself.

Second, the naming scheme, and this one matters more than it looks. My firmware auto-detection keys on the trailing letter: a version string ending in O means an open build with the passthrough, and anything purely numeric is treated as stock, which is how a mixed fleet works without per-unit configuration. In build_patch.py the current string is V1.1.44, so a unit flashed with it would read as stock to me and I would disable tag reading and writing on it. I can of course broaden my detection, but there is a second reason to keep a marker: if Anycubic ever ships a real V1.1.44, a purely numeric string makes the two indistinguishable for every host, not just mine. Would you keep an explicit marker in the version string?

And since you offered: the one thing I would love to understand better is the per-slot decoder field from GET_FEED_INFO. I use its span during a retract as my only ACE-side transport signal, and on a healthy unload I see about 96 % of the commanded length. What I cannot tell is what the counter actually tracks (filament through the encoder, or motor revolutions), whether it resets or holds per command, and whether a flat zero across a long rollback is a reliable "nothing moved". A user recently reported an unload that the ACE acknowledged as successful while the decoder stayed at zero for 73 seconds, with the filament later found buckled inside the tube - so knowing whether that zero is trustworthy would let me turn it into a real verification instead of a diagnostic.

Thanks again for all of this, and for making the research public.

@Simon-CR

Simon-CR commented Sep 9, 2026

Copy link
Copy Markdown

Hi @decay71,

Glad the op 7/9 and ghost-tag explanations landed! The host-side workarounds you built were an impressive engineering response to what was fundamentally a firmware buffer bug, but having the hardware return 0 on failed reads should clean all of that up.

Here is the update on your requests, the telemetry deep-dive, and the rotisserie drying mechanism:


1. Version Naming & patch.json (V1.1.46O)

  • Version String: Point taken on auto-detection and collision prevention. The build is tagged V1.1.46O (keeping the trailing O). The string table at 0x08018C20 has 8 null padding bytes following stock V1.1.31\0, so the extra character fits safely.
  • patch.json is live on GitHub: I just pushed commit 096edd2 to Simon-CR/ace2-pro-firmware-research. The repo now auto-generates firmware/patch.json on build:
    1. Ghost-Tag Read Gate (pageread_gate_stub.s at 0x0800E228): stops the blind 144-byte return on read failure, returning 0 so unreadable slots never inherit stale RAM from an adjacent bay.
    2. RC522 Hardware Tunnel & op 9 (rc522_stub.s at 0x0800E7DA): full register read/write, transceive, and direct 8-bit RAM buffer reads (BUF + 0..255).
    3. Native Multi-Format On-Chip Decoder (native_tag_decoder.c): autonomous, zero-alloc decode of OpenSpool NDEF, FilaMan, Prusament, and Creality CFS directly into cmd 68 and background cmd 13 slot records.
    4. Bambu Lab UID Passthrough (uid_stub.s at 0x0800E836, sentinel 0x0201): captures the immutable MIFARE Classic UID during the ISO 14443A select cascade before the unauthenticated NTAG read fails, returning the raw UID in sku.

Your web-UI patcher can consume firmware/patch.json directly.

(Note on Bambu: V1.1.46O keeps the 0x0201 UID passthrough, but we've confirmed the Cortex-M3 has plenty of flash/cycles to run standalone HMAC-SHA256 with the RC522's hardware MFAuthent (0x0E). We're working on V1.1.47O next to bring full native Bambu decoding directly on-chip).


2. Deep Dive on GET_FEED_INFO (cmd 76) decoder

I disassembled the cmd 76 handler (0x0800B2D8) and the motion runner down to the registers to verify your user's 73-second unload failure. Here is the exact hardware behavior:

  1. What it tracks:
    decoder tracks physical filament movement, not motor rotation.
    Lanes 0–3 route to hardware quadrature timers (TIM1, TIM8, TIM3, TIM5), which are connected directly to each lane's rubber encoder wheel. The handler reads (TIMx->CNT + extension[lane]) * 1.2342 mm (0x3F9DFA44). In our empty-lane tests, commanding a 299 mm rollback spun the motor 3,699 counts (steps = 3699, length = 299), but decoder read exactly 0.

  2. Reset vs. Hold:
    The encoder accumulator (0x20001E68) and TIMx->CNT are explicitly zeroed at the start of every motion command (0x08009D2E, called via vtable +0x14 by FEED_OR_ROLLBACK at 0x0800AE08 for feed and 0x0800A2E4 for rollback).
    Once motion stops, the counter is left untouched. It holds the final displacement until the next motion command begins.

  3. Why the 73s zero rollback happened (and why it is 100% trustworthy):
    In stock firmware, FEED_OR_ROLLBACK sets bypass = (mode == 1). The firmware's internal deficit/jam comparator (sub_8009D74) is completely bypassed during rollbacks.
    If filament buckles or jams during an unload, the motor and spool will spin, the filament will remain stationary over the encoder wheel, the firmware will ignore the slip deficit, and once the move completes it will report status = 0 (SUCCESS).

    Conclusion: A flat zero on decoder across a long rollback is ground truth that the filament was stationary. You can safely promote that check from a diagnostic to an authoritative abort/fault condition in mUlt1ACE (e.g., if commanded length > 30 mm and |decoder| < 2.0 mm, flag an unload buckle/jam).


3. Spool Roasting / Rotisserie Drying

You mentioned drying in multiACE; in case it's of interest, we built a true rotisserie drying system (ace_dryroll.cfg) based on two mechanical quirks of the ACE 2 Pro:

Anycubic's built-in auto_roll barely works β€” it only nudges ~5 mm backwards every 4 minutes. But because the feed motor and spool cradle rollers share a permanent mechanical coupling with no clutch, you can rotate spools continuously:

  1. spin Mode (Unthreaded spool): When a spool is in the cradle but not loaded into the drive gears (empty status, tip taped to flange), commanding a rollback rotates the spool cradle indefinitely without moving any filament (magnitude_mm advances, moved_mm stays 0). It acts like a true rotisserie with zero risk to the strand.
  2. sweep Mode (Threaded spool): When filament is threaded into the gears (ready), it oscillates back and forth in a calibrated window behind the sensor park datum. Net movement is 0, so it never advances toward the hub while ensuring even heat exposure.

4. Downstream PRs

I just placed an order for a Snapmaker U1 this week, so I'll be setting up multiACE myself soon. I don't plan to be an active core developer on multiACE, but as we build and test features on our hardware (like the rotisserie drying logic, upcoming V1.1.47O on-chip Bambu decoding, or small shims to let multiACE run cleanly on generic CoreXY Klipper setups), we'd be happy to submit them as standalone PRs if you're open to merging them. That way we can leverage multiACE on our machines and contribute improvements back whenever we have something solid.

@Simon-CR

Simon-CR commented Sep 9, 2026

Copy link
Copy Markdown

@decay71 sorry I might have worded it wrong, I meant to say that MY replies are ai generated for the most part, I save time where I can :)

@decay71

decay71 commented Sep 9, 2026

Copy link
Copy Markdown

@Simon-CR got that, and i wanted to say, mine too!

thanks - the cmd 76 disassembly is the single most useful thing anyone has handed this project. I built the gate the same day.

The unload gate is in. Some context on why it took your finding: our unload verifies the toolhead end with the presence pin, but the bulk retract that follows a verified clear ran completely unverified. We had the decoder span logged for months and never dared act on it, because we could not tell "the ACE moved nothing" from "this firmware never reported the field" - our parser read an absent decoder as 0. So that split came first: only real readings count now, and without data every consumer abstains. Then your point about bypass = (mode == 1) closed it, because it explains the thing that always bothered me - the device reporting status = 0 while nothing moves. A flat span is the only signal that contradicts that, and now it does: a bulk retract of 30 mm or more measuring under 2 mm fails the unload instead of reporting it done. Deliberately a near-total stall rather than a proportional check; healthy spans read 87-101 % here, so it can miss a partial stall but cannot invent one. It also gets its own message, since "unload the slot first" is the wrong advice when the head is already empty and only the tube is blocked.

That directly serves the open field report I mentioned - a user whose unload read 0 of 1450 mm and whose photo showed buckled filament in the PTFE tube. Until now our software agreed with the ACE that the unload had succeeded.

patch.json works unchanged. Same schema, same base, so our pure-Python patcher consumed it as-is; 1.1.46O is a flash target in the web UI now, and both of our V2 units are already running it. Two notes in case they are useful to others:

We derive our CRC from your result_crc16 rather than storing a second number: the CRC register is linear, so ours = yours XOR crc16(init=0xFFFF, zeros(result_size)). That transform reproduces the 1.1.3O pair (0xB086 β†’ 0x7444) exactly, so the two conventions never have to be maintained separately. It predicted 1.1.46O correctly before we had the image.
Thank you for keeping the trailing O. It is load-bearing on our side: the tag read/write UI appears only for units whose version string ends in it, so a numeric-only build would silently remove those features for everyone.

On the read gate: I had expected our neighbour-handling to fall silent on 1.1.46O and I was wrong about the mechanism, which is worth stating in case it matters to your testing. Two separate things produce a wrong tag here. Your gate fixes the one where a failed read returns the adjacent bay's stale buffer. The other is physical - the slot pair shares an antenna, so a parked neighbour card genuinely answers and the read genuinely succeeds on the wrong card. We still rotate that neighbour out of the field, and after the flash it still fires, correctly. So the gate shows up as an absence of corrupted payloads on failed reads, not as an absence of our workaround.

Rotisserie: genuinely interested. The spin mode is the clever part - we have humidity-regulated drying, but the spool only ever sits still, and Anycubic's own auto_roll is as ineffective as you describe. Send ace_dryroll.cfg whenever it suits and we will test it here; we have both V1 and V2 units on the bench.

Contributions: the public repo is a published copy of the development tree rather than the place work happens, so pull requests against it would get overwritten on the next publish. The simplest route: keep your work on a branch in your own repo and tell me where to look - I will port it and credit you in the commit. A patch on the issue tracker works just as well for something small. Either way the offer is very welcome, and the generic-CoreXY shims especially: multiACE is written against the Snapmaker U1 fork, so anything that isolates those assumptions makes the project better. You are already credited with a link to your repo in both READMEs since the 1.00b release.

Enjoy the U1 when it arrives. I

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment