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-fishing.user.js b/idleon-fishing.user.js index cb62e03..3c37c03 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,104 @@ }; // 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. That reads backwards + // until you know where the rungs land in pixels, so: + // + // 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. + // + // 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. + // + // 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 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 { + 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 +820,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 +932,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-hoops.user.js b/idleon-hoops.user.js index bdb07a6..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 @@ -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..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.10 +// @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 @@ -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, @@ -1516,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; } @@ -1597,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]; @@ -1757,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); @@ -1772,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 @@ -1817,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 @@ -1825,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; @@ -1841,39 +1931,104 @@ }; // 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. That reads backwards + // until you know where the rungs land in pixels, so: + // + // 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. + // + // 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. + // + // 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 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 { + 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 ---------- @@ -2066,7 +2221,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; @@ -2178,7 +2333,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; @@ -2783,7 +2941,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