Close

Four bugs that all looked like bad WiFi

A project log for The Arcade Cabinet That Runs Nothing

An arcade cabinet built around an M5Stack AtomS3R that plays 27 DOS games and DOOM, and runs a demo reel of 41 titles including full-motion

juha-liljaJuha Lilja 2 hours ago0 Comments

It worked, and then it didn't. Sometimes smooth, sometimes frozen for half a second. Sometimes it jumped forward and I walked into a wall. Every one of these looked like a WiFi problem. None of them were the WiFi.

1. Two animated blocks, the rest of the screen frozen. The encoder was diffing each new frame against the last frame it had encoded. Over UDP that's not the same as what the device has on screen. One lost update and those pixels are stale forever, because the server believes it already sent them. Only tiles that keep changing repair themselves, which on the device looked like two blinking lights animating on a frozen DOOM level for thirty seconds at a time.

The fix was free: every input packet already carries last_frame_id, so the server diffs against the last frame the device acknowledged.

def on_ack(self, frame_id):    if frame_id in self.pending:        self.ref_frame = self.pending.pop(frame_id)    for fid in [f for f in self.pending if not newer(f, frame_id)]:        del self.pending[fid]

The regression test drops a quarter of all packets and checks that the screen converges. The first version of that test passed against deliberately broken code, because the periodic keyframe repaired the damage every 3 seconds and hid the bug. Keyframes are now switched off for that check.

2. frags 12, lost 12. The device's diagnostics line said every fragmented update was lost and every single-datagram update arrived. The AtomS3R's UDP socket holds one datagram. Send two back to back and the second lands microseconds later with nowhere to go. So fragments are paced 3 ms apart:

def pump_tx(self, now):    if not self.tx_queue or not self.client:        return    if now - self.last_frag_ms < self.cfg.frag_gap_ms:        return    pkt = self.tx_queue.pop(0)    self.sock.sendto(pkt, self.client.addr)    self.last_frag_ms = now

On top of that there's flow control: no more than 2 unacknowledged updates in flight, and the ack timeout is 3x the smoothed round-trip time with exponential backoff. The frame rate settles at what the device can actually absorb instead of what --fps asks for.

3. Smooth for a second, frozen for half a second. ESP32 WiFi modem sleep. By default the radio sleeps between DTIM beacons, so a downstream packet can wait 100-300 ms for the radio to wake up. That's the whole frame budget several times over, and it's most visible when the picture is nearly static.

try:    wlan.config(pm=network.WLAN.PM_NONE)
except Exception:    wlan.config(ps_mode=network.WIFI_PS_NONE)

4. 'socket' object has no attribute 'recv_into'. UIFlow2's socket doesn't have it. Other MicroPython builds do, some only have the stream readinto(). So the firmware probes and keeps the first one that exists: recv_into, then readinto, then recv, then recvfrom. It prints what it picked. Also, readinto() reports "no data" as None instead of 0, which made the first version loop forever.

The drawing side is short. drawJpg takes a memoryview slice, so a JPEG goes from the receive buffer to the decoder without a copy:

def draw_rect(self, x, y, w, h, enc, off, n):    if enc == ENC_JPEG:        M5.Display.drawJpg(self.mv[off:off + n], x, y)    elif enc == ENC_SOLID:        v = (self.buf[off] << 8) | self.buf[off + 1]        rgb = ((((v >> 11) & 0x1F) << 19) |               (((v >> 5) & 0x3F) << 10) | ((v & 0x1F) << 3))        M5.Display.fillRect(x, y, w, h, rgb)    elif enc == ENC_RAW565:        M5.Display.drawRawBuf(self.mv[off:off + n], x, y, w, h, n, False)

The whole firmware also runs on the PC with M5GFX, the IMU and time.ticks_* stubbed out, drawing into a PNG. That's how I debugged the protocol without flashing anything: every line of packet parsing is the firmware's own.

Discussions