Skip to content

SWI 0x0A — ArcTan2

  • Entry: 0x000004FC (THUMB; real address 0x4FC, entry word at odd 0x4FD)
  • Status: verified (hardware-checked 2026-07-08: cycle counts + 393,216-point octant-boundary sweep matching the documented algorithm bit-for-bit)

Summary

Full 360° arctangent of a vector (x, y). Returns an unsigned angle in the range 0x0000..0xFFFF (0x10000 = 360°, counter-clockwise, 0 = +X axis). It selects an octant from the signs and relative magnitudes of x and y, computes a ratio whose magnitude is guaranteed <= 1 (so SWI 0x09 ArcTan stays in its accurate range), calls ArcTan, and adds the correct quadrant/octant offset.

Parameters

Reg In Meaning
r0 s32 X component
r1 s32 Y component

Returns

Reg Out Meaning
r0 u16 Angle 0x0000..0xFFFF, where 0x4000=90°, 0x8000=180°, 0xC000=270°

Clobbered registers

r2–r7 are used as scratch (pushed/popped). r1 is destroyed.

Hardware audit (2026-07-08, canary r4–r12 + CPSR snapshot): caller-visible clobbers: r0 = angle; r1 is clobbered on the general path but untouched on the axis fast path; r3 = 0x170 leftover; r2 preserved. r2, r4–r12, r13, and CPSR (flags and mode) came back bit-identical on every tested path. r11/r12 are explained by the SWI dispatcher (it pushes {r11, r12, lr} — see 10_irq_boot_and_iwram.md) and CPSR by the SPSR restore on return; r2 and r4–r10 must be preserved or restored by the routine itself — where a static note above claims r2 is destroyed, the hardware disagrees at the caller level. Raw data: results/clobber_audit_pass2.csv.

Algorithm

Axis special cases (handled first, no division): - y == 0: return 0x0000 if x >= 0, else 0x8000 (180°). - x == 0 (and y != 0): return 0x4000 (90°) if y >= 0, else 0xC000 (270°).

General case: precompute x<<14, y<<14, -x, -y, and the offset constants 0x4000 (90°) and 0x8000 (180°). Then, based on the signs of x and y and the comparison of |x| vs |y|, pick one of eight octants. In every octant the code divides the smaller-magnitude axis (shifted left 14) by the larger-magnitude axis using SWI 0x06 Div, so the quotient fed to ArcTan has magnitude <= 1.0 in Q1.14. It then calls ArcTan and combines (hardware-exact — comparison operators and formulas below reproduce the boundary sweep bit-for-bit; the axis cases y==0 / x==0 never reach here):

Octant (sign x, sign y, magnitude test) Ratio passed to ArcTan Result formula
x≥0, y>0, x ≥ y (y<<14)/x A
x≥0, y>0, x < y (x<<14)/y 0x4000 − A
x<0, y>0, −x < y (x<<14)/y 0x4000 − A
x<0, y>0, −x ≥ y (y<<14)/x 0x8000 + A
x<0, y<0, −x > −y (y<<14)/x 0x8000 + A
x<0, y<0, −x ≤ −y (x<<14)/y 0xC000 − A
x>0, y<0, x ≤ −y (x<<14)/y 0xC000 − A
x>0, y<0, x > −y (y<<14)/x 0x10000 + A (wraps to low bits)

where A = ArcTan(ratio) (signed). 0xC000 = 0x4000 + 0x8000; the last row uses 0x8000<<1 = 0x10000 with a negative A, whose low 16 bits give the correct near-360° angle. The final result is masked to 16 bits.

An earlier draft of this table (from static analysis alone) had the (x>0, y<0, |x|>|y|) and (x<0, y<0, |x|≥|y|) formulas swapped (0x8000 + A0x10000 + A); the hardware sweep settled it as above. Note that on the SW diagonal x == y < 0 the two adjacent formulas coincide (0x8000 + 0x2000 = 0xC000 − 0x2000 = 0xA000), so the boundary assignment there is unobservable from outputs.

A reference implementation of this dispatch (+ Div truncation toward zero + the bit-exact ArcTan model) reproduces all 393,216 boundary-sweep points with zero mismatches (tools/analyze_boundary.py, results/arctan2_boundary_dump.csv).

Note: the internal Div/ArcTan calls are reached through tiny THUMB→ARM trampoline veneers (add r3,pc / bx r3) at 0x3A4 (→ Div at 0x3B4) and 0x470 (→ ArcTan at 0x474), since ArcTan2 is THUMB while Div/ArcTan are ARM.

Edge cases & known bugs

  • Verified against atan2: (1,1)→0x2000 (45°), (-1,1)→0x6000 (135°), (-1,-1)→0xA000 (225°), (1,-1)→0xE000 (315°), (100,50)→0x12E4 (26.56°), (50,100)→0x2D1C (63.44°). Axis cases (1,0)→0, (-1,0)→0x8000, (0,1)→0x4000, (0,-1)→0xC000.
  • Boundary sweep (hardware, all magnitudes k=1..0x7FFF/−0x8000..−1): every diagonal ray is exactly constant — (k,k)→0x2000, (−k,−k)→0xA000, (k,−k)→0xE000, (−k,k)→0x6000 — and both axes are exactly constant ((k,0)→0, (−k,0)→0x8000, (0,k)→0x4000, (0,−k)→0xC000). One step off the diagonal, (k,k+1)→0x2001 and (k,k−1)→0x1FFF for every k ≥ 0x4000 (a single output unit; below that the Div truncation makes the offset slightly larger).
  • (0,0) returns 0x0000 (hardware-verified): the y==0 axis path is taken before the x==0 branch, and it treats x=0 as non-negative. (Earlier draft predicted 0x4000; real hardware returns 0.)
  • By always dividing the larger axis by the smaller, ArcTan's |input|>1 divergence is never triggered, so ArcTan2 stays accurate in all quadrants.
  • Small residual error comes entirely from ArcTan's approximation and from the integer Div truncation of the ratio (loses fractional precision when |x|,|y| are small).

Cycle count

Hardware-measured net CPU cycles (worker-ROM harness, TM0/TM1 cascade at F/1, 13-cycle baseline subtracted; identical across 3 runs — see 02_hardware_verification_checklist.md § Measurement setup).

Input (x, y) Cycles Result
(0, 0) 73 0x0000
(0x4000, 0) 73 0x0000
(0, 0x4000) 78 0x4000
(0x1000, 0x3000) 342 0x32E5
(0x4000, 0x4000) 361 0x2000
(-1, -1) 364 0xA000

Axis cases return almost immediately; the general path (one Div + one ArcTan plus octant fix-up) costs 342–364.

Open questions (need hardware verification)

None. The 2026-07-08 boundary sweep (diagonals, axes, diagonal±1 — 393,216 points) matches the documented dispatch bit-for-bit, resolving (0,0) (returns 0x0000), the octant formula assignment, and the boundary comparison operators.

GBATEK cross-reference

Agrees with GBATEK (0..0xFFFF output, 0x4000=90°). Adds the explicit octant selection table, the "divide larger by smaller" trick that keeps ArcTan in range, and the axis special-case results including (0,0).