Skip to content

Fishing gauge native read - #10

Merged
averagenative merged 8 commits into
mainfrom
fishing-gauge-native-read
Sep 12, 2026
Merged

averagenative merged 8 commits into
mainfrom
fishing-gauge-native-read

Conversation

@averagenative

Copy link
Copy Markdown
Owner

No description provided.

averagenative and others added 8 commits September 10, 2026 12:01
The predicted line dived off the bottom of the screen. Two separate causes,
both of which needed the character to be wearing a gold helmet -- which is why
9384 frames of recorded darts never showed either of them.

findAim averaged EVERY gold pixel in the box around the player to place its
march origin. Measured in the 250x250 native box: the helmet is 261 gold
pixels (h 42.0, s 0.57) against the fletching's 156 (h 46.9, s 0.80), and it
fragments into seven blobs because the sprite's dark outline runs between the
strands. The average therefore landed in the head. The hand search had the
same problem from the other side -- it took the LEFTMOST blob, and the
leftmost helmet strand sits at x=116 where the fletching is at x=142.

Colour cannot separate them: helmets change with gear, so any hue or
saturation window that excludes this helmet is only waiting for the next one.
The separation that holds is structural -- a helmet is worn on the head, the
dart is held at chest height. The leftmost blob still picks the character out
of the scene; we then keep only blobs within a sprite width of it and take the
lowest of those. findAim snaps to the gold blob nearest that pick instead of
averaging over whatever else the character has on.

That was not the whole story. With the origin corrected the line still dived
about a third of the time, because the scan started at -75 degrees, roughly 50
below anything the game can produce. Marching down from the fletching follows
the character's own torso, legs and platform, which is a longer clear run than
the dart ever offers, so whenever the dart read was weak the winner was
whatever angle pointed at the floor. The real sweep was measured from five
independent sources -- four recordings replayed through this same code, and
one live capture:

  2026-08-14        1214px    1032 frames   -25.4 .. +65.3
  2026-07-28 16-43  1312px    2938 frames   -25.4 .. +64.6
  2026-07-28 17-14  1312px    2370 frames   -28.0 .. +65.7
  2026-07-28 19-26  1312px    3044 frames   -25.9 .. +65.0
  live             1327.9px    125 frames   -25.5 .. +64.8

~11,200 accepted aims, and not one below -30. The floor is NOT a tight
constant: four sources cluster at -25.4..-25.9 and the fifth sits 2.6 degrees
lower, so SWEEP_LO is set 12 degrees under the worst observed case rather than
hugging it. Clamping alone only moves the pin from -75 to SWEEP_LO, so a
winner sitting within 5 degrees of the boundary is rejected as well: a real
aim is an interior maximum with reach falling away either side, whereas a
march that ran out of range is still climbing when the scan stops.

Verified live: 1927 board frames, 0 below -30, range -25.5 .. +64.5, against
38 of 125 diving the day before. All four recordings replay identically before
and after, so the clamp costs nothing on legitimate play. The game-over screen
now reads "no dart in hand" and draws nothing, where it previously drew a
confident line into the floor.

aimReach and aimR1 are published on the probe because reach is the value that
says whether the march followed a dart or ran off the end of its own search.
It is deliberately NOT used as a guard: it separated perfectly within one
session (real 83-85.8 against dives at 59.5/73.3/80.2/99.6) but the same
measurement off the recordings spread to 82-100, and normalised by canvas
width the two disagreed by 10%. A window wide enough for both lets the dives
back in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ixels

Two constants in this file were still in absolute pixels, against the standing
rule that calibration is stored as fractions of canvas size. Both were
harvested on a 1326-wide canvas and both misbehave on a 960-wide one.

readMph gated glyphs on n<10, w 3..16, h 8..18. This crop comes out 51px tall
at 1326 and 36px at 960, so every glyph is 28% smaller and the "11" in "11 mph"
measured w=6 h=6 n=16 -- BOTH digits fell through the h<8 floor. The failure is
worse than losing the number: two letterforms out of "mph" (w=7 h=8 n=30, and
w=8 h=13 n=57) sail past the same gates, so the reader goes on to match
leftover letters against digit templates and can return a confident wrong
answer. Earlier cyan winds reading 6mph and 7mph on this canvas are suspect for
that reason, and mph feeds straight into A = windK * mph * W.

The gates and the digit/"mph" gap are now fractions of the crop height against
REF_H=51, the height the templates were harvested at, so at S.h=51 they
reproduce the old constants exactly. Verified live at 960: "3 mph" reads 3 and
"11 mph" reads 11, where 11 returned null before. Single and double digit both
confirmed against the HUD.

findAim's reach floor was 40 css px flat, less than half what a real dart
produces, so it caught almost nothing. On the game-over screen -- no dart in
hand, the march running off a 5-pixel scrap of gold helmet -- reach was 42.9
and the helper drew a confident "+1" from it. The sweep clamp added in 22140b4
does not catch this: the march found a plausible in-range angle rather than
pinning at the boundary, so that screen is NOT fixed by the clamp as the
previous commit's message implies.

The floor must not be set from the minimum reach a recording reports, because
that minimum is an artifact of wherever the floor already sits -- it censors
the tail being measured. Lowering it from 0.05 to 0.040 "discovered" reaches of
54-64 that 0.05 had been hiding, which is circular and nearly shipped a
threshold sitting 0.4px off real data. Measured with the floor disabled, the
distribution is bimodal and the gap is plain (reach in css px, W=1312):

           17-14              19-26
  30-80     32 (2.5%)          51 (5.5%)    sparse scatter
  80-105  1264 (97.5%)        873 (94.5%)   the dart, sharply from 80

2220 accepted frames over two clips, real mode starting at 80 css = 0.0610 W in
both; live agrees at 0.0625-0.0646 W. The no-dart march was 0.0323 W, inside
the scatter. 0.055 sits in the empty region between the modes rather than being
fitted to either edge.

Replayed across all four recordings: the dense mode is preserved whole and only
the sub-mode scatter goes (17-14 1296 -> 1269, 19-26 924 -> 876). Note that
accept counts must be read off aimReach, not aimDeg -- aimDeg persists across
frames when findAim returns null, so it does not measure rejection at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Track thrown dart" has been a checkbox with nothing behind it. dartPts was
declared, cleared once when the screen was gated out, and never written; the
only read was `if (cfg.live && hand && dartPts.length) { /* nothing to do */ }`.
lastDartT, flightWind and flightAim were declared on the same line and never
referenced again. So the probe reported dart:0 for every frame ever recorded,
and the panel offered a feature that did not exist.

It matters because the flight is the only place the model can be checked
against reality. A landing point alone cannot separate vN from gN from landN --
they trade off against each other -- but a tracked flight gives position
against time, which fits speed and gravity directly.

The tracker takes gold blobs from a corridor between the thrower and the board.
The right edge stops short of the board because darts stuck in it keep their
fletchings and would otherwise look like a permanent crowd of candidates:
measured on the live canvas, stuck fletchings sit at css x 1191 against a board
at 1272.6, so 0.08 W clears them. isGold already separates a fletching from the
board's own tan bands -- the fletching palette is h=50 s=0.74 and h=40 s=0.94,
the bands are all s<=0.45 -- so no new colour rule was needed.

A launch is a blob that was not there last frame, which avoids needing to know
where the hand is: the moment the dart leaves, the hand search has no fletching
left and falls back to whatever else the character is wearing. The aim and wind
are captured AT RELEASE and carried with the flight, so a residual no longer
has to be guessed backwards from a landing.

Continuing a track REQUIRES forward progress, not merely "not backwards". There
is no horizontal drag, so a real dart advances the same amount every frame,
always well over the STILL threshold. A first cut accepted same-place matches
and let finished tracks latch onto a stationary fletching forever: flights of
3.2 and 3.7 seconds, and a dart reported airborne for 63% of all frames. With
forward progress required, replayed against two recordings:

  2026-08-14   9 flights   dur 0.133 / 0.767 / 1.267   airborne 31%
  2026-07-28   34 flights  dur 0.200 / 0.800 / 0.967   airborne 53%

none over 2s, where before there were several.

The finished flight is published on the probe with every observed position, and
the observed path is drawn so a wrong track is visible rather than silent.

First fit off 8 flights of the 08-14 clip, which has no wind: |v| clusters at
741-827 px/s (median 781 -> vN 0.643 against the configured 0.548), while g
scatters from 74 to 909 and is not usable -- a t^2 coefficient fitted to ~20
points across a shallow 0.8s arc is badly conditioned. The fitted launch angle
tracks aimAtRelease to a median of 3.6 degrees, which is a decent check on the
aim read. No calibration constant is changed here: that wants many more flights
across all four recordings, and a gN measurement that is actually determined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Darts have been landing above the predicted line for as long as anyone has
looked. It is not vN, not gN, not the wind: findAim reads the dart's visual
axis, and the dart does not fly along it.

Measured against 12 no-wind flights tracked by the code added in 0dd22bf,
fitting position against time and comparing the angle flown to the angle
reported at release:

  aim  4.50 -> flown  9.48   +4.98      aim 21.50 -> flown 25.59   +4.09
  aim 23.50 -> flown 27.61   +4.11      aim 28.09 -> flown 32.69   +4.60
  aim 35.22 -> flown 38.69   +3.47      aim 15.28 -> flown 19.64   +4.36
  aim 21.94 -> flown 25.85   +3.91      aim 22.57 -> flown 27.20   +4.63
  aim 38.00 -> flown 41.27   +3.27      aim 10.97 -> flown 15.35   +4.38
  aim 18.54 -> flown 22.98   +4.44      aim 13.77 -> flown 18.11   +4.34

mean +4.18, sd 0.47, and a slope against aim angle of -0.04 deg/deg: a constant
offset, not a scaling error. Propagated to the board that puts the line 44-60px
below the dart, worse at shallow aims, which matches the residuals measured off
the board directly (-55 to -90px, 22 of 23 negative).

Why it went undetected: the note on findAim claimed "validated against 16 real
throws: r = 0.97 against the launch angle actually flown". r is a CORRELATION
and cannot see a constant offset -- a reading biased by a fixed 4 degrees still
scores 0.97. The validation measured the wrong statistic. The comment now says
so, because the obvious way to re-check this is to compute r again and conclude
everything is fine.

vN and gN are NOT the problem and are left alone. The same 12 flights give
|v| median 734 px/s (sd 6) -> 0.553 against the configured 0.548, and g median
454 px/s^2 (sd 16) -> 0.607 against 0.612. Both inside 1%. An earlier fit off a
recording put vN 17% low; that was 8 sparse flights with a badly conditioned
quadratic, and it was wrong.

landN goes to zero and calVer to 5. That term only ever existed to absorb this
residual -- at -0.023 it was cancelling about a third of the bias -- so with the
angle corrected at source, keeping it would over-correct the other way. Zero
rather than deleted, because a real residual may remain now that the aim is
right; it should be measured off a tracked flight, not fitted through the other
three constants.

AIM_BIAS is the figure measured at the first tracked point. Extrapolating back
to the launch point suggests slightly more (+5.23, sd 0.98), but that rests on
pairing releases to flights by index -- 33 releases against 30 flights -- and is
driven by the rows with the largest inferred gaps. The flight record now carries
its own launch point so the next session measures it directly; refine then.

Replayed against 2026-08-14: 722 accepted aims, unchanged; range moves -25.4..
65.3 to -21.2..69.5, exactly the shift; nothing newly rejected at either end;
flight tracking unaffected at 315 airborne frames.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit set landN to zero on the argument that the term only
existed to absorb the aim bias, and left a note saying a real residual might
remain and should be measured. It has been measured, and there is none.

With the aim correction in place, the shipped predict() was run from each
recorded launch point and compared against every observed position of 19
no-wind flights. 16 of the 19 track the real dart at 1.6-8.3px rms across the
whole arc, and observed minus predicted at the end of tracking averages +0.1px
with sd 11.2. The three that miss start wrong rather than drift wrong: their
launch point was captured far from where the dart was first seen, so they
measure the launch capture and not the flight model.

The same run re-confirms the other constants from 15 clean flights, with the
launch point now recorded in the flight rather than inferred by pairing:

  residual aim bias after the +4.18 correction:  -0.03 deg, sd 0.43
  vN 0.554 (sd 0.002) against 0.548
  gN 0.616 (sd 0.015) against 0.612

so AIM_BIAS measures 4.15 where 4.18 shipped -- inside the noise, left alone.
The +5.23 figure the previous commit warned about was indeed an artifact of
pairing releases to flights by index; flights with a large launch-to-first-seen
gap give +2.44 while the clean ones give -0.03, which is exactly the signature
of a bad extrapolation rather than a real effect.

One trap is written into the comment because it is genuinely misleading:
pairing a landing on the board against "the last prediction before it landed"
still reports a mean residual of -75px with sd 88, even now that the model is
correct. The dart is airborne for about a second while the aim sweep moves on,
so the prediction being compared belongs to a later aim entirely. That number
looks like evidence and is not. Compare against the tracked flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…is its point

Two separate wrongs in readWind, one of them in the comment.

The direction was read off the /scale frame, where the arrow survives as about
47 pixels. That is where its noise came from -- not from the method, which is
what the evidence first suggested. Rotating a captured glyph through a known
sweep and re-reading it at each resolution:

  scale 1  451px   error sd  0.6 deg   worst  1.3
  scale 2  148px   error sd  2.2 deg   worst  7.0
  scale 4   47px   error sd  9.7 deg   worst 22.4   <- what this used
  scale 6   25px   error sd 14.5 deg   worst 40.3

At native resolution the principal axis tracks rotation to about a degree. Live,
the old read swung 57 degrees across resolutions on one unchanged arrow (-25.0,
-48.2, +9.4, -42.8 at scales 1/2/4/6) and disagreed with what was on screen.
Same failure as the fishing gauge in 2232d91 and the mph glyph gates in 28d547b:
a measurement taken through the downscale that only ever needed the full frame.

The comment claimed "its principal axis gives that direction". It does not. The
glyph is a chunky double chevron that narrows at BOTH ends, and its axis of
greatest variance sits at a fixed angle to its point -- the rotation sweep shows
a constant offset of about 45 degrees against the captured frame. So the value
returned is rotation-correct and origin-wrong: differences between readings are
trustworthy, the absolute bearing is not. That is now written down, along with
the fact that anchoring the offset needs one arrow of independently known
direction, probably one per colour, since the magenta glyph is a different
sprite from the cyan one.

This matters beyond the reading itself: every attempt to fit windK's vertical
component or the HV ratio from flight data takes sin(deg) as input, so all of
them were being fed a bearing with an unknown constant error. Measured wind
acceleration per state is consistent in MAGNITUDE (|a|/mph 12-25, median ~18
against the 21.0 that windK 0.0158 implies) and incoherent in DIRECTION (-49 to
+67 deg, uncorrelated with what was read), which is exactly the signature.

Also drops stray pixels hard against the window's left edge before measuring.
A captured mask shows a column of matching pixels many pixels clear of the
glyph; they are far enough out to drag the centroid and the axis with it.

Replayed against 2026-08-14: 722 accepted aims, aim range -21.2..69.5, 315
airborne frames -- identical to before. That clip carries no wind, so it
exercises everything around the change without touching the new path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…f fitting it

Three corrections, all from the game's own flight code rather than from fitting
screen pixels, and all of them things the empirical route could not settle.

MAGENTA WAS NEVER A DIFFERENT KIND OF WIND. The arrow sprite is chosen as
    mag < 10 ? DartWind0 : mag < 18 ? DartWind1 : DartWind2
so the colour is a strength tier and nothing else. Every cyan logged here came
in at 4/6/8/9 mph and every magenta at 10/11/13 -- that boundary exactly. The
`trust = cyan ? 1 : 0` gate was therefore discarding the STRONGEST winds,
modelling a 13 mph crosswind as still air. Measured off 104 tracked flights,
magenta pushes at 18.9-24.9 px/s^2 per mph against cyan's 14.5-20.5: the same
wind, harder.

The unreliable direction read that justified the gate was real but was not about
magenta. It was the downscale and the stray pixels, both fixed in 1fee8d7.
Measured on the sprites themselves, the unrotated arrow's principal axis sits at
+1.43 deg (DartWind0) and +2.13 deg (DartWind1) -- under a degree apart, so
there is no per-colour correction to make. That also retires the "constant 45
degree offset" from the previous commit: it was the captured glyph's own
rotation, not a property of the shape. With strays dropped that capture reads
-45.6, i.e. the arrow pointed at about -47, which is what was on screen.

windK IS NOW DERIVED. The flight step is vx += windX/600, vy += windY/750 at
STEP_SIZE 10ms -- 100 updates a second -- on a 960x540 design canvas. A per-step
bump of k is k*10000 px/s^2, so the vertical term is windY*13.333, and the
displayed mph IS the wind magnitude (the game shows ceil(hypot(windX,windY))).
Scaling to this canvas gives windK = 13.333/960 = 0.01389. The horizontal gives
the same number once HV=1.25 is applied, that ratio being 750/600 and the actual
origin of HV. The old 0.0158 implied 21.0 px/s^2 per mph; the derived value
gives 18.4, against 18 measured off the flights. The old value was ~14% high.

SWEEP_LO GOES TO -50, fixing a bug introduced in 22140b4. The arm sweeps
-20 + (38 + 15t/(t+30))*Trigg(sin, ...) and launches at vy = speed*sin(arm) with
screen y down, so this file's angle is -arm and the true range is -33..+73. Since
AIM_BIAS is added after the scan, a genuine -33 reaches the boundary test at
about -37.2 raw, and the -40 floor rejected anything at or under -35 -- clipping
the bottom of a legitimate sweep. Readings had only reached -28 so it had not
bitten, but it would have on a long run at full amplitude.

Known gap, not fixed here: DartWind2, the >=18 mph sprite, is red-orange and
matches neither hue window, so those winds read as 'none' and are modelled as
still air. Detecting it needs care because the HUD behind it is also brown.

calVer 6 with windK reseeded. Replayed against 2026-08-14: 722 accepted aims,
range -21.2..69.5, 315 airborne frames, all unchanged -- that clip carries no
wind, so it checks the surrounding code without exercising the new paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The note on shotL/shotR called the platform coupling "KNOWN, UNEXPLAINED, and
the biggest error left". It is explainable. The game sets

    platY = 335 + 110 * Trigg('sin', 0, 1.1)
    vy    = -2.9 +  0.7 * Trigg('cos', 0, 1.1)

and Trigg takes the SAME argument for both, so where the platform is and how
hard the ball leaves are one oscillator in quadrature. sin comes from the
platform's height, cos from which way it is travelling.

That accounts for every part of the measurement that looked contradictory.
The coupling is real, which is why corr(platY, shotL) came out -0.79 and -0.86.
But a given height maps to TWO shots, one rising and one falling, so nothing
linear in height can separate them -- and the relationship is not even
monotonic: the shot is at its EXTREMES when the platform is at mid height and
average at the top and bottom of its travel. Over 8 and 5 flights inside one
~5s cycle that looks locally linear and correlates strongly, then fails out of
sample. Which is exactly the 43%-better-on-shotL, 3%-better-at-the-rim split
that was recorded and could not be accounted for.

shotCurve now re-cuts its roots for the vy a particular throw will get.
Curvature is g/2vx^2 and cannot move -- neither g nor vx sees the oscillator --
so only the launch slope changes, by d(vy/vx) = 0.7*cos/3.9. The release point
stays where the shipped constants put it, so cos=0 reproduces the old curve to
the pixel: this can only add variation that was missing, never shift the
average. Across the cycle it moves the predicted landing by +-6.5% of screen
width.

The correction is gated until most of one platform swing has been observed,
because the midpoint is a guess before that and a wrong midpoint is worse than
no correction. Near the turning points the direction of travel cannot be read,
so it falls back to cos=0 -- which is also where cos really is near zero, so
that failure is self-limiting.

shotA's seed becomes 2.177, derived as 0.069/(2*3.9^2)*960 rather than fitted.
The old 2.233 came off 13 flights (sd 0.034, range 2.195..2.288) and sits just
outside that, so it is a systematic 2.6% and not noise -- same direction and
size as the tracking bias found in the darts helper. Self-calibration still
runs; this only moves where a fresh install starts.

NOT VALIDATED AGAINST FLIGHTS. Two checks were run and both turned out to be
tautological -- cos is computed from platY, so "cos^2 + sin^2 = 1" and "|cos|
peaks at mid height" are true by construction and confirm the arithmetic only.
The real test is whether each shot's fitted R tracks the cos it was thrown on,
and each flight's fit is now published on the probe for exactly that. It could
not be run here: the one hoops recording available has 655 airborne frames but
never commits a per-flight fit, so there was nothing to correlate. What is
claimed is that the physics is the game's own and that cos=0 is unchanged;
what is not claimed is that it measurably improves a real shot yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@averagenative averagenative self-assigned this Sep 12, 2026
@averagenative
averagenative merged commit b2408f7 into main Sep 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant