Een boer die nooit slaapt: hoe ik Hay Day automatiseer met ADB en OpenCV

Een blik onder de motorkap van mijn Hay Day-bot: waarom ik input swipe overboord gooide voor rauwe sendevent-events, en de vuile trucs die het inzaaien vloeiend maken.

Delen

Ik heb een bot geschreven die in Hay Day een akker inzaait zonder dat ik een vinger uitsteek. Geen mouse-hijacking, geen gepixel-peuter op het Windows-bureaublad: alles loopt puur via ADB naar een MEmu-emulator, en de akker wordt met OpenCV opgespoord. Klinkt simpel. Was het niet.

Dit is geen tutorial met een happy path. Dit is het verhaal van de drie plekken waar de voor de hand liggende oplossing kapotging, en de workarounds die het uiteindelijk vloeiend maakten.

Het bot-recept in het kort

De pipeline is rechttoe rechtaan:

  1. Screenshot pakken via ADB (screencap).
  2. Met OpenCV de grond detecteren op kleur.
  3. Het midden aantikken zodat de camera scherpstelt.
  4. De tarwe-tool oppakken (long-press) en in een zigzag over het veld slepen.

Stap 4 is waar het interessant wordt. Een veld inzaaien is niet één tik, het is één lange, ononderbroken veeg die over elk stukje aarde gaat. En daar liep ik meteen tegen de eerste muur.

Workaround 1: input swipe gooien voor rauwe evdev-events

De standaardmanier om in Android een veeg te sturen is adb shell input swipe. Prima voor één veeg. Maar een zaaipad bestaat uit tientallen segmenten, en elke input swipe-aanroep is een compléét gebaar op zichzelf: pen neer, bewegen, pen omhoog. Ketting je die aan elkaar, dan tilt de vinger tussen elk segment op en verankert het touch-punt opnieuw. Het resultaat: gehaperde, gebroken veegjes en een half ingezaaid veld.

De oplossing is een niveau dieper duiken, naar de Linux evdev-laag waar Android z'n touch-input vandaan haalt. Met het sendevent-commando schrijf je rauwe input-events rechtstreeks naar /dev/input/eventN. Door één keer DOWN te sturen, dan een reeks MOVE-events, en pas aan het einde UP, krijg je één echt ononderbroken gebaar.

Het protocol is Type B (slot-based multitouch). Zo ziet een touch-DOWN eruit als losse events:

# ── touch DOWN ───────────────────────────────────────────────────────
hx0, hy0 = self._to_hw(*android_points[0])
lines += [
    self._se(EV_ABS, ABS_MT_SLOT,         0),   # slot 0 = eerste vinger
    self._se(EV_ABS, ABS_MT_TRACKING_ID,  1),   # positief id = vinger aanwezig
    self._se(EV_ABS, ABS_MT_POSITION_X,   hx0),
    self._se(EV_ABS, ABS_MT_POSITION_Y,   hy0),
]
if self.pressure:
    lines.append(self._se(EV_ABS, ABS_MT_PRESSURE, self.pressure))  # veel devices eisen druk > 0
lines += [
    self._se(EV_KEY, BTN_TOUCH,            1),
    self._se(EV_ABS, ABS_X,                hx0),  # single-touch compat-laag
    self._se(EV_ABS, ABS_Y,                hy0),
    self._syn(),                                  # commit dit frame
]

Elke sendevent-regel is letterlijk één EV_ABS/EV_KEY/EV_SYN-tripel. De _syn() aan het eind is cruciaal: zonder SYN_REPORT weet de kernel niet dat het frame compleet is en gebeurt er niets.

Een paar valkuilen die ik onderweg tegenkwam:

  • ABS_MT_PRESSURE moet vaak > 0 zijn, anders registreert het device de aanraking niet.
  • De vinger optillen doe je met een unsigned -1 als tracking-id, oftewel 4294967295. Dat is de evdev-conventie voor "slot leeg".
  • ABS_X/ABS_Y (de oude single-touch compat-events) heb je alleen bij touch-down nodig. Ze meesturen bij elke MOVE verdubbelt het aantal sendevent-aanroepen voor niks. Weglaten halveert de scripttijd:
# Alleen ABS_MT_POSITION_X/Y + SYN_REPORT per move (3 calls).
# ABS_X/ABS_Y zijn compat-events die enkel bij touch-down nodig zijn;
# ze weglaten uit moves halveert het aantal sendevent-calls.
for ax, ay in android_points[1:]:
    hx, hy = self._to_hw(ax, ay)
    if hx == prev_hx and hy == prev_hy:
        continue  # sla duplicaten over, scheelt weer calls
    lines += [
        self._se(EV_ABS, ABS_MT_POSITION_X, hx),
        self._se(EV_ABS, ABS_MT_POSITION_Y, hy),
        self._syn(),
    ]
    if delay > 0:
        lines.append(f"sleep {delay:.4f}")
    prev_hx, prev_hy = hx, hy

Waarom een shellscript, geen losse ADB-calls

Elke sendevent los via ADB sturen zou honderden round-trips betekenen: traag en met wispelturige timing. Voor een groot pad is het script al gauw honderden regels. Die hele reeks over de commandline meegeven knalt tegen ARG_MAX aan.

De workaround: schrijf het volledige script naar een bestand, push het naar het device en draai het daar met één sh-aanroep. Alle timing gebeurt op het device zelf, precies zoals bedoeld.

# Push script naar device (voorkomt ARG_MAX-limiet, minimaliseert round-trips)
self.adb.run("-s", self.adb.device_address, "push", local_tmp, REMOTE_SCRIPT_PATH)
...
self.adb.shell("sh", REMOTE_SCRIPT_PATH, check=False, timeout=timeout)

De timeout schat ik vooraf: elke sendevent kost ongeveer 6 ms omdat het een child-proces spawnt op Android, plus alle sleep-tijd, plus wat marge.

De long-press-hack om items op te pakken

In Hay Day pak je gereedschap op met een lange druk, niet met een tik. Ik simuleer dat door na de touch-DOWN, maar vóór de eerste beweging, gewoon even stil te blijven staan:

# ── hold at start (long-press om item op te pakken) ────────────────
lines.append(f"sleep {hold_duration:.3f}")   # ~0.8s zodat Hay Day de pick-up registreert

Geen aparte long-press-call nodig; het is gewoon een sleep middenin het ene grote gebaar.

Workaround 2: van rommelige kleurmask naar strak veld

De akker vinden gebeurt op kleur. Ik neem twee sample-afbeeldingen van grond, bereken hun gemiddelde kleur, en maak een masker van alle pixels die daar dichtbij liggen. Twee maskers, want grond ziet er niet overal hetzelfde uit:

diff1 = np.abs(screen - self.template1_color)
mask1 = (np.mean(diff1, axis=2) < self.color_threshold).astype(np.uint8) * 255

diff2 = np.abs(screen - self.template2_color)
mask2 = (np.mean(diff2, axis=2) < self.color_threshold).astype(np.uint8) * 255

current_mask = cv2.bitwise_or(mask1, mask2)   # lijkt op grond A OF grond B

Het ruwe masker is een gatenkaas: de voren tussen de gewassen breken het beeld op in losse horizontale strepen. Een morphological close met een 15x15-kernel plakt die strepen aan elkaar tot één vlak:

kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15))
current_mask = cv2.morphologyEx(current_mask, cv2.MORPH_CLOSE, kernel)

Daarna filter ik contouren op minimale oppervlakte (5000 px) om sneeuwvlekjes en ruis weg te gooien, en pak ik de grootste. Het echte trucje zit in minAreaRect: die tekent de kleinste geroteerde rechthoek rond de contour, en vult zo ontbrekende hoeken vanzelf in tot de echte veldvorm.

De bug die het halve scherm als "veld" markeerde

Hier zat een gemene fout. Mijn eerste getSoilBounds berekende de grenzen uit het volledige masker. Probleem: één verdwaalde false-positive-pixel helemaal links en één rechts, en ineens strekt de bounding box zich uit over de hele schermbreedte. Het zaaipad probeerde dan half het scherm te bezaaien.

De workaround: bereken de bounds niet uit het masker, maar uit de grootste geldige contour. Ruispixels vallen dan buiten de contour en tellen niet mee.

def getSoilBounds(self, screen):
    """
    Gebruikt de bounding rect van de grootste geldige contour, NIET het volle masker.
    Het volle masker liet losse false-positive-pixels aan de schermranden
    de min_x/max_x oprekken tot de volledige schermbreedte.
    """
    _, mask, center, _ = self.detect(screen)
    if mask is None or center is None:
        return None

    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    valid = [c for c in contours if cv2.contourArea(c) > self.min_contour_area]
    if not valid:
        return None

    largest = max(valid, key=cv2.contourArea)
    x, y, w, h = cv2.boundingRect(largest)
    return (x, y, x + w, y + h, center[0], center[1])

Een klein detail, maar het verschil tussen een net ingezaaid veld en een bot die willekeurig over het scherm klodert.

Workaround 3: een zigzag zonder scherpe hoeken

Met het veld in kaart gebracht, moet er een pad komen dat elke plek raakt zonder de vinger op te tillen. De naïeve aanpak, links-rechts-links met scherpe hoeken op de rand, hapert: bij een 90-graden-knik verliest het spel het gebaar soms.

Mijn oplossing is een raster van horizontale rijen, maar op elke rand maakt de vinger een strakke halve cirkel naar de volgende rij. Geen scherpe hoek, geen verspilde beweging, en de bochten bulgen net büiten het veld (het spel negeert aanrakingen buiten de grond, dus dat mag):

def right_uturn(x_edge, y_top):
    """
    Halve cirkel aan de RECHTERrand:
      (x_edge, y_top) -> boog naar rechts -> (x_edge, y_top + 2r)
    Centrum = (x_edge, y_top + r), straal = r, met de klok mee.
    """
    cy_t = y_top + r
    n    = max(8, int(math.pi * r / point_spacing))
    return [(int(x_edge + r * math.cos(-math.pi/2 + math.pi*i/n)),
             int(cy_t   + r * math.sin(-math.pi/2 + math.pi*i/n)))
            for i in range(n + 1)]

De straal van de U-bocht is precies de helft van de rij-afstand (r = spacing // 2). Daardoor overlapt elke rij de bocht van de vorige met exact de bochtstraal, en wordt er nooit een strook overgeslagen. De rijen zelf klem ik binnen de veldgrenzen, alleen de U-bochten mogen naar buiten steken:

going_right = True
y = min_y
while y <= max_y:
    if going_right:
        pts += row(min_x, max_x, y)
        next_y = y + spacing
        if next_y <= max_y:
            pts += right_uturn(max_x, y)   # verbindt rij y met rij y+spacing
    else:
        pts += row(max_x, min_x, y)
        next_y = y + spacing
        if next_y <= max_y:
            pts += left_uturn(min_x, y)
    if next_y > max_y:
        break
    y = next_y
    going_right = not going_right

Bonus: het venster dat nooit bevriest

Een debug-viewer die vastloopt is nutteloos. Al het ADB-werk (screenshots, taps, de trage drag) draait daarom in een aparte thread; de main-thread doet niets anders dan de cv2-venster-loop en blijft altijd responsief.

"""
Bot thread  — al het ADB-werk (screenshots, tap, drag)
Main thread — cv2 venster-loop, blokkeert nooit, altijd responsief
"""
...
t = threading.Thread(target=bot_main, args=(view,), daemon=True)
t.start()
while t.is_alive():
    view.tick()   # main-thread pompt het venster

cv2's GUI-loop moet op de main-thread leven, dus die keuze was niet vrijblijvend: het is de enige indeling die zowel een live overlay als een niet-blokkerende bot geeft.

Wat ik ervan leer

De rode draad: de voor de hand liggende API is vaak te grofkorrelig. input swipe kan geen ononderbroken gebaar. Een kleurmasker kan geen veld afbakenen. Een scherpe hoek kan het spel in de war brengen. Telkens was de oplossing om een laag dieper te gaan (rauwe evdev-events, contour-bounds, meetkundige bochten) en de ruwe rand glad te schuren met een gerichte workaround.

Het is nog een prototype: oogsten zit er nog niet in, en de detector is gevoelig voor hoe de grond eruitziet. Maar het zaait, en het zaait vloeiend. En het interessantste zit niet in de bot zelf, maar in wat je eronder tegenkomt zodra je één laag dieper moet.

Tags

hay day bot, android automatisering, adb, sendevent, evdev, opencv, computer vision, memu emulator, python automatisering, touch injection, reverse engineering, game bot

Neem contact op