S620 drive-burst firmware
Raises the GAOMON S620's report rate past the ~530 Hz ceiling that settle-timing patches run into, by shortening the pen excitation drive burst instead.
Measured 610 Hz on OEM02_T18e_241030 at the same position noise as the factory firmware, with the full 8192-level pressure range intact.
Two tablets have been flashed, on different bootloader revisions. Every rate and noise figure below comes from the first of them.
Before you start: already on custom firmware?
Go back to stock first. This applies to dumping as well as flashing.
Patches are built from the factory bytes, so a dump taken from a tablet that already runs custom firmware records the modified image and cannot be used as a patch source. The tool checks the application hash and refuses anything that is not the factory build, which is a guard rather than an inconvenience: applying these patches on top of someone else's is untested and the failure modes are not understood.
Two different approaches exist for this tablet and they do not compose. Some tools rewrite instructions in place, which is what this one does. Others install a runtime into the extension slot at 0x0800cc00 and redirect calls in the application into it, leaving the original instructions untouched. A tablet carrying one will fail this tool's preimage checks, and the log will say which of the two it found.
Restore with whichever tool made the change, confirm this tool reports OEM02_T18e_241030, then save that backup. It is the only pristine copy you will get, and it cannot be recovered from a modified device.
The finding
The S620's report rate is not limited by USB polling. It is limited by the firmware's acquisition loop:
period = 1838.5 us fixed + 6.978 x settle_sum
Published S620 patches shorten the four analog settle waits, which are four MOVS R0,#imm8 immediates. That works until about 530 Hz, at which point 97.8% of the report period is fixed work and there is nothing left to shorten.
Disassembling the fixed path shows where it actually goes. Each report performs 17 measurements, split across three adaptive scan windows:
| function | loop bound | measurements |
|---|---|---|
0x08006cb4 | cmp r5,#5 | 5 |
0x08006fd0 | cmp r4,#6 | 6 |
0x080070b8 | cmp r4,#6 | 6 |
Every measurement excites the pen's LC tank and then reads it back. The excitation is twelve near-identical routines between 0x08008f44 and 0x08009dfa, each bit-banging a square wave on PA15 through GPIOA_BSRR/BRR at 0x48000000. They differ only in NOP-sled length, giving twelve drive frequencies from roughly 456 kHz to 554 kHz, which brackets the pen's resonance.
Each one opens with the number of drive periods to emit:
0x08008f44 push {r4, lr}
0x08008f46 movs r4, #29 <- drive periods
...
loop:
str #0x8000 -> [0x48000000+0x28] ; BRR, pin 15 low
75 x NOP
str #0x8000 -> [0x48000000+0x18] ; BSRR, pin 15 high
71 x NOP
subs / sub / uxtb / bne -> loop
At 72 MHz those bursts are about 979 us of the 1838 us fixed path, roughly 53% of it. The drive period count is therefore the largest single component of the fixed path, and changing it means twelve one-byte immediates. No loop bounds, recentre offsets or edge tests are involved.
Why 72 MHz
The clock is not stated anywhere in the application. The PLL configuration at 0x080017d4 gives it:
orr r2,#0x10000 ; HXTALEN, wait HXTALSTB
and r2, 0x77c2ffff ; clear PLLSEL and PLLMF
orr r2,#0x110000 ; PLLSEL = HXTAL, PLLMF = x6
orr r2,#0x1000000 ; PLLEN, wait PLLSTB
bic #3 ; orr #2 ; SCS = PLL
PLL is HXTAL x6, and rcu_deinit leaves USBFSPSC at 00 = divide by 1.5, which must produce exactly 48 MHz for USB. So SYSCLK is 72 MHz and HXTAL is 12 MHz.
Budget
Derived from NOP counts, the PLL configuration and the ADC sample-time register (index 5 = 55.5 cycles, plus 12.5 conversion, at ADCCLK 18 MHz):
| component | per report |
|---|---|
| excitation, 17 x 57.6 us | 979 us |
| ADC, 17 x 3 x 3.8 us | 193 us |
| coordinate maths, packing, USB submit | ~666 us |
| total | 1838 us |
That reproduces the empirically measured 1838.5 us. As a cross-check, the model predicts 536 Hz for a settle-only build where the published work measured 530.
The tool uses a refitted form, because the four settle sites do not fire equally often and a single sum cannot express that:
period_us = 1006 + 27 * drive_periods + sum(settle[i] * w[i]), w = [17, 2, 17, 4]
The weights are firings per report. This reproduces the stock, 626 Hz and 684 Hz builds within 0.2%. All three of those were recorded with OpenTabletDriver running though, and the one recording made without it came in 2.6% above the model, with no single set of weights fitting both groups. Treat a modelled rate as a floor.
Measurements
Host-observed from real HID report intervals. Noise is detrended using second differences so it can be measured with the pen moving, which matters because a stationary pen makes the tablet report more slowly than it sustains in use.
| build | rate | noise X | noise Y | pressure max |
|---|---|---|---|---|
| 29 periods, stock settle | 293.7 Hz | 2.66 | 4.21 | - |
| 29 periods, factory reflash control | 293.0 Hz | 3.86 | 4.36 | - |
| 20 periods, 1 us pre-amp settle | 626.4 Hz | 10.2 | 5.2 | 7988 / 8191 |
| 20 periods, 6 us pre-amp settle | 610.2 Hz | 3.63 | 3.63 | 7988 / 8191 |
| 15 periods, 1 us pre-amp settle | 684.4 Hz | 17.1 | 19.4 | 8191 / 8191 |
Noise is in raw tablet units. The tablet is 33020 units across 165.1 mm, so 10 units is about 2 px on a 50 mm mapped area and about 0.6 px on full area.
The shipped build measures inside the range the factory firmware itself produced, 2.66 to 4.36, so the rate is not being paid for with jitter.
The noise was the settle, not the drive burst
Earlier builds blamed position noise on reduced drive energy. That was wrong. Three of the four settle waits were driven to 1 us, and one of them is the analog pre-amplifier settling window that follows PA5 closing the switch between the antenna and the ADC. Sampling before it settles is what produced the jitter.
Restoring it to 6 us, a fifth of stock, costs about 16 Hz and returns the noise to factory levels while keeping 20 drive periods. Pressure holds at full range even at 15 periods, so tank saturation was never the limit either.
One caveat on how these were measured. Every run except the 610 Hz one was recorded with OpenTabletDriver running, which drops roughly 7% of reports. Missing reports inflate a detrended noise estimate, because the estimate assumes evenly spaced samples. The comparison still holds, because the stock rows were recorded under the same conditions as the noisy 626 Hz row and came out clean, so the gap between them is real rather than an artifact.
Run-to-run variation on identical firmware was about +/-1.2 noise units, so smaller differences than that are not signal.
Use a rear motherboard USB port
Through a USB hub, this bootloader's DFU reads corrupt in ways that pass every obvious sanity check. Observed failures included images shifted by exactly 128 bytes, individual blocks returning data belonging to other addresses, two full reads of the same flash disagreeing with each other, and repeated dfuERROR states. Drawing continues to work perfectly throughout, which is why it looks like broken firmware.
Moving from two hub levels to a direct rear-panel port fixed every one of those symptoms immediately, with no code changes.
If the tool reports transferSize 1024 instead of 2048 after connecting, the link came up desynced. Replug and reconnect until it reads 2048 before doing anything else.
Safety model
- The bootloader at
0x08000000-0x08004000and the calibration page at0x0800fc00are never written. Both are enforced inassertWritable. - Every patch site is checked against its expected bytes before the image is built, so a firmware build this patch was not derived from is refused rather than corrupted.
- Backup reads 64 KB twice and requires the passes to agree byte for byte.
- Erase is followed by a read-back proving the whole span is
0xFFbefore anything is written. Some GD32 bootloader revisions acknowledge an erase and leave the page untouched. - After writing, every byte is read back and compared.
- Verification reads retry on mismatch. This cannot launder a bad write into a pass: a stale read carries some other address's bytes and will not coincidentally equal the expectation.
- A settle sum below 6 us is refused. Such an image flashes and verifies perfectly and then never enumerates on USB.
Recovery is always available because the DFU bootloader is entered by holding the outer two express keys while plugging in, which is independent of whatever the application firmware does.
Bootloader quirks
These matter if you change the transport code:
- Its control pipe returns the *previous* reply when a request follows too closely, so every transfer is spaced by 25 ms. Without that spacing a
GETSTATUSanswer surfaces as the result of the nextUPLOAD. - It reports
dfuIDLE(2)ordfuUPLOAD-IDLE(9)afterSET_ADDRESSrather thandfuDNLOAD-IDLE(5), so that state check is advisory here. - Its LANGID string descriptor is malformed, so libusb cannot read the alt-setting memory layout and
dfu-utilrefuses to run at all. WebUSB does not need it. - Setting the address pointer once and incrementing the block number, the usual DfuSe sequential read, silently produces a 128-byte shifted image. Every block restates its address.
Running it
The page, dfu.js and s620.js are static. No build step, no dependencies. They need HTTPS or localhost, because WebUSB requires a secure context.
Any static host works:
python -m http.server 8080
Chrome or Edge. Firefox and Safari do not implement WebUSB.
Hosting
Four static files with no build step, so any static host works. WebUSB needs a secure context, meaning HTTPS or localhost. Plain HTTP on an IP address will not offer the device picker at all.
server.py is a standard-library static server for local use:
HOST=0.0.0.0 PORT=8080 python server.py
For a VPS, put nginx or Caddy in front for TLS rather than exposing the Python server directly.
Flash counter
counter.py is a standard-library service on loopback that keeps two integers, verified and failed, in $STATE_DIRECTORY/flashes.json. GET /api/flashes returns them and the page posts to it after each flash. Put it behind the same site:
handle /api/* {
reverse_proxy 127.0.0.1:8931
}
It runs under systemd as a DynamicUser with a StateDirectory, so it owns no path of its own and IPAddressDeny=any keeps it on loopback.
Two things it deliberately is not. It takes an unauthenticated POST, because the page has no accounts and collects nothing identifying, so treat the number as a rough indicator rather than a statistic; a per-IP cooldown of 60 seconds stops casual spam and nothing more. And it stores only two integers: no addresses, no times, no device data.
The page works without it. Every call is wrapped so a counter that is down cannot affect a flash, and the shared line simply stays hidden.
Separately, the page keeps a per-browser history in localStorage, exportable as JSON from the Log card. That never leaves the browser.
Credit
The drive-burst finding, the timing decomposition, the 72 MHz derivation, the tracker and dispatch analysis, the measurement methodology and this tool are by miku (Discord hs.ypp), from reverse engineering OEM02_T18e_241030.
The settle immediates, the filter-bypass site and the barrel-rescan sites are from the published S620 work by catears124, MIT licensed.
No warranty. You are writing firmware to your own hardware.