From fd548751a294d8ca7dac3cd568cd5f8de5de8f6f Mon Sep 17 00:00:00 2001 From: averagenative Date: Tue, 1 Sep 2026 19:00:16 -0400 Subject: [PATCH 1/5] Also record that the correlation behind it is not stable Two more captures in, the platform-height coupling that motivates all of this is itself unreliable. Across three captures it measures -0.13, -0.46 and -0.82; within the two halves of the third, -0.81 and -0.96. That is not one effect measured three times, it is what a correlation looks like at n = 6 to 12. Curvature is the one parameter the anchor cannot touch mathematically -- shotA is a*W straight off the fit, platform position never enters it -- so a coupling there is either real physics or estimator bias. The time domain rules out the first: across those flights vx holds to 1.3%, and neither vx nor fitted g tracks platform height, -0.11 and -0.14. The physical curvature g/2vx^2 is constant, so the wobble is noise in medA. A slope cap was tried on the strength of it -- refit y(x) using only the part of the arc below some |dy/dx|, since the steep tail is where the parameterisation degenerates and how much tail is tracked depends on how high the platform was. On its own capture it looked excellent, 12.3% down to 5.1%. It does not replicate: no effect on a second capture, and on a third the single whole-segment fit underneath it blows up to 92% spread, that fit being precisely what the gated median exists to avoid. Not shipped. What is stable is the thing already merged: the gated median's spread runs 4-12% by session against 47% before the screens. Anything finer needs more than a dozen flights per session to see, and every correction derived from a dozen has failed on the next dozen. --- idleon-hoops.user.js | 27 +++++++++++++++++++++++++++ idleon-suite.user.js | 29 ++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/idleon-hoops.user.js b/idleon-hoops.user.js index bdb07a6..014fa37 100644 --- a/idleon-hoops.user.js +++ b/idleon-hoops.user.js @@ -132,6 +132,33 @@ // within .21 of a standard deviation, so there is no real drift to model. // Score a per-shot correction per session, never across pooled sessions. // + // And be suspicious of the correlation itself. Measured on three separate + // captures the platform-height coupling to curvature came out at -0.13, + // -0.46 and -0.82; within the two halves of the third capture, -0.81 and + // -0.96. That is not one effect measured three times, it is what a + // correlation looks like at n = 6 to 12. Curvature is the one parameter the + // anchor cannot touch mathematically -- shotA is a*W straight off the fit + // and platform position never enters it -- so a coupling there has to be + // either real physics or a bias in the estimator, and the time domain says + // it is neither: across those same flights vx holds to 1.3% and neither vx + // nor the fitted g tracks platform height (-0.11 and -0.14). The physical + // curvature g/2vx^2 is constant. The wobble is sampling noise in medA. + // + // A slope cap was tried on the back of it -- refit y(x) using only the part + // of the arc below some |dy/dx|, on the theory that the steep tail is where + // the parameterisation degenerates and how much tail gets tracked depends + // on how high the platform was. On the capture it was derived from it looked + // excellent, spread 12.3% down to 5.1%. It does not replicate: no effect at + // all on a second capture, and on a third the single fit it rests on is + // catastrophic, 92% spread, because one whole-segment fit is exactly the + // thing the gated median exists to avoid. Not shipped. + // + // What IS stable: the gated median's own spread runs 4-12% depending on the + // session, against 47% before the screens went in. That is the honest state + // of it. Anything smaller than that needs more than a dozen flights per + // session to see, and every correction derived from a dozen has so far + // failed on the next dozen. + // // The ~55px of arc height at the rim is therefore the real accuracy ceiling // today, and it is per-shot noise in L and R rather than anything to do // with the anchor. Averaging across shots is what actually removes it, diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 8cc7b81..32bc8ef 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.10 +// @version 1.11 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @description All-in-one: autoclicker + Hoops, Fishing and Darts minigame helpers for Legends of IdleOn, each one individually switchable @@ -663,6 +663,33 @@ // within .21 of a standard deviation, so there is no real drift to model. // Score a per-shot correction per session, never across pooled sessions. // + // And be suspicious of the correlation itself. Measured on three separate + // captures the platform-height coupling to curvature came out at -0.13, + // -0.46 and -0.82; within the two halves of the third capture, -0.81 and + // -0.96. That is not one effect measured three times, it is what a + // correlation looks like at n = 6 to 12. Curvature is the one parameter the + // anchor cannot touch mathematically -- shotA is a*W straight off the fit + // and platform position never enters it -- so a coupling there has to be + // either real physics or a bias in the estimator, and the time domain says + // it is neither: across those same flights vx holds to 1.3% and neither vx + // nor the fitted g tracks platform height (-0.11 and -0.14). The physical + // curvature g/2vx^2 is constant. The wobble is sampling noise in medA. + // + // A slope cap was tried on the back of it -- refit y(x) using only the part + // of the arc below some |dy/dx|, on the theory that the steep tail is where + // the parameterisation degenerates and how much tail gets tracked depends + // on how high the platform was. On the capture it was derived from it looked + // excellent, spread 12.3% down to 5.1%. It does not replicate: no effect at + // all on a second capture, and on a third the single fit it rests on is + // catastrophic, 92% spread, because one whole-segment fit is exactly the + // thing the gated median exists to avoid. Not shipped. + // + // What IS stable: the gated median's own spread runs 4-12% depending on the + // session, against 47% before the screens went in. That is the honest state + // of it. Anything smaller than that needs more than a dozen flights per + // session to see, and every correction derived from a dozen has so far + // failed on the next dozen. + // // The ~55px of arc height at the rim is therefore the real accuracy ceiling // today, and it is per-shot noise in L and R rather than anything to do // with the anchor. Averaging across shots is what actually removes it, From 2232d9128a96347e70ed6acc113b3d42af963491 Mon Sep 17 00:00:00 2001 From: averagenative Date: Tue, 8 Sep 2026 20:43:52 -0400 Subject: [PATCH 2/5] Read the fishing gauge at native resolution, not through the downscale The game quantises the cast gauge: _event_Minigames1 charges with power = 1 - |sin(theta)|, theta stepping one degree a tick from 90, and sets the fill sprite's scaleY to round(64 * power). So the fill has 65 heights and one rung is one game unit. The track and fill sprites attach from one anchor at -87 and -23, and 87 - 23 = 64, so a full fill reaches exactly the track's top edge and fill/track really is the power. readMeter was being handed the 4x-downscaled frame. That put the whole gauge at ~21 rows, so a rung was under a third of a row and the reading could not resolve the game's own quantum at all. A one-row error was ~5% of the gauge, and since the aim curve's slope near full charge is ~1.18 lane-fractions per unit of power, it arrived as ~6% of the lane -- four times the error of the curve it was feeding. That is why long casts were far off while short ones looked fine: the charge law is flattest at the bottom, so the same row is worth very little near zero power. The gauge now gets its own native-resolution grab, about a fortieth of the frame and only once a lane exists. Replayed against five fishing clips: at the plateau the old code reads 0.727 -- 16/22, the only vocabulary it had -- where native reads 0.778. No clip reads worse than before and one improves 84 -> 88 frames measured. Two things this turned up. The column scan had to start bridging gaps: its "longest unbroken run" test only ever passed because the downscale blurred the pole's texture, the fill/track seam and the marker line into pixels that matched. At native resolution those gaps are real, a strict run measured 16-32 rows against the 38 demanded, and every frame above about a third charge failed outright. And the gauge geometry is now settled once and held rather than re-derived every frame -- a denominator that drifts is a slow scale error on every prediction, not noise the aim marker can average away. Snapping to the 64-rung ladder is computed and reported but deliberately not used. A rung is ~1.4px at this canvas size and a fill edge resolves to maybe half a pixel, so snapping misassigns rungs often enough to give back most of what it wins. 585 readings across the five clips sit no closer to the rungs than random. Consistent with a real ladder buried in measurement noise, which is what the pixel arithmetic predicts. calVer 7: samples paired with a gauge read through the downscale carry that error in the power axis, and refitting on them fits the error. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-fishing.user.js | 240 ++++++++++++++++++++++++++++++---------- idleon-suite.user.js | 244 +++++++++++++++++++++++++++++++---------- 2 files changed, 366 insertions(+), 118 deletions(-) diff --git a/idleon-fishing.user.js b/idleon-fishing.user.js index cb62e03..1204984 100644 --- a/idleon-fishing.user.js +++ b/idleon-fishing.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Fishing Helper // @namespace nativerobot -// @version 2.3 +// @version 2.4 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-fishing.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-fishing.user.js // @description Draws where your cast will land, plus fish and hazard markers, for the IdleOn fishing minigame @@ -64,9 +64,13 @@ }, JSON.parse(localStorage.getItem(KEY) || '{}')); // Samples are (power, landing) pairs and would survive a change of model — // but not a change of what "power" meant. Everything learned before v6 was - // paired with a gauge reading that could collapse, so it goes. - if (cfg.calVer !== 6) { - cfg.calVer = 6; cfg.samples = []; + // paired with a gauge reading that could collapse. Everything learned before + // v7 was paired with a gauge read through the 4x downscale, where one row of + // the ~21-row gauge was ~5% of it and the reading could not resolve the + // game's own step at all — so those pairs carry the readback error in the + // power axis, and refitting on them fits the error. They go too. + if (cfg.calVer !== 7) { + cfg.calVer = 7; cfg.samples = []; cfg.aim2 = 0.3095; cfg.aim1 = 0.5631; cfg.aim0 = 0.0420; delete cfg.aimA; delete cfg.aimB; } @@ -198,6 +202,33 @@ } catch (e) { return null; } } + // The gauge is read at NATIVE resolution, in its own narrow grab. Everything + // else works off the 4x-downscaled frame, which is fine for finding a lane + // 20% of the screen wide and hopeless for a gauge ~64px tall: see the power + // meter section for what that cost. The band is ~10% of the width by 30% of + // the height, so this reads about a fortieth of the frame — cheaper than the + // downscaled grab it corrects, and it only runs once a lane has been found. + const gauge = document.createElement('canvas'); + const gctx = gauge.getContext('2d', { willReadFrequently: true }); + + function grabGauge(cv, lane, sw, sh) { + // lane is in downscaled coordinates; nx/ny carry back to native ones. + const nx = cv.width / sw, ny = cv.height / sh; + const sx = Math.max(0, Math.floor((lane.x0 - sw * 0.10) * nx)); + const ex = Math.min(cv.width, Math.ceil((lane.x0 - sw * 0.005) * nx)); + const sy = Math.max(0, Math.floor((lane.y - sh * 0.22) * ny)); + const ey = Math.min(cv.height, Math.ceil((lane.y + sh * 0.08) * ny)); + const w = ex - sx, h = ey - sy; + if (w < 4 || h < 16) return null; + if (gauge.width !== w || gauge.height !== h) { gauge.width = w; gauge.height = h; } + try { + gctx.clearRect(0, 0, w, h); + gctx.drawImage(cv, sx, sy, w, h, 0, 0, w, h); + return { d: gctx.getImageData(0, 0, w, h).data, w, h, sx, sy, nx, ny, + cvW: cv.width, cvH: cv.height }; + } catch (e) { return null; } + } + function gameCanvas() { let best = null, area = 0; for (const c of document.querySelectorAll('canvas')) { @@ -382,14 +413,33 @@ // ---------- the power meter ---------- // A short vertical gauge just left of the lane that fills from the bottom as // you hold. Its fill fraction is what the aim marker is derived from. - function readMeter(d, w, h, lane) { - const x0 = Math.max(0, lane.x0 - Math.round(w * 0.10)); - const x1 = Math.max(1, lane.x0 - Math.round(w * 0.005)); - // The pole keeps going below the lane row, down to its base — the fill's - // zero point. Cutting the scan at the lane line (as this used to) read the - // gauge about a fifth short: the ruler's 0 floated above the pole's bottom - // and every fill in the below-lane stretch measured as zero power. - const yLo = Math.max(0, lane.y - Math.round(h * 0.22)), yHi = Math.min(h, lane.y + Math.round(h * 0.08)); + // + // The gauge is read at NATIVE resolution, out of its own grab, while the lane + // and everything else come off the 4x-downscaled frame. That split is the + // whole point of this section, so it is worth writing down why. + // + // The game sets the fill sprite's vertical scale to round(64 * power) — read + // out of N.js, the shipped bundle, where _event_Minigames1 does + // + // theta += 1 (starts at 90, one step per tick) + // power = 1 - |sin(theta degrees)| + // AdjustImgInst("height", fillSprite, 100 * round(64 * power)) + // + // and AdjustImgInst("height", img, e) is set_scaleY(SCALE * e / 100). So the + // fill takes exactly 65 heights and one step is the game's SCALE in pixels. + // + // Read through the 4x downscale the whole gauge came to ~21 rows, so a step + // was under a third of a row: the reading could not resolve the game's own + // quantum, and a one-row error in either the fill or the track was ~5% of the + // gauge. The aim curve's slope near full charge is ~1.18 lane-fractions per + // unit of power, so that single row arrived as ~6% of the lane — four times + // the error of the curve it was feeding. That is why long casts were far off + // while short ones looked fine: the same row is worth ~6% at full charge and + // very little near zero, because the charge law is flattest at the bottom. + const RUNGS = 64; + + function readMeter(G, lane) { + const { d, w, h, sx, sy, nx, ny, cvW, cvH } = G; const isCase = (hu, s, v) => v < 0.55 && inH(hu, 5, 60) && s > 0.25; const part = (hu, s, v) => isBobber(hu, s, v) || isCase(hu, s, v); @@ -397,31 +447,44 @@ // ~43% on every reading, because the striped beach umbrella beside the // meter is red too — so low-power casts could never be predicted. The // gauge is a tall thin column and the umbrella is squat, so the column - // with the longest unbroken vertical run picks out the real meter. + // with the longest vertical run picks out the real meter. + // + // The run BRIDGES small gaps, as findLane does. It did not have to when + // this ran on the downscaled frame: the 4x box filter blurred the pole's + // own texture, the fill/track seam and the green marker line into pixels + // that passed, so an unbroken run was easy to come by. At native + // resolution those gaps are real, and a strict run measured 16-32 rows + // against the 38 this test demands — every frame above about a third + // charge failed outright and the meter read nothing at all. Bridging is + // also what keeps the test meaningful rather than merely looser: what + // separates the gauge from the umbrella is that the gauge is LONG, and a + // run broken into thirds cannot show that. + const bridge = Math.max(2, Math.round(cvH * 0.006)); let bestX = -1, bestRun = 0; - for (let x = x0; x < x1; x++) { - let run = 0; - for (let y = yLo; y < yHi; y++) { + for (let x = 0; x < w; x++) { + let run = 0, gap = 0, longest = 0; + for (let y = 0; y < h; y++) { const [hu, s, v] = hsvAt(d, y * w + x); - if (part(hu, s, v)) { run++; if (run > bestRun) { bestRun = run; bestX = x; } } - else run = 0; + if (part(hu, s, v)) { run += gap + 1; gap = 0; if (run > longest) longest = run; } + else if (run && gap < bridge) gap++; + else { run = 0; gap = 0; } } + if (longest > bestRun) { bestRun = longest; bestX = x; } } - if (bestX < 0 || bestRun < h * 0.05) return null; + if (bestX < 0 || bestRun < cvH * 0.05) return null; - const pad = Math.max(1, Math.round(w * 0.006)); - const cx0 = Math.max(x0, bestX - pad), cx1 = Math.min(x1, bestX + pad + 1); + const pad = Math.max(1, Math.round(cvW * 0.006)); + const cx0 = Math.max(0, bestX - pad), cx1 = Math.min(w, bestX + pad + 1); // Per row: how many of the band's columns are pole, and how many are fill. - const n = yHi - yLo; - const rowN = new Uint8Array(n), rowRed = new Uint8Array(n); - for (let y = yLo; y < yHi; y++) { + const rowN = new Uint8Array(h), rowRed = new Uint8Array(h); + for (let y = 0; y < h; y++) { let c = 0, r = 0; for (let x = cx0; x < cx1; x++) { const [hu, s, v] = hsvAt(d, y * w + x); if (isBobber(hu, s, v)) { r++; c++; } else if (isCase(hu, s, v)) c++; } - rowN[y - yLo] = c; rowRed[y - yLo] = r; + rowN[y] = c; rowRed[y] = r; } // One matching pixel in the row is enough. There WAS a width test here — // "at least half as many columns as the widest row" — to keep single-pixel @@ -442,7 +505,7 @@ // What actually separates a gauge from a speck is not width, it is that a // gauge is a long unbroken run and a speck is one or two isolated rows. // The walk below tests exactly that, and it was already doing the work. - const on = i => i >= 0 && i < n && rowN[i] > 0; + const on = i => i >= 0 && i < h && rowN[i] > 0; // Both ends are walked out from inside the pole rather than taken as the // first and last matching row. Two things break the run and have to be @@ -450,15 +513,15 @@ // that matches neither mask, and the game draws a green marker line across // the gauge. The gap to the foliage above is far longer than either, so // bridging a couple of rows separates them cleanly. - const gapMax = Math.max(2, Math.round(h * 0.02)); + const gapMax = Math.max(2, Math.round(cvH * 0.02)); const walk = (from, dir) => { let cur = from; for (;;) { let next = -1; for (let g = 1; g <= gapMax; g++) { const y = cur + dir * g; - if (y < yLo || y >= yHi) break; - if (on(y - yLo)) { next = y; break; } + if (y < 0 || y >= h) break; + if (on(y)) { next = y; break; } } if (next < 0) return cur; cur = next; @@ -466,39 +529,97 @@ }; // The base is sought from the lane row down, not up: the dark PTS banner // sits lower in the same columns at some layouts. - let bot = Math.min(lane.y, yHi - 1); - while (bot > yLo && !on(bot - yLo)) bot--; - if (!on(bot - yLo)) return null; + let bot = Math.min(Math.round(lane.y * ny) - sy, h - 1); + if (bot < 0) return null; + while (bot > 0 && !on(bot)) bot--; + if (!on(bot)) return null; bot = walk(bot, 1); const top = walk(bot, -1); - if (bot - top < 4) return null; + // Four rows of the OLD downscaled gauge, which is 4*ny native rows now. + if (bot - top < 4 * ny) return null; let fillTop = null; - for (let y = top; y <= bot; y++) if (rowRed[y - yLo] > 0) { fillTop = y; break; } - const total = bot - top + 1; - const fill = fillTop === null ? 0 : (bot - fillTop + 1); - return { top, bot, total, x: bestX, fillTop, frac: Math.max(0, Math.min(1, fill / total)) }; + for (let y = top; y <= bot; y++) if (rowRed[y] > 0) { fillTop = y; break; } + // Ends come back in native canvas pixels, which is the space the geometry + // is held in — the band offset sy moves with the lane row and must not + // leak into a value that is supposed to be fixed furniture. + return { + x: (sx + bestX) / nx, + topAbs: sy + top, botAbs: sy + bot, + fillTopAbs: fillTop === null ? null : sy + fillTop, + nx, ny + }; } - // The gauge is fixed furniture — it cannot move between frames — so its ends - // are held over a short window and the median taken, exactly as the lane is. - // A splash or a floating "+1 FISH" can cover part of the pole for a frame or - // two, and a gauge measured short reads the same red bar as far more power - // than it is. Holding the geometry and re-deriving only the fill removed - // every such outlier from the recording (worst case 6 rows for a 21-row - // gauge, i.e. triple the true power, on 1% of frames). - let meterHist = []; + // The gauge is fixed furniture — it cannot move between frames, and the game + // never resizes it — so its two ends are settled ONCE and then held, instead + // of being re-derived every frame. A splash or a floating "+1 FISH" can cover + // part of the pole for a frame or two, and a gauge measured short reads the + // same red bar as far more power than it is. The previous version took a + // rolling median over 1500ms, which removed the outliers (worst case 6 rows + // for a 21-row gauge, i.e. triple the true power, on 1% of frames) but still + // let the denominator drift with whatever the last 1.5s happened to contain. + // A denominator that drifts is not noise, it is a slow scale error on every + // prediction, and the aim marker cannot tell the two apart. + let meterHist = [], meterGeom = null; + function resetMeter() { meterHist = []; meterGeom = null; } + function stableMeter(m, t) { - if (m) meterHist.push({ t, top: m.top, bot: m.bot }); - meterHist = meterHist.filter(o => t - o.t < 1500); - if (!m || meterHist.length < 3) return m; - const tops = meterHist.map(o => o.top).sort((a, b) => a - b); - const bots = meterHist.map(o => o.bot).sort((a, b) => a - b); - const top = tops[tops.length >> 1], bot = bots[bots.length >> 1]; - if (bot - top < 4) return m; - const total = bot - top + 1; - const fill = m.fillTop === null ? 0 : (bot - m.fillTop + 1); - return { top, bot, total, x: m.x, fillTop: m.fillTop, - frac: Math.max(0, Math.min(1, fill / total)) }; + if (!m) return null; + if (!meterGeom) { + meterHist.push({ t, top: m.topAbs, bot: m.botAbs }); + meterHist = meterHist.filter(o => t - o.t < 1500); + if (meterHist.length >= 10) { + const med = k => { + const a = meterHist.map(o => o[k]).sort((x, y) => x - y); + return a[a.length >> 1]; + }; + meterGeom = { top: med('top'), bot: med('bot') }; + } + } + const top = meterGeom ? meterGeom.top : m.topAbs; + const bot = meterGeom ? meterGeom.bot : m.botAbs; + const totalPx = bot - top + 1; + if (totalPx < 4) return null; + const fillPx = m.fillTopAbs === null ? 0 : Math.max(0, bot - m.fillTopAbs + 1); + const rawFrac = Math.max(0, Math.min(1, fillPx / totalPx)); + // The game's power is always exactly k/64, so snapping the reading to that + // ladder ought to remove the sub-step noise for free. It is computed, and + // reported, but deliberately NOT what the helper uses. + // + // The ladder is real and its position is not in doubt. The minigame + // attaches the gauge's two sprites from one anchor: the track at + // anchor.y-87, the fill at anchor.y-23 with its origin moved to its own + // bottom edge so it grows upward. 87-23 = 64, the same 64 the fill's scale + // is quantised to — so a full fill reaches exactly the track's top edge, + // one rung is exactly one game unit, and fill/track really is the power. + // + // What is in doubt is whether the rung survives being measured. At the + // canvas size in the recordings the whole track is ~89px, so a rung is + // ~1.4px, and the edge of a fill is an antialiased blend the colour masks + // resolve to no better than about half a pixel. Half a pixel is a third of + // a rung. Snapping under that much noise misassigns the rung often enough + // to give back most of what it wins, and it can only ever win half a rung. + // + // The replay data says the same thing: 585 non-zero readings over five + // fishing clips, distance to the nearest rung 0.434% against the 0.391% + // that uniformly random readings would give — no clustering, at 32, 64 or + // 128 rungs alike. Consistent with a real ladder buried in measurement + // noise, which is exactly what the pixel arithmetic above predicts. + // + // So rawFrac ships. Snapping becomes worth turning on if the gauge is ever + // read at a canvas size where a rung is comfortably more than a pixel; + // `snapped` is in the probe so that day can be recognised rather than + // guessed at. + const snapped = Math.round(rawFrac * RUNGS) / RUNGS; + const frac = rawFrac; + return { + x: m.x, + top: top / m.ny, bot: bot / m.ny, + fillTop: m.fillTopAbs === null ? null : m.fillTopAbs / m.ny, + total: totalPx / m.ny, + totalPx, fillPx, stepPx: totalPx / RUNGS, rawFrac, frac, snapped, + settled: !!meterGeom + }; } // ---------- blobs of a given colour on/near the lane ---------- @@ -692,7 +813,7 @@ const L = (t - laneT > 700) ? (laneHist = [], null) : stableLane(raw, t, sh); lane = L; if (!lane) { - bobHist = []; hold = null; meterHist = []; + bobHist = []; hold = null; resetMeter(); if (frame % 15 === 0) stEl.textContent = 'idle\nnot at the fishing spot'; probe({ frame, idle: 'no lane' }); return; @@ -804,7 +925,10 @@ } // ---- power meter ---- - const m = stableMeter(readMeter(d, sw, sh, lane), t); + // Its own native-resolution grab, not the downscaled frame — see the + // power meter section for the ~6%-of-lane error that cost. + const Gg = grabGauge(cv, lane, sw, sh); + const m = stableMeter(Gg ? readMeter(Gg, lane) : null, t); if (m) { charge = m.frac; if (charge > 0.02) chargeSeen = t; diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 32bc8ef..b4fd54f 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.11 +// @version 1.16 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @description All-in-one: autoclicker + Hoops, Fishing and Darts minigame helpers for Legends of IdleOn, each one individually switchable @@ -1543,9 +1543,13 @@ }, cfg => { // Samples are (power, landing) pairs and would survive a change of model — // but not a change of what "power" meant. Everything learned before v6 was - // paired with a gauge reading that could collapse, so it goes. - if (cfg.calVer !== 6) { - cfg.calVer = 6; cfg.samples = []; + // paired with a gauge reading that could collapse. Everything learned before + // v7 was paired with a gauge read through the 4x downscale, where one row of + // the ~21-row gauge was ~5% of it and the reading could not resolve the + // game's own step at all — so those pairs carry the readback error in the + // power axis, and refitting on them fits the error. They go too. + if (cfg.calVer !== 7) { + cfg.calVer = 7; cfg.samples = []; cfg.aim2 = 0.3095; cfg.aim1 = 0.5631; cfg.aim0 = 0.0420; delete cfg.aimA; delete cfg.aimB; } @@ -1624,6 +1628,33 @@ } catch (e) { return null; } } + // The gauge is read at NATIVE resolution, in its own narrow grab. Everything + // else works off the 4x-downscaled frame, which is fine for finding a lane + // 20% of the screen wide and hopeless for a gauge ~64px tall: see the power + // meter section for what that cost. The band is ~10% of the width by 30% of + // the height, so this reads about a fortieth of the frame — cheaper than the + // downscaled grab it corrects, and it only runs once a lane has been found. + const gauge = document.createElement('canvas'); + const gctx = gauge.getContext('2d', { willReadFrequently: true }); + + function grabGauge(cv, lane, sw, sh) { + // lane is in downscaled coordinates; nx/ny carry back to native ones. + const nx = cv.width / sw, ny = cv.height / sh; + const sx = Math.max(0, Math.floor((lane.x0 - sw * 0.10) * nx)); + const ex = Math.min(cv.width, Math.ceil((lane.x0 - sw * 0.005) * nx)); + const sy = Math.max(0, Math.floor((lane.y - sh * 0.22) * ny)); + const ey = Math.min(cv.height, Math.ceil((lane.y + sh * 0.08) * ny)); + const w = ex - sx, h = ey - sy; + if (w < 4 || h < 16) return null; + if (gauge.width !== w || gauge.height !== h) { gauge.width = w; gauge.height = h; } + try { + gctx.clearRect(0, 0, w, h); + gctx.drawImage(cv, sx, sy, w, h, 0, 0, w, h); + return { d: gctx.getImageData(0, 0, w, h).data, w, h, sx, sy, nx, ny, + cvW: cv.width, cvH: cv.height }; + } catch (e) { return null; } + } + // ---------- colour helpers (measured off the real sprites) ---------- function hsvAt(d, i) { const p = i * 4, r = d[p], g = d[p + 1], b = d[p + 2]; @@ -1784,14 +1815,33 @@ // ---------- the power meter ---------- // A short vertical gauge just left of the lane that fills from the bottom as // you hold. Its fill fraction is what the aim marker is derived from. - function readMeter(d, w, h, lane) { - const x0 = Math.max(0, lane.x0 - Math.round(w * 0.10)); - const x1 = Math.max(1, lane.x0 - Math.round(w * 0.005)); - // The pole keeps going below the lane row, down to its base — the fill's - // zero point. Cutting the scan at the lane line (as this used to) read the - // gauge about a fifth short: the ruler's 0 floated above the pole's bottom - // and every fill in the below-lane stretch measured as zero power. - const yLo = Math.max(0, lane.y - Math.round(h * 0.22)), yHi = Math.min(h, lane.y + Math.round(h * 0.08)); + // + // The gauge is read at NATIVE resolution, out of its own grab, while the lane + // and everything else come off the 4x-downscaled frame. That split is the + // whole point of this section, so it is worth writing down why. + // + // The game sets the fill sprite's vertical scale to round(64 * power) — read + // out of N.js, the shipped bundle, where _event_Minigames1 does + // + // theta += 1 (starts at 90, one step per tick) + // power = 1 - |sin(theta degrees)| + // AdjustImgInst("height", fillSprite, 100 * round(64 * power)) + // + // and AdjustImgInst("height", img, e) is set_scaleY(SCALE * e / 100). So the + // fill takes exactly 65 heights and one step is the game's SCALE in pixels. + // + // Read through the 4x downscale the whole gauge came to ~21 rows, so a step + // was under a third of a row: the reading could not resolve the game's own + // quantum, and a one-row error in either the fill or the track was ~5% of the + // gauge. The aim curve's slope near full charge is ~1.18 lane-fractions per + // unit of power, so that single row arrived as ~6% of the lane — four times + // the error of the curve it was feeding. That is why long casts were far off + // while short ones looked fine: the same row is worth ~6% at full charge and + // very little near zero, because the charge law is flattest at the bottom. + const RUNGS = 64; + + function readMeter(G, lane) { + const { d, w, h, sx, sy, nx, ny, cvW, cvH } = G; const isCase = (hu, s, v) => v < 0.55 && inH(hu, 5, 60) && s > 0.25; const part = (hu, s, v) => isBobber(hu, s, v) || isCase(hu, s, v); @@ -1799,31 +1849,44 @@ // ~43% on every reading, because the striped beach umbrella beside the // meter is red too — so low-power casts could never be predicted. The // gauge is a tall thin column and the umbrella is squat, so the column - // with the longest unbroken vertical run picks out the real meter. + // with the longest vertical run picks out the real meter. + // + // The run BRIDGES small gaps, as findLane does. It did not have to when + // this ran on the downscaled frame: the 4x box filter blurred the pole's + // own texture, the fill/track seam and the green marker line into pixels + // that passed, so an unbroken run was easy to come by. At native + // resolution those gaps are real, and a strict run measured 16-32 rows + // against the 38 this test demands — every frame above about a third + // charge failed outright and the meter read nothing at all. Bridging is + // also what keeps the test meaningful rather than merely looser: what + // separates the gauge from the umbrella is that the gauge is LONG, and a + // run broken into thirds cannot show that. + const bridge = Math.max(2, Math.round(cvH * 0.006)); let bestX = -1, bestRun = 0; - for (let x = x0; x < x1; x++) { - let run = 0; - for (let y = yLo; y < yHi; y++) { + for (let x = 0; x < w; x++) { + let run = 0, gap = 0, longest = 0; + for (let y = 0; y < h; y++) { const [hu, s, v] = hsvAt(d, y * w + x); - if (part(hu, s, v)) { run++; if (run > bestRun) { bestRun = run; bestX = x; } } - else run = 0; + if (part(hu, s, v)) { run += gap + 1; gap = 0; if (run > longest) longest = run; } + else if (run && gap < bridge) gap++; + else { run = 0; gap = 0; } } + if (longest > bestRun) { bestRun = longest; bestX = x; } } - if (bestX < 0 || bestRun < h * 0.05) return null; + if (bestX < 0 || bestRun < cvH * 0.05) return null; - const pad = Math.max(1, Math.round(w * 0.006)); - const cx0 = Math.max(x0, bestX - pad), cx1 = Math.min(x1, bestX + pad + 1); + const pad = Math.max(1, Math.round(cvW * 0.006)); + const cx0 = Math.max(0, bestX - pad), cx1 = Math.min(w, bestX + pad + 1); // Per row: how many of the band's columns are pole, and how many are fill. - const n = yHi - yLo; - const rowN = new Uint8Array(n), rowRed = new Uint8Array(n); - for (let y = yLo; y < yHi; y++) { + const rowN = new Uint8Array(h), rowRed = new Uint8Array(h); + for (let y = 0; y < h; y++) { let c = 0, r = 0; for (let x = cx0; x < cx1; x++) { const [hu, s, v] = hsvAt(d, y * w + x); if (isBobber(hu, s, v)) { r++; c++; } else if (isCase(hu, s, v)) c++; } - rowN[y - yLo] = c; rowRed[y - yLo] = r; + rowN[y] = c; rowRed[y] = r; } // One matching pixel in the row is enough. There WAS a width test here — // "at least half as many columns as the widest row" — to keep single-pixel @@ -1844,7 +1907,7 @@ // What actually separates a gauge from a speck is not width, it is that a // gauge is a long unbroken run and a speck is one or two isolated rows. // The walk below tests exactly that, and it was already doing the work. - const on = i => i >= 0 && i < n && rowN[i] > 0; + const on = i => i >= 0 && i < h && rowN[i] > 0; // Both ends are walked out from inside the pole rather than taken as the // first and last matching row. Two things break the run and have to be @@ -1852,15 +1915,15 @@ // that matches neither mask, and the game draws a green marker line across // the gauge. The gap to the foliage above is far longer than either, so // bridging a couple of rows separates them cleanly. - const gapMax = Math.max(2, Math.round(h * 0.02)); + const gapMax = Math.max(2, Math.round(cvH * 0.02)); const walk = (from, dir) => { let cur = from; for (;;) { let next = -1; for (let g = 1; g <= gapMax; g++) { const y = cur + dir * g; - if (y < yLo || y >= yHi) break; - if (on(y - yLo)) { next = y; break; } + if (y < 0 || y >= h) break; + if (on(y)) { next = y; break; } } if (next < 0) return cur; cur = next; @@ -1868,39 +1931,97 @@ }; // The base is sought from the lane row down, not up: the dark PTS banner // sits lower in the same columns at some layouts. - let bot = Math.min(lane.y, yHi - 1); - while (bot > yLo && !on(bot - yLo)) bot--; - if (!on(bot - yLo)) return null; + let bot = Math.min(Math.round(lane.y * ny) - sy, h - 1); + if (bot < 0) return null; + while (bot > 0 && !on(bot)) bot--; + if (!on(bot)) return null; bot = walk(bot, 1); const top = walk(bot, -1); - if (bot - top < 4) return null; + // Four rows of the OLD downscaled gauge, which is 4*ny native rows now. + if (bot - top < 4 * ny) return null; let fillTop = null; - for (let y = top; y <= bot; y++) if (rowRed[y - yLo] > 0) { fillTop = y; break; } - const total = bot - top + 1; - const fill = fillTop === null ? 0 : (bot - fillTop + 1); - return { top, bot, total, x: bestX, fillTop, frac: Math.max(0, Math.min(1, fill / total)) }; - } - - // The gauge is fixed furniture — it cannot move between frames — so its ends - // are held over a short window and the median taken, exactly as the lane is. - // A splash or a floating "+1 FISH" can cover part of the pole for a frame or - // two, and a gauge measured short reads the same red bar as far more power - // than it is. Holding the geometry and re-deriving only the fill removed - // every such outlier from the recording (worst case 6 rows for a 21-row - // gauge, i.e. triple the true power, on 1% of frames). - let meterHist = []; + for (let y = top; y <= bot; y++) if (rowRed[y] > 0) { fillTop = y; break; } + // Ends come back in native canvas pixels, which is the space the geometry + // is held in — the band offset sy moves with the lane row and must not + // leak into a value that is supposed to be fixed furniture. + return { + x: (sx + bestX) / nx, + topAbs: sy + top, botAbs: sy + bot, + fillTopAbs: fillTop === null ? null : sy + fillTop, + nx, ny + }; + } + + // The gauge is fixed furniture — it cannot move between frames, and the game + // never resizes it — so its two ends are settled ONCE and then held, instead + // of being re-derived every frame. A splash or a floating "+1 FISH" can cover + // part of the pole for a frame or two, and a gauge measured short reads the + // same red bar as far more power than it is. The previous version took a + // rolling median over 1500ms, which removed the outliers (worst case 6 rows + // for a 21-row gauge, i.e. triple the true power, on 1% of frames) but still + // let the denominator drift with whatever the last 1.5s happened to contain. + // A denominator that drifts is not noise, it is a slow scale error on every + // prediction, and the aim marker cannot tell the two apart. + let meterHist = [], meterGeom = null; + function resetMeter() { meterHist = []; meterGeom = null; } + function stableMeter(m, t) { - if (m) meterHist.push({ t, top: m.top, bot: m.bot }); - meterHist = meterHist.filter(o => t - o.t < 1500); - if (!m || meterHist.length < 3) return m; - const tops = meterHist.map(o => o.top).sort((a, b) => a - b); - const bots = meterHist.map(o => o.bot).sort((a, b) => a - b); - const top = tops[tops.length >> 1], bot = bots[bots.length >> 1]; - if (bot - top < 4) return m; - const total = bot - top + 1; - const fill = m.fillTop === null ? 0 : (bot - m.fillTop + 1); - return { top, bot, total, x: m.x, fillTop: m.fillTop, - frac: Math.max(0, Math.min(1, fill / total)) }; + if (!m) return null; + if (!meterGeom) { + meterHist.push({ t, top: m.topAbs, bot: m.botAbs }); + meterHist = meterHist.filter(o => t - o.t < 1500); + if (meterHist.length >= 10) { + const med = k => { + const a = meterHist.map(o => o[k]).sort((x, y) => x - y); + return a[a.length >> 1]; + }; + meterGeom = { top: med('top'), bot: med('bot') }; + } + } + const top = meterGeom ? meterGeom.top : m.topAbs; + const bot = meterGeom ? meterGeom.bot : m.botAbs; + const totalPx = bot - top + 1; + if (totalPx < 4) return null; + const fillPx = m.fillTopAbs === null ? 0 : Math.max(0, bot - m.fillTopAbs + 1); + const rawFrac = Math.max(0, Math.min(1, fillPx / totalPx)); + // The game's power is always exactly k/64, so snapping the reading to that + // ladder ought to remove the sub-step noise for free. It is computed, and + // reported, but deliberately NOT what the helper uses. + // + // The ladder is real and its position is not in doubt. The minigame + // attaches the gauge's two sprites from one anchor: the track at + // anchor.y-87, the fill at anchor.y-23 with its origin moved to its own + // bottom edge so it grows upward. 87-23 = 64, the same 64 the fill's scale + // is quantised to — so a full fill reaches exactly the track's top edge, + // one rung is exactly one game unit, and fill/track really is the power. + // + // What is in doubt is whether the rung survives being measured. At the + // canvas size in the recordings the whole track is ~89px, so a rung is + // ~1.4px, and the edge of a fill is an antialiased blend the colour masks + // resolve to no better than about half a pixel. Half a pixel is a third of + // a rung. Snapping under that much noise misassigns the rung often enough + // to give back most of what it wins, and it can only ever win half a rung. + // + // The replay data says the same thing: 585 non-zero readings over five + // fishing clips, distance to the nearest rung 0.434% against the 0.391% + // that uniformly random readings would give — no clustering, at 32, 64 or + // 128 rungs alike. Consistent with a real ladder buried in measurement + // noise, which is exactly what the pixel arithmetic above predicts. + // + // So rawFrac ships. Snapping becomes worth turning on if the gauge is ever + // read at a canvas size where a rung is comfortably more than a pixel; + // `snapped` is in the probe so that day can be recognised rather than + // guessed at. + const snapped = Math.round(rawFrac * RUNGS) / RUNGS; + const frac = rawFrac; + return { + x: m.x, + top: top / m.ny, bot: bot / m.ny, + fillTop: m.fillTopAbs === null ? null : m.fillTopAbs / m.ny, + total: totalPx / m.ny, + totalPx, fillPx, stepPx: totalPx / RUNGS, rawFrac, frac, snapped, + settled: !!meterGeom + }; } // ---------- blobs of a given colour on/near the lane ---------- @@ -2093,7 +2214,7 @@ const L = (t - laneT > 700) ? (laneHist = [], null) : stableLane(raw, t, sh); lane = L; if (!lane) { - bobHist = []; hold = null; meterHist = []; + bobHist = []; hold = null; resetMeter(); if (frame % 15 === 0) stEl.textContent = 'idle\nnot at the fishing spot'; probe({ frame, idle: 'no lane' }); return; @@ -2205,7 +2326,10 @@ } // ---- power meter ---- - const m = stableMeter(readMeter(d, sw, sh, lane), t); + // Its own native-resolution grab, not the downscaled frame — see the + // power meter section for the ~6%-of-lane error that cost. + const Gg = grabGauge(cv, lane, sw, sh); + const m = stableMeter(Gg ? readMeter(Gg, lane) : null, t); if (m) { charge = m.frac; if (charge > 0.02) chargeSeen = t; From e5a35aa42d02014913f3cc6893dcaf48ad747721 Mon Sep 17 00:00:00 2001 From: averagenative Date: Tue, 8 Sep 2026 20:44:08 -0400 Subject: [PATCH 3/5] Push the dart sideways 1.25x harder than it is pushed down The wind is one vector, but the game does not push equally hard along both axes with it. The minigame builds the wind as 30*cos(phi) and 30*sin(phi) into two slots, then each flight tick adds the horizontal slot over 600 and the vertical slot over 750. Same vector, different divisors, so the horizontal acceleration is 750/600 = 1.25x the vertical. predict() was applying one coefficient to both. Which axis moves is settled by how windK was measured: it was solved from the vertical acceleration difference between two wind clusters, so 0.0158 is the /750 term and it stays. The horizontal is the one that was never independently confirmed -- the per-throw x-fits scattered +-300px/s^2 -- and it is the one that was wrong. This is a ratio between two terms in one model, which is why it can be taken from the game at all. vN and gN would need the game's tick rate and design resolution to convert into these units, and neither is recoverable from the bundle, so those measured values are left alone. It should also account for the residual recorded against landN, "the unexplained leftover splits +-20px WITH the wind sign". A horizontal wind error does exactly that: it changes how long the dart takes to reach the board, so the dart lands at the wrong point on an otherwise correct vertical curve, and the error flips sign when the wind does. landN was fitted with the horizontal term 20% light and is carrying some of it, so it wants re-measuring on throws recorded after this. Unverified against footage. Both darts recordings replay identically before and after, because neither has any detected cyan wind for the term to act on -- which confirms no regression, not the fix. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-darts.user.js | 26 ++++++++++++++++++++++++-- idleon-suite.user.js | 26 ++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/idleon-darts.user.js b/idleon-darts.user.js index 7f8a9d7..b93558a 100644 --- a/idleon-darts.user.js +++ b/idleon-darts.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Darts Helper // @namespace nativerobot -// @version 1.4 +// @version 1.5 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-darts.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-darts.user.js // @description Draws the predicted dart path and where it lands on the board, wind included, for the Throwy Darts minigame @@ -503,7 +503,29 @@ const trust = wnd.key === 'cyan' ? 1 : 0; const A = trust * cfg.windK * (wnd.mph || 6) * W; const wr = (wnd.deg || 0) * Math.PI / 180; - const ax = A * Math.cos(wr), ay = -A * Math.sin(wr); + // The wind is ONE vector, but the game does not push equally hard along + // both axes with it. Read out of N.js, the shipped bundle: the minigame + // builds the wind as 30*cos(phi) and 30*sin(phi) into two slots, then each + // flight tick adds the horizontal slot over 600 and the vertical slot over + // 750. Same vector, different divisors — so the horizontal acceleration is + // 750/600 = 1.25x the vertical one, and a model using a single coefficient + // for both is wrong on the horizontal axis by exactly that factor. + // + // Which axis is the correct one is settled by how windK was measured: it + // was solved from the vertical acceleration difference between two wind + // clusters (see its comment), so 0.0158 is the /750 term and it stays. The + // horizontal is the one that was never independently confirmed — the + // per-throw x-fits scattered +-300px/s^2 — and it is the one that moves. + // + // This should also account for the residual recorded against landN: "the + // unexplained leftover splits +-20px WITH the wind sign". A horizontal + // wind error does exactly that. It changes how long the dart takes to + // reach the board, so it lands at the wrong point on an otherwise correct + // vertical curve, and the error flips sign when the wind does. landN was + // fitted with the horizontal term 20% light and is therefore carrying some + // of it; it wants re-measuring on throws recorded after this change. + const HV = 1.25; + const ax = A * HV * Math.cos(wr), ay = -A * Math.sin(wr); const th = deg * Math.PI / 180; const vx = v * Math.cos(th), vy = -v * Math.sin(th); // The residual is eased in over the flight so the line still starts at the diff --git a/idleon-suite.user.js b/idleon-suite.user.js index b4fd54f..03c2519 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.16 +// @version 1.17 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @description All-in-one: autoclicker + Hoops, Fishing and Darts minigame helpers for Legends of IdleOn, each one individually switchable @@ -2934,7 +2934,29 @@ const trust = wnd.key === 'cyan' ? 1 : 0; const A = trust * cfg.windK * (wnd.mph || 6) * W; const wr = (wnd.deg || 0) * Math.PI / 180; - const ax = A * Math.cos(wr), ay = -A * Math.sin(wr); + // The wind is ONE vector, but the game does not push equally hard along + // both axes with it. Read out of N.js, the shipped bundle: the minigame + // builds the wind as 30*cos(phi) and 30*sin(phi) into two slots, then each + // flight tick adds the horizontal slot over 600 and the vertical slot over + // 750. Same vector, different divisors — so the horizontal acceleration is + // 750/600 = 1.25x the vertical one, and a model using a single coefficient + // for both is wrong on the horizontal axis by exactly that factor. + // + // Which axis is the correct one is settled by how windK was measured: it + // was solved from the vertical acceleration difference between two wind + // clusters (see its comment), so 0.0158 is the /750 term and it stays. The + // horizontal is the one that was never independently confirmed — the + // per-throw x-fits scattered +-300px/s^2 — and it is the one that moves. + // + // This should also account for the residual recorded against landN: "the + // unexplained leftover splits +-20px WITH the wind sign". A horizontal + // wind error does exactly that. It changes how long the dart takes to + // reach the board, so it lands at the wrong point on an otherwise correct + // vertical curve, and the error flips sign when the wind does. landN was + // fitted with the horizontal term 20% light and is therefore carrying some + // of it; it wants re-measuring on throws recorded after this change. + const HV = 1.25; + const ax = A * HV * Math.cos(wr), ay = -A * Math.sin(wr); const th = deg * Math.PI / 180; const vx = v * Math.cos(th), vy = -v * Math.sin(th); // The residual is eased in over the flight so the line still starts at the From 82249bd6e6e65cd78effa0977b03120366baf76d Mon Sep 17 00:00:00 2001 From: averagenative Date: Tue, 8 Sep 2026 21:35:46 -0400 Subject: [PATCH 4/5] Settle the gauge ladder on the live canvas: the rung is one pixel The snap comment said this had to be answered on a live canvas rather than a recording, and it now has been. Watched through tools/chrome over a session of real casts, totalPx reads 64 and never anything else: the backing store is the game's own resolution, so a rung is exactly one pixel and fillPx/64 IS k/64 by construction. Fifteen distinct locked charges all came back whole rungs -- 0.063, 0.094, 0.141, 0.156 through 0.813 -- with the largest departure the 0.05% that three decimal places of printout account for on their own. That also explains the replay result the previous commit recorded, where 585 readings sat no closer to the rungs than random at any spacing. The recordings captured the canvas at its CSS size, 750 tall against the game's 540, so a unit spanned 1.389px and the gauge measured the ~89px we saw. A 1.4px rung, through H.264, against a fill edge the colour masks resolve to about half a pixel, does not survive being measured. The ladder was there and the capture destroyed it -- which was one of the two explanations offered, now decided. The snap stays off, and the pair of measurements makes the case better than either did alone: it is an identity at 64px, exactly where it would be safe, and unreliable at 89px, exactly where it would have to earn its place. There is no canvas size at which it is worth having. `snapped` stays in the probe as a tripwire -- if it ever diverges from rawFrac on a live canvas, the gauge is being read at a scale nobody has considered. Comment and provenance only; no behaviour changes, so no @version bump. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-fishing.user.js | 51 ++++++++++++++++++++++------------------ idleon-suite.user.js | 53 ++++++++++++++++++++++++------------------ 2 files changed, 59 insertions(+), 45 deletions(-) diff --git a/idleon-fishing.user.js b/idleon-fishing.user.js index 1204984..3c37c03 100644 --- a/idleon-fishing.user.js +++ b/idleon-fishing.user.js @@ -584,32 +584,39 @@ const rawFrac = Math.max(0, Math.min(1, fillPx / totalPx)); // The game's power is always exactly k/64, so snapping the reading to that // ladder ought to remove the sub-step noise for free. It is computed, and - // reported, but deliberately NOT what the helper uses. + // reported, but deliberately NOT what the helper uses. That reads backwards + // until you know where the rungs land in pixels, so: // - // The ladder is real and its position is not in doubt. The minigame - // attaches the gauge's two sprites from one anchor: the track at - // anchor.y-87, the fill at anchor.y-23 with its origin moved to its own - // bottom edge so it grows upward. 87-23 = 64, the same 64 the fill's scale - // is quantised to — so a full fill reaches exactly the track's top edge, - // one rung is exactly one game unit, and fill/track really is the power. + // The ladder is real. The minigame attaches the gauge's two sprites from + // one anchor: the track at anchor.y-87, the fill at anchor.y-23 with its + // origin moved to its own bottom edge so it grows upward. 87-23 = 64, the + // same 64 the fill's scale is quantised to — a full fill reaches exactly + // the track's top edge, one rung is exactly one game unit, and fill/track + // really is the power. // - // What is in doubt is whether the rung survives being measured. At the - // canvas size in the recordings the whole track is ~89px, so a rung is - // ~1.4px, and the edge of a fill is an antialiased blend the colour masks - // resolve to no better than about half a pixel. Half a pixel is a third of - // a rung. Snapping under that much noise misassigns the rung often enough - // to give back most of what it wins, and it can only ever win half a rung. + // And on the live canvas the rung is exactly one PIXEL. Watched through + // tools/chrome over a session of real casts, totalPx reads 64 and never + // anything else, because the backing store is the game's own resolution — + // so fillPx/64 IS k/64 by construction. Fifteen distinct locked charges + // came back 0.063, 0.094, 0.141, 0.156 ... 0.813, every one of them a whole + // rung, the largest departure being the 0.05% that three decimal places of + // printout can account for on its own. rawFrac needs no snapping: it is + // already exact. // - // The replay data says the same thing: 585 non-zero readings over five - // fishing clips, distance to the nearest rung 0.434% against the 0.391% - // that uniformly random readings would give — no clustering, at 32, 64 or - // 128 rungs alike. Consistent with a real ladder buried in measurement - // noise, which is exactly what the pixel arithmetic above predicts. + // Which also explains the recordings, where 585 readings sat no closer to + // the rungs than random, at 32, 64 or 128 alike. Those captured the canvas + // at its CSS size, 750 tall against the game's 540, so a unit spanned + // 750/540 = 1.389px and the gauge measured the ~89px we saw. A rung that + // is 1.4px wide, through H.264, against a fill edge the colour masks + // resolve to about half a pixel, is a rung that does not survive being + // measured. The ladder was there; the capture destroyed it. // - // So rawFrac ships. Snapping becomes worth turning on if the gauge is ever - // read at a canvas size where a rung is comfortably more than a pixel; - // `snapped` is in the probe so that day can be recognised rather than - // guessed at. + // So the snap stays off, and the two measurements say why better than + // either does alone: it is an identity at 64px, exactly where it would be + // safe, and unreliable at 89px, exactly where it would have to earn its + // place. There is no canvas size at which it is worth having. `snapped` + // stays in the probe as the check — if it ever diverges from rawFrac on a + // live canvas, the gauge is being read at a scale nobody has thought about. const snapped = Math.round(rawFrac * RUNGS) / RUNGS; const frac = rawFrac; return { diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 03c2519..b57edfb 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.17 +// @version 1.18 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @description All-in-one: autoclicker + Hoops, Fishing and Darts minigame helpers for Legends of IdleOn, each one individually switchable @@ -1986,32 +1986,39 @@ const rawFrac = Math.max(0, Math.min(1, fillPx / totalPx)); // The game's power is always exactly k/64, so snapping the reading to that // ladder ought to remove the sub-step noise for free. It is computed, and - // reported, but deliberately NOT what the helper uses. + // reported, but deliberately NOT what the helper uses. That reads backwards + // until you know where the rungs land in pixels, so: // - // The ladder is real and its position is not in doubt. The minigame - // attaches the gauge's two sprites from one anchor: the track at - // anchor.y-87, the fill at anchor.y-23 with its origin moved to its own - // bottom edge so it grows upward. 87-23 = 64, the same 64 the fill's scale - // is quantised to — so a full fill reaches exactly the track's top edge, - // one rung is exactly one game unit, and fill/track really is the power. + // The ladder is real. The minigame attaches the gauge's two sprites from + // one anchor: the track at anchor.y-87, the fill at anchor.y-23 with its + // origin moved to its own bottom edge so it grows upward. 87-23 = 64, the + // same 64 the fill's scale is quantised to — a full fill reaches exactly + // the track's top edge, one rung is exactly one game unit, and fill/track + // really is the power. // - // What is in doubt is whether the rung survives being measured. At the - // canvas size in the recordings the whole track is ~89px, so a rung is - // ~1.4px, and the edge of a fill is an antialiased blend the colour masks - // resolve to no better than about half a pixel. Half a pixel is a third of - // a rung. Snapping under that much noise misassigns the rung often enough - // to give back most of what it wins, and it can only ever win half a rung. + // And on the live canvas the rung is exactly one PIXEL. Watched through + // tools/chrome over a session of real casts, totalPx reads 64 and never + // anything else, because the backing store is the game's own resolution — + // so fillPx/64 IS k/64 by construction. Fifteen distinct locked charges + // came back 0.063, 0.094, 0.141, 0.156 ... 0.813, every one of them a whole + // rung, the largest departure being the 0.05% that three decimal places of + // printout can account for on its own. rawFrac needs no snapping: it is + // already exact. // - // The replay data says the same thing: 585 non-zero readings over five - // fishing clips, distance to the nearest rung 0.434% against the 0.391% - // that uniformly random readings would give — no clustering, at 32, 64 or - // 128 rungs alike. Consistent with a real ladder buried in measurement - // noise, which is exactly what the pixel arithmetic above predicts. + // Which also explains the recordings, where 585 readings sat no closer to + // the rungs than random, at 32, 64 or 128 alike. Those captured the canvas + // at its CSS size, 750 tall against the game's 540, so a unit spanned + // 750/540 = 1.389px and the gauge measured the ~89px we saw. A rung that + // is 1.4px wide, through H.264, against a fill edge the colour masks + // resolve to about half a pixel, is a rung that does not survive being + // measured. The ladder was there; the capture destroyed it. // - // So rawFrac ships. Snapping becomes worth turning on if the gauge is ever - // read at a canvas size where a rung is comfortably more than a pixel; - // `snapped` is in the probe so that day can be recognised rather than - // guessed at. + // So the snap stays off, and the two measurements say why better than + // either does alone: it is an identity at 64px, exactly where it would be + // safe, and unreliable at 89px, exactly where it would have to earn its + // place. There is no canvas size at which it is worth having. `snapped` + // stays in the probe as the check — if it ever diverges from rawFrac on a + // live canvas, the gauge is being read at a scale nobody has thought about. const snapped = Math.round(rawFrac * RUNGS) / RUNGS; const frac = rawFrac; return { From 3954c8ba3a105f9d8bb8c517daf6d043c6b633ab Mon Sep 17 00:00:00 2001 From: averagenative Date: Wed, 9 Sep 2026 12:19:14 -0400 Subject: [PATCH 5/5] Bump the hoops version for the note that rode in with it fd54875 edited idleon-hoops.user.js without bumping @version, so Tampermonkey would never offer the update. It was pushed to hoops-anchor-note after that branch's PR had already merged, so no CI run ever saw it and the failure sat latent until this branch carried the commit into a PR against main. Comment-only or not, the check does not care and neither should it: a file that differs from the installed one and claims the same version is a file nobody receives. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-hoops.user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/idleon-hoops.user.js b/idleon-hoops.user.js index 014fa37..548af7b 100644 --- a/idleon-hoops.user.js +++ b/idleon-hoops.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Hoops Helper // @namespace nativerobot -// @version 1.9 +// @version 1.10 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-hoops.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-hoops.user.js // @description Dotted-line shot preview + live ball arc for the Swishy Hoops minigame in Legends of IdleOn