From 22140b416c14d092d4c6416bda998480311acc1a Mon Sep 17 00:00:00 2001 From: averagenative Date: Thu, 10 Sep 2026 12:01:16 -0400 Subject: [PATCH 1/8] Read the aim from the dart, not from the gold nearest the floor The predicted line dived off the bottom of the screen. Two separate causes, both of which needed the character to be wearing a gold helmet -- which is why 9384 frames of recorded darts never showed either of them. findAim averaged EVERY gold pixel in the box around the player to place its march origin. Measured in the 250x250 native box: the helmet is 261 gold pixels (h 42.0, s 0.57) against the fletching's 156 (h 46.9, s 0.80), and it fragments into seven blobs because the sprite's dark outline runs between the strands. The average therefore landed in the head. The hand search had the same problem from the other side -- it took the LEFTMOST blob, and the leftmost helmet strand sits at x=116 where the fletching is at x=142. Colour cannot separate them: helmets change with gear, so any hue or saturation window that excludes this helmet is only waiting for the next one. The separation that holds is structural -- a helmet is worn on the head, the dart is held at chest height. The leftmost blob still picks the character out of the scene; we then keep only blobs within a sprite width of it and take the lowest of those. findAim snaps to the gold blob nearest that pick instead of averaging over whatever else the character has on. That was not the whole story. With the origin corrected the line still dived about a third of the time, because the scan started at -75 degrees, roughly 50 below anything the game can produce. Marching down from the fletching follows the character's own torso, legs and platform, which is a longer clear run than the dart ever offers, so whenever the dart read was weak the winner was whatever angle pointed at the floor. The real sweep was measured from five independent sources -- four recordings replayed through this same code, and one live capture: 2026-08-14 1214px 1032 frames -25.4 .. +65.3 2026-07-28 16-43 1312px 2938 frames -25.4 .. +64.6 2026-07-28 17-14 1312px 2370 frames -28.0 .. +65.7 2026-07-28 19-26 1312px 3044 frames -25.9 .. +65.0 live 1327.9px 125 frames -25.5 .. +64.8 ~11,200 accepted aims, and not one below -30. The floor is NOT a tight constant: four sources cluster at -25.4..-25.9 and the fifth sits 2.6 degrees lower, so SWEEP_LO is set 12 degrees under the worst observed case rather than hugging it. Clamping alone only moves the pin from -75 to SWEEP_LO, so a winner sitting within 5 degrees of the boundary is rejected as well: a real aim is an interior maximum with reach falling away either side, whereas a march that ran out of range is still climbing when the scan stops. Verified live: 1927 board frames, 0 below -30, range -25.5 .. +64.5, against 38 of 125 diving the day before. All four recordings replay identically before and after, so the clamp costs nothing on legitimate play. The game-over screen now reads "no dart in hand" and draws nothing, where it previously drew a confident line into the floor. aimReach and aimR1 are published on the probe because reach is the value that says whether the march followed a dart or ran off the end of its own search. It is deliberately NOT used as a guard: it separated perfectly within one session (real 83-85.8 against dives at 59.5/73.3/80.2/99.6) but the same measurement off the recordings spread to 82-100, and normalised by canvas width the two disagreed by 10%. A window wide enough for both lets the dives back in. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-darts.user.js | 135 +++++++++++++++++++++++++++++++++++++++---- idleon-suite.user.js | 135 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 246 insertions(+), 24 deletions(-) diff --git a/idleon-darts.user.js b/idleon-darts.user.js index b93558a..50e569c 100644 --- a/idleon-darts.user.js +++ b/idleon-darts.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Darts Helper // @namespace nativerobot -// @version 1.5 +// @version 1.8 // @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 @@ -432,15 +432,41 @@ // fletching through anything that is NOT the reddish wall, and take the angle // that reaches furthest. Validated against 16 real throws: r = 0.97 against // the launch angle actually flown. - function findAim(B, W, H) { + // hx, hy are the fletching in CSS pixels, as picked out of the downscaled + // frame by the blob search in the loop. They are only accurate to a /scale + // cell, which is why the centroid is re-taken here at native resolution — + // but they are accurate enough to say WHICH gold blob is the fletching, and + // that is the part the average used to get wrong. Averaging every gold pixel + // in the box put the origin between the fletching and whatever else the + // character had on: with the gold helmet the origin landed in the head, and + // the march then found the torso rather than the dart. See the hand blob + // search for the measurements. + function findAim(B, W, H, hx, hy) { const sx = B.sx / B.cvW * W, sy = B.sy / B.cvH * H; const kx = W / B.cvW, ky = H / B.cvH; - let gx = 0, gy = 0, gn = 0; + const ox = hx / W * B.cvW - B.sx, oy = hy / H * B.cvH - B.sy; + const seen = new Uint8Array(B.w * B.h), stack = []; + let gx = 0, gy = 0, gn = 0, bestD = Infinity; for (let y = 0; y < B.h; y++) for (let x = 0; x < B.w; x++) { - if (isGold(...px(B, x, y))) { gx += x; gy += y; gn++; } + const i = y * B.w + x; + if (seen[i] || !isGold(...px(B, x, y))) continue; + stack.length = 0; stack.push(i); seen[i] = 1; + let n = 0, ax = 0, ay = 0; + while (stack.length) { + const q = stack.pop(), qx = q % B.w, qy = (q / B.w) | 0; + n++; ax += qx; ay += qy; + for (const nb of [q - 1, q + 1, q - B.w, q + B.w]) { + if (nb < 0 || nb >= B.w * B.h || seen[nb]) continue; + if (Math.abs((nb % B.w) - qx) > 1) continue; // no wrap at the edges + if (isGold(...px(B, nb % B.w, (nb / B.w) | 0))) { seen[nb] = 1; stack.push(nb); } + } + } + if (n < 8) continue; + const cx = ax / n, cy = ay / n; + const d = (cx - ox) * (cx - ox) + (cy - oy) * (cy - oy); + if (d < bestD) { bestD = d; gx = cx; gy = cy; gn = n; } } - if (gn < 8) return null; - gx /= gn; gy /= gn; + if (!gn) return null; const notWall = (x, y) => { if (x < 0 || y < 0 || x >= B.w || y >= B.h) return false; const [h, s, v] = px(B, x, y); @@ -451,7 +477,43 @@ const R0 = Math.round(18 * scale), R1 = Math.round(100 * scale); const ext = []; let best = null; - for (let deg = -75; deg <= 80; deg++) { + // The scan used to start at -75, roughly 50 degrees below anything the + // game can actually produce, and that dead zone is where the aim went to + // die. Marching down from the fletching runs along the character's own + // torso, legs and the platform, which is a longer clear run than the dart + // ever offers, so whenever the dart read was weak the winner was whatever + // angle pointed at the floor — and the drawn line dived off the bottom of + // the screen. + // + // The real sweep was measured from five independent sources - four + // recordings replayed through this same code and one live capture: + // + // 2026-08-14 1214px canvas 1032 frames -25.4 .. +65.3 + // 2026-07-28 16-43 1312px 2938 frames -25.4 .. +64.6 + // 2026-07-28 17-14 1312px 2370 frames -28.0 .. +65.7 + // 2026-07-28 19-26 1312px 3044 frames -25.9 .. +65.0 + // live 1327.9px 125 frames -25.5 .. +64.8 + // + // ~11,200 accepted aims, and not one below -30 in any of them. The floor + // is NOT a tight constant: four sources cluster at -25.4..-25.9 and the + // fifth sits 2.6 degrees lower at -28.0, so treat -28 as the observed + // worst case rather than the true limit. In the live capture 38 further + // frames sat at -75.0 .. -70.8 - jammed against the old scan floor, with + // 44.5 degrees of empty space between them and the nearest real reading. + // Nothing legitimate lives down there. + // + // SWEEP_LO is set 12 degrees under the worst observed floor rather than + // hugging it. An earlier draft used -35, which left only 2 degrees of + // clearance against that -28.0 clip; since a fifth source moved the floor + // once, a sixth could move it again, and widening costs nothing because + // the boundary test below still catches a march that runs out of range. Angles are resolution independent, which is why this is + // the axis to guard on: reach looked like a perfect separator within one + // session (real 83-85.8 against dives at 59.5/73.3/80.2/99.6) but the same + // measurement off the recording spread to 82-100, and normalised by canvas + // width the two disagreed by 10%. A reach window wide enough for both lets + // the dives back in, so it is deliberately not used here. + const SWEEP_LO = -40; + for (let deg = SWEEP_LO; deg <= 80; deg++) { const th = deg * Math.PI / 180, ux = Math.cos(th), uy = -Math.sin(th); let reach = R0, gap = 0; for (let r = R0; r <= R1; r++) { @@ -462,6 +524,17 @@ if (!best || reach > best.reach) best = { deg, reach }; } if (!best || best.reach < 40 * scale) return null; + // Narrowing the scan alone only moves the problem: a march that wants to + // point at the floor now pins at SWEEP_LO instead of -75. But that is the + // tell. A real aim is an interior maximum — the reach falls away on both + // sides of it — whereas a march that ran out of range is still climbing + // when the scan stops, so it sits hard against the boundary. Every one of + // the 38 dive frames measured was within 4.2 degrees of the floor, so a + // 5-degree boundary band catches them all; the lowest real reading in + // ~11,200 aims was -28.0, which is 7 degrees clear of the -35 cutoff. + // Rejecting the boundary costs nothing real and removes what the clamp + // leaves behind. + if (best.deg <= SWEEP_LO + 5) return null; const near = ext.filter(e => e.reach >= best.reach - 4 * scale); if (near.length > 34) return null; // a broad plateau is the body, not a dart let sw = 0, sd = 0; @@ -578,8 +651,30 @@ // gold pixel on screen. Averaging dragged the "hand" into the bottom-left // corner whenever the "Get 9 Bullseye in a row" trophy hint was showing, // because its trophy icons are gold too. The hint sits in the bottom band - // and the HUD in the top one, so both are cut out; of what remains the - // leftmost blob is the hand, since a thrown dart only ever travels right. + // and the HUD in the top one, so both are cut out. + // + // Which of the remaining blobs is the fletching used to be answered with + // "the leftmost one, since a thrown dart only ever travels right". That is + // wrong whenever the character is WEARING something gold. Measured on the + // gold helmet, in the 250x250 native box around the player: the helmet is + // 261 gold pixels (h 42.0, s 0.57) against the fletching's 156 (h 46.9, + // s 0.80), and it fragments into seven blobs because the sprite's dark + // outline runs between the strands. The leftmost of those sits at x=116 + // where the fletching is at x=142, so the "hand" latched onto the helmet, + // findAim marched from the character's head instead of the chest, and the + // longest clear run from there is straight DOWN the torso and legs — which + // is why the predicted line dived off the bottom of the screen at + // aimDeg -56.8 while the dart was plainly held at about +40. + // + // Colour cannot separate them: helmets change colour with gear, so any + // hue or saturation window that excludes this helmet is only waiting for + // the next one. The separation that holds is structural — a helmet is worn + // on the head, the dart is held at chest height, so of the gold on the + // character the fletching is the LOWEST. The leftmost blob still picks the + // character out of the scene (a dart in flight is right of the thrower, and + // is what the x cut below is for); we then keep only blobs within a + // sprite's width of it and take the lowest of those, so a gold helmet + // anchors the search and no longer wins it. const hand = (() => { const y0 = Math.round(I.h * 0.14), y1 = Math.round(I.h * 0.88); // The thrower stays in the left half (measured 331-560px of 1326); the @@ -587,7 +682,7 @@ // being mistaken for the one in your hand. const x1 = Math.round(I.w * 0.62); const seen = new Uint8Array(I.w * I.h), stack = []; - let best = null; + const blobs = []; for (let y = y0; y < y1; y++) for (let x = 0; x < x1; x++) { const i = y * I.w + x; if (seen[i] || !isGold(...px(I, x, y))) continue; @@ -604,8 +699,16 @@ } } if (n < 4) continue; - if (!best || minx < best.minx) best = { x: sx / n * kx, y: sy / n * ky, n, minx }; + blobs.push({ x: sx / n * kx, y: sy / n * ky, n, minx, cy: sy / n }); } + if (!blobs.length) return null; + // The character sprite measured 55 native px wide of 960 (0.057 of the + // canvas). 0.08 gives room for a wide helmet either side of the body + // without reaching the next thing on screen. + const anchor = Math.min(...blobs.map(b => b.minx)); + const near = blobs.filter(b => b.minx - anchor <= I.w * 0.08); + let best = null; + for (const b of near) if (!best || b.cy > best.cy) best = b; return best; })(); @@ -613,7 +716,7 @@ let aim = null; if (hand) { const B = grabBox(cv, hand.x, hand.y, Math.max(120, W * 0.13), W, H); - if (B) aim = findAim(B, W, H); + if (B) aim = findAim(B, W, H, hand.x, hand.y); } if (aim) { // The sweep is smooth at roughly 3 deg per frame; anything wilder is the @@ -672,6 +775,14 @@ probe({ frame, board, wind, aimDeg, hand, hitBand, hitY, dart: dartPts.length, + // How far the winning march actually got, in css px. Published because + // it is the value that says whether findAim followed a DART or just ran + // off the end of its own search: a dart is a protrusion of finite length, + // the character's torso is not, so a march down the body only stops when + // it hits the R1 ceiling. Without this in the probe there is no way to + // tell those two apart after the fact. + aimReach: aim ? +aim.reach.toFixed(1) : null, + aimR1: 100, cal: { vN: cfg.vN, gN: cfg.gN, windK: cfg.windK, landN: cfg.landN } }); } diff --git a/idleon-suite.user.js b/idleon-suite.user.js index b57edfb..6d38c24 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.18 +// @version 1.22 // @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 @@ -2870,15 +2870,41 @@ // fletching through anything that is NOT the reddish wall, and take the angle // that reaches furthest. Validated against 16 real throws: r = 0.97 against // the launch angle actually flown. - function findAim(B, W, H) { + // hx, hy are the fletching in CSS pixels, as picked out of the downscaled + // frame by the blob search in the loop. They are only accurate to a /scale + // cell, which is why the centroid is re-taken here at native resolution — + // but they are accurate enough to say WHICH gold blob is the fletching, and + // that is the part the average used to get wrong. Averaging every gold pixel + // in the box put the origin between the fletching and whatever else the + // character had on: with the gold helmet the origin landed in the head, and + // the march then found the torso rather than the dart. See the hand blob + // search for the measurements. + function findAim(B, W, H, hx, hy) { const sx = B.sx / B.cvW * W, sy = B.sy / B.cvH * H; const kx = W / B.cvW, ky = H / B.cvH; - let gx = 0, gy = 0, gn = 0; + const ox = hx / W * B.cvW - B.sx, oy = hy / H * B.cvH - B.sy; + const seen = new Uint8Array(B.w * B.h), stack = []; + let gx = 0, gy = 0, gn = 0, bestD = Infinity; for (let y = 0; y < B.h; y++) for (let x = 0; x < B.w; x++) { - if (isGold(...px(B, x, y))) { gx += x; gy += y; gn++; } + const i = y * B.w + x; + if (seen[i] || !isGold(...px(B, x, y))) continue; + stack.length = 0; stack.push(i); seen[i] = 1; + let n = 0, ax = 0, ay = 0; + while (stack.length) { + const q = stack.pop(), qx = q % B.w, qy = (q / B.w) | 0; + n++; ax += qx; ay += qy; + for (const nb of [q - 1, q + 1, q - B.w, q + B.w]) { + if (nb < 0 || nb >= B.w * B.h || seen[nb]) continue; + if (Math.abs((nb % B.w) - qx) > 1) continue; // no wrap at the edges + if (isGold(...px(B, nb % B.w, (nb / B.w) | 0))) { seen[nb] = 1; stack.push(nb); } + } + } + if (n < 8) continue; + const cx = ax / n, cy = ay / n; + const d = (cx - ox) * (cx - ox) + (cy - oy) * (cy - oy); + if (d < bestD) { bestD = d; gx = cx; gy = cy; gn = n; } } - if (gn < 8) return null; - gx /= gn; gy /= gn; + if (!gn) return null; const notWall = (x, y) => { if (x < 0 || y < 0 || x >= B.w || y >= B.h) return false; const [h, s, v] = px(B, x, y); @@ -2889,7 +2915,43 @@ const R0 = Math.round(18 * scale), R1 = Math.round(100 * scale); const ext = []; let best = null; - for (let deg = -75; deg <= 80; deg++) { + // The scan used to start at -75, roughly 50 degrees below anything the + // game can actually produce, and that dead zone is where the aim went to + // die. Marching down from the fletching runs along the character's own + // torso, legs and the platform, which is a longer clear run than the dart + // ever offers, so whenever the dart read was weak the winner was whatever + // angle pointed at the floor — and the drawn line dived off the bottom of + // the screen. + // + // The real sweep was measured from five independent sources - four + // recordings replayed through this same code and one live capture: + // + // 2026-08-14 1214px canvas 1032 frames -25.4 .. +65.3 + // 2026-07-28 16-43 1312px 2938 frames -25.4 .. +64.6 + // 2026-07-28 17-14 1312px 2370 frames -28.0 .. +65.7 + // 2026-07-28 19-26 1312px 3044 frames -25.9 .. +65.0 + // live 1327.9px 125 frames -25.5 .. +64.8 + // + // ~11,200 accepted aims, and not one below -30 in any of them. The floor + // is NOT a tight constant: four sources cluster at -25.4..-25.9 and the + // fifth sits 2.6 degrees lower at -28.0, so treat -28 as the observed + // worst case rather than the true limit. In the live capture 38 further + // frames sat at -75.0 .. -70.8 - jammed against the old scan floor, with + // 44.5 degrees of empty space between them and the nearest real reading. + // Nothing legitimate lives down there. + // + // SWEEP_LO is set 12 degrees under the worst observed floor rather than + // hugging it. An earlier draft used -35, which left only 2 degrees of + // clearance against that -28.0 clip; since a fifth source moved the floor + // once, a sixth could move it again, and widening costs nothing because + // the boundary test below still catches a march that runs out of range. Angles are resolution independent, which is why this is + // the axis to guard on: reach looked like a perfect separator within one + // session (real 83-85.8 against dives at 59.5/73.3/80.2/99.6) but the same + // measurement off the recording spread to 82-100, and normalised by canvas + // width the two disagreed by 10%. A reach window wide enough for both lets + // the dives back in, so it is deliberately not used here. + const SWEEP_LO = -40; + for (let deg = SWEEP_LO; deg <= 80; deg++) { const th = deg * Math.PI / 180, ux = Math.cos(th), uy = -Math.sin(th); let reach = R0, gap = 0; for (let r = R0; r <= R1; r++) { @@ -2900,6 +2962,17 @@ if (!best || reach > best.reach) best = { deg, reach }; } if (!best || best.reach < 40 * scale) return null; + // Narrowing the scan alone only moves the problem: a march that wants to + // point at the floor now pins at SWEEP_LO instead of -75. But that is the + // tell. A real aim is an interior maximum — the reach falls away on both + // sides of it — whereas a march that ran out of range is still climbing + // when the scan stops, so it sits hard against the boundary. Every one of + // the 38 dive frames measured was within 4.2 degrees of the floor, so a + // 5-degree boundary band catches them all; the lowest real reading in + // ~11,200 aims was -28.0, which is 7 degrees clear of the -35 cutoff. + // Rejecting the boundary costs nothing real and removes what the clamp + // leaves behind. + if (best.deg <= SWEEP_LO + 5) return null; const near = ext.filter(e => e.reach >= best.reach - 4 * scale); if (near.length > 34) return null; // a broad plateau is the body, not a dart let sw = 0, sd = 0; @@ -3015,8 +3088,30 @@ // gold pixel on screen. Averaging dragged the "hand" into the bottom-left // corner whenever the "Get 9 Bullseye in a row" trophy hint was showing, // because its trophy icons are gold too. The hint sits in the bottom band - // and the HUD in the top one, so both are cut out; of what remains the - // leftmost blob is the hand, since a thrown dart only ever travels right. + // and the HUD in the top one, so both are cut out. + // + // Which of the remaining blobs is the fletching used to be answered with + // "the leftmost one, since a thrown dart only ever travels right". That is + // wrong whenever the character is WEARING something gold. Measured on the + // gold helmet, in the 250x250 native box around the player: the helmet is + // 261 gold pixels (h 42.0, s 0.57) against the fletching's 156 (h 46.9, + // s 0.80), and it fragments into seven blobs because the sprite's dark + // outline runs between the strands. The leftmost of those sits at x=116 + // where the fletching is at x=142, so the "hand" latched onto the helmet, + // findAim marched from the character's head instead of the chest, and the + // longest clear run from there is straight DOWN the torso and legs — which + // is why the predicted line dived off the bottom of the screen at + // aimDeg -56.8 while the dart was plainly held at about +40. + // + // Colour cannot separate them: helmets change colour with gear, so any + // hue or saturation window that excludes this helmet is only waiting for + // the next one. The separation that holds is structural — a helmet is worn + // on the head, the dart is held at chest height, so of the gold on the + // character the fletching is the LOWEST. The leftmost blob still picks the + // character out of the scene (a dart in flight is right of the thrower, and + // is what the x cut below is for); we then keep only blobs within a + // sprite's width of it and take the lowest of those, so a gold helmet + // anchors the search and no longer wins it. const hand = (() => { const y0 = Math.round(I.h * 0.14), y1 = Math.round(I.h * 0.88); // The thrower stays in the left half (measured 331-560px of 1326); the @@ -3024,7 +3119,7 @@ // being mistaken for the one in your hand. const x1 = Math.round(I.w * 0.62); const seen = new Uint8Array(I.w * I.h), stack = []; - let best = null; + const blobs = []; for (let y = y0; y < y1; y++) for (let x = 0; x < x1; x++) { const i = y * I.w + x; if (seen[i] || !isGold(...px(I, x, y))) continue; @@ -3041,8 +3136,16 @@ } } if (n < 4) continue; - if (!best || minx < best.minx) best = { x: sx / n * kx, y: sy / n * ky, n, minx }; + blobs.push({ x: sx / n * kx, y: sy / n * ky, n, minx, cy: sy / n }); } + if (!blobs.length) return null; + // The character sprite measured 55 native px wide of 960 (0.057 of the + // canvas). 0.08 gives room for a wide helmet either side of the body + // without reaching the next thing on screen. + const anchor = Math.min(...blobs.map(b => b.minx)); + const near = blobs.filter(b => b.minx - anchor <= I.w * 0.08); + let best = null; + for (const b of near) if (!best || b.cy > best.cy) best = b; return best; })(); @@ -3050,7 +3153,7 @@ let aim = null; if (hand) { const B = grabBox(cv, hand.x, hand.y, Math.max(120, W * 0.13), W, H); - if (B) aim = findAim(B, W, H); + if (B) aim = findAim(B, W, H, hand.x, hand.y); } if (aim) { // The sweep is smooth at roughly 3 deg per frame; anything wilder is the @@ -3109,6 +3212,14 @@ probe({ frame, board, wind, aimDeg, hand, hitBand, hitY, dart: dartPts.length, + // How far the winning march actually got, in css px. Published because + // it is the value that says whether findAim followed a DART or just ran + // off the end of its own search: a dart is a protrusion of finite length, + // the character's torso is not, so a march down the body only stops when + // it hits the R1 ceiling. Without this in the probe there is no way to + // tell those two apart after the fact. + aimReach: aim ? +aim.reach.toFixed(1) : null, + aimR1: 100, cal: { vN: cfg.vN, gN: cfg.gN, windK: cfg.windK, landN: cfg.landN } }); } From 28d547b8bc859a09e5c7fee84de99b2c6cc3a2b5 Mon Sep 17 00:00:00 2001 From: averagenative Date: Thu, 10 Sep 2026 12:48:17 -0400 Subject: [PATCH 2/8] Scale the wind digits and the aim reach floor to the canvas, not to pixels Two constants in this file were still in absolute pixels, against the standing rule that calibration is stored as fractions of canvas size. Both were harvested on a 1326-wide canvas and both misbehave on a 960-wide one. readMph gated glyphs on n<10, w 3..16, h 8..18. This crop comes out 51px tall at 1326 and 36px at 960, so every glyph is 28% smaller and the "11" in "11 mph" measured w=6 h=6 n=16 -- BOTH digits fell through the h<8 floor. The failure is worse than losing the number: two letterforms out of "mph" (w=7 h=8 n=30, and w=8 h=13 n=57) sail past the same gates, so the reader goes on to match leftover letters against digit templates and can return a confident wrong answer. Earlier cyan winds reading 6mph and 7mph on this canvas are suspect for that reason, and mph feeds straight into A = windK * mph * W. The gates and the digit/"mph" gap are now fractions of the crop height against REF_H=51, the height the templates were harvested at, so at S.h=51 they reproduce the old constants exactly. Verified live at 960: "3 mph" reads 3 and "11 mph" reads 11, where 11 returned null before. Single and double digit both confirmed against the HUD. findAim's reach floor was 40 css px flat, less than half what a real dart produces, so it caught almost nothing. On the game-over screen -- no dart in hand, the march running off a 5-pixel scrap of gold helmet -- reach was 42.9 and the helper drew a confident "+1" from it. The sweep clamp added in 22140b4 does not catch this: the march found a plausible in-range angle rather than pinning at the boundary, so that screen is NOT fixed by the clamp as the previous commit's message implies. The floor must not be set from the minimum reach a recording reports, because that minimum is an artifact of wherever the floor already sits -- it censors the tail being measured. Lowering it from 0.05 to 0.040 "discovered" reaches of 54-64 that 0.05 had been hiding, which is circular and nearly shipped a threshold sitting 0.4px off real data. Measured with the floor disabled, the distribution is bimodal and the gap is plain (reach in css px, W=1312): 17-14 19-26 30-80 32 (2.5%) 51 (5.5%) sparse scatter 80-105 1264 (97.5%) 873 (94.5%) the dart, sharply from 80 2220 accepted frames over two clips, real mode starting at 80 css = 0.0610 W in both; live agrees at 0.0625-0.0646 W. The no-dart march was 0.0323 W, inside the scatter. 0.055 sits in the empty region between the modes rather than being fitted to either edge. Replayed across all four recordings: the dense mode is preserved whole and only the sub-mode scatter goes (17-14 1296 -> 1269, 19-26 924 -> 876). Note that accept counts must be read off aimReach, not aimDeg -- aimDeg persists across frames when findAim returns null, so it does not measure rejection at all. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-darts.user.js | 80 +++++++++++++++++++++++++++++++++++++++++--- idleon-suite.user.js | 80 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 152 insertions(+), 8 deletions(-) diff --git a/idleon-darts.user.js b/idleon-darts.user.js index 50e569c..b474d87 100644 --- a/idleon-darts.user.js +++ b/idleon-darts.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Darts Helper // @namespace nativerobot -// @version 1.8 +// @version 1.9 // @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 @@ -379,6 +379,32 @@ const mx = Math.max(r, g, b), mn = Math.min(r, g, b); return mx > 110 && (mx - mn) > 45; }; + // Glyph size gates, as fractions of the crop height rather than raw pixels. + // They used to be absolute -- n<10, w 3..16, h 8..18 -- harvested from a + // 1326-wide canvas where this crop comes out 51px tall. On a 960-wide + // canvas the same crop is 36px and every glyph is 28% smaller, so the "11" + // in "11 mph" measured w=6 h=6 n=16 and BOTH digits fell through the h<8 + // floor. Worse than losing the number: two letterforms out of "mph" + // (w=7 h=8 n=30, and w=8 h=13 n=57) sailed past the same gates, so the + // reader went on to match leftover letters against digit templates and + // could return a confident wrong answer instead of null. Yesterday's cyan + // winds reading "6mph" and "7mph" on this canvas are suspect for exactly + // that reason, and mph feeds straight into A = windK * mph * W. + // + // The reference is the 51px crop the templates were harvested at, so the + // ratios below are the old constants over 51 (and over 51^2 for the pixel + // count, which scales with area). At S.h=36 that gives h 5.7..12.7, + // w 2.1..11.3, n>=5: the digits at h=6 are kept, the h=13 ascender of "h" + // is now correctly rejected, and the gap rule below still cuts before the + // rest of "mph". + const REF_H = 51; + const k = S.h / REF_H; + const G = { + nMin: 10 * k * k, + wMin: 3 * k, wMax: 16 * k, + hMin: 8 * k, hMax: 18 * k, + gap: 16 * k // the space before "mph" starts + }; const seen = new Uint8Array(S.w * S.h), glyphs = [], st = []; for (let y = 0; y < S.h; y++) for (let x = 0; x < S.w; x++) { const i = y * S.w + x; @@ -398,7 +424,7 @@ } } const w = x1 - x0 + 1, h = y1 - y0 + 1; - if (n < 10 || w < 3 || w > 16 || h < 8 || h > 18) continue; + if (n < G.nMin || w < G.wMin || w > G.wMax || h < G.hMin || h > G.hMax) continue; const g = new Uint8Array(w * h); for (const [cx, cy] of cells) g[(cy - y0) * w + (cx - x0)] = 1; glyphs.push({ x0, w, h, g }); @@ -407,7 +433,7 @@ if (!glyphs.length) return null; const digits = []; for (let i = 0; i < glyphs.length; i++) { - if (i > 0 && glyphs[i].x0 - glyphs[i - 1].x0 > 16) break; // gap before "mph" + if (i > 0 && glyphs[i].x0 - glyphs[i - 1].x0 > G.gap) break; // gap before "mph" digits.push(glyphs[i]); } if (!digits.length || digits.length > 2) return null; @@ -523,7 +549,53 @@ ext.push({ deg, reach }); if (!best || reach > best.reach) best = { deg, reach }; } - if (!best || best.reach < 40 * scale) return null; + // A march has to run at least as far as a dart does, or it did not find a + // dart. This floor used to be 40 CSS px flat -- absolute pixels again, and + // set at less than half of what a real dart actually produces, so it caught + // almost nothing. Measured reach for a genuine in-hand dart: + // + // live W=1327.9 83.0 .. 85.8 -> 0.0625 .. 0.0646 W + // 08-14 W=1214 82 .. 100 -> 0.0675 .. 0.0824 W + // 07-28 16-43 W=1312 66 .. 100 -> 0.0503 .. 0.0762 W + // 07-28 17-14 W=1312 66 .. 100 -> 0.0503 .. 0.0762 W + // 07-28 19-26 W=1312 69 .. 100 -> 0.0526 .. 0.0762 W + // + // and on the game-over screen, where the character holds nothing and the + // march ran off a 5-pixel scrap of helmet, it was 42.9 css -> 0.0323 W. + // The old floor let that through by 2.9px and the helper drew a confident + // "+1" from it. + // + // Do NOT set this by looking at the minimum reach a recording reports: + // that minimum is an artifact of wherever the floor already is, because + // the floor censors the very tail you are trying to measure. Lowering it + // from 0.05 to 0.040 "discovered" reaches of 54-64 that the 0.05 floor had + // been hiding, which is circular and nearly shipped a threshold sitting + // 0.4px off real data. + // + // Measured properly, with the floor disabled entirely, the distribution is + // bimodal and the gap is obvious (bins are reach in css px on W=1312): + // + // 17-14 19-26 + // 30-80 32 (2.5%) 51 (5.5%) sparse scatter + // 80-105 1264 (97.5%) 873 (94.5%) the dart, sharply from 80 + // + // 2220 accepted frames across the two clips, and the real mode begins at + // 80 css = 0.0610 W in both. Live agrees: 83.0-85.8 on W=1327.9 = 0.0625 + // -0.0646 W. The one measured no-dart march was 42.9 css = 0.0323 W, well + // inside the scatter. 0.055 sits in the empty region between the modes -- + // 11% under the real mode's edge and 41% over the bogus reading -- rather + // than being fitted to either edge. It discards the sub-mode scatter too, + // which costs nothing: that is 2-5% of frames and the aim survives 400ms + // of staleness anyway. + // + // Note this is a floor, NOT the reach window rejected earlier in this file: + // that needed an upper bound too, and the upper end did not transfer across + // resolutions. A floor is set from the real distribution, which is well + // sampled at both resolutions, and does not care what the top end does. + // Caveat for whoever tunes this next: the real side has 800+ samples, the + // no-dart side has exactly one. + const REACH_MIN_W = 0.055; // fraction of canvas width + if (!best || best.reach < REACH_MIN_W * B.cvW) return null; // Narrowing the scan alone only moves the problem: a march that wants to // point at the floor now pins at SWEEP_LO instead of -75. But that is the // tell. A real aim is an interior maximum — the reach falls away on both diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 6d38c24..db0a82c 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.22 +// @version 1.24 // @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 @@ -2817,6 +2817,32 @@ const mx = Math.max(r, g, b), mn = Math.min(r, g, b); return mx > 110 && (mx - mn) > 45; }; + // Glyph size gates, as fractions of the crop height rather than raw pixels. + // They used to be absolute -- n<10, w 3..16, h 8..18 -- harvested from a + // 1326-wide canvas where this crop comes out 51px tall. On a 960-wide + // canvas the same crop is 36px and every glyph is 28% smaller, so the "11" + // in "11 mph" measured w=6 h=6 n=16 and BOTH digits fell through the h<8 + // floor. Worse than losing the number: two letterforms out of "mph" + // (w=7 h=8 n=30, and w=8 h=13 n=57) sailed past the same gates, so the + // reader went on to match leftover letters against digit templates and + // could return a confident wrong answer instead of null. Yesterday's cyan + // winds reading "6mph" and "7mph" on this canvas are suspect for exactly + // that reason, and mph feeds straight into A = windK * mph * W. + // + // The reference is the 51px crop the templates were harvested at, so the + // ratios below are the old constants over 51 (and over 51^2 for the pixel + // count, which scales with area). At S.h=36 that gives h 5.7..12.7, + // w 2.1..11.3, n>=5: the digits at h=6 are kept, the h=13 ascender of "h" + // is now correctly rejected, and the gap rule below still cuts before the + // rest of "mph". + const REF_H = 51; + const k = S.h / REF_H; + const G = { + nMin: 10 * k * k, + wMin: 3 * k, wMax: 16 * k, + hMin: 8 * k, hMax: 18 * k, + gap: 16 * k // the space before "mph" starts + }; const seen = new Uint8Array(S.w * S.h), glyphs = [], st = []; for (let y = 0; y < S.h; y++) for (let x = 0; x < S.w; x++) { const i = y * S.w + x; @@ -2836,7 +2862,7 @@ } } const w = x1 - x0 + 1, h = y1 - y0 + 1; - if (n < 10 || w < 3 || w > 16 || h < 8 || h > 18) continue; + if (n < G.nMin || w < G.wMin || w > G.wMax || h < G.hMin || h > G.hMax) continue; const g = new Uint8Array(w * h); for (const [cx, cy] of cells) g[(cy - y0) * w + (cx - x0)] = 1; glyphs.push({ x0, w, h, g }); @@ -2845,7 +2871,7 @@ if (!glyphs.length) return null; const digits = []; for (let i = 0; i < glyphs.length; i++) { - if (i > 0 && glyphs[i].x0 - glyphs[i - 1].x0 > 16) break; // gap before "mph" + if (i > 0 && glyphs[i].x0 - glyphs[i - 1].x0 > G.gap) break; // gap before "mph" digits.push(glyphs[i]); } if (!digits.length || digits.length > 2) return null; @@ -2961,7 +2987,53 @@ ext.push({ deg, reach }); if (!best || reach > best.reach) best = { deg, reach }; } - if (!best || best.reach < 40 * scale) return null; + // A march has to run at least as far as a dart does, or it did not find a + // dart. This floor used to be 40 CSS px flat -- absolute pixels again, and + // set at less than half of what a real dart actually produces, so it caught + // almost nothing. Measured reach for a genuine in-hand dart: + // + // live W=1327.9 83.0 .. 85.8 -> 0.0625 .. 0.0646 W + // 08-14 W=1214 82 .. 100 -> 0.0675 .. 0.0824 W + // 07-28 16-43 W=1312 66 .. 100 -> 0.0503 .. 0.0762 W + // 07-28 17-14 W=1312 66 .. 100 -> 0.0503 .. 0.0762 W + // 07-28 19-26 W=1312 69 .. 100 -> 0.0526 .. 0.0762 W + // + // and on the game-over screen, where the character holds nothing and the + // march ran off a 5-pixel scrap of helmet, it was 42.9 css -> 0.0323 W. + // The old floor let that through by 2.9px and the helper drew a confident + // "+1" from it. + // + // Do NOT set this by looking at the minimum reach a recording reports: + // that minimum is an artifact of wherever the floor already is, because + // the floor censors the very tail you are trying to measure. Lowering it + // from 0.05 to 0.040 "discovered" reaches of 54-64 that the 0.05 floor had + // been hiding, which is circular and nearly shipped a threshold sitting + // 0.4px off real data. + // + // Measured properly, with the floor disabled entirely, the distribution is + // bimodal and the gap is obvious (bins are reach in css px on W=1312): + // + // 17-14 19-26 + // 30-80 32 (2.5%) 51 (5.5%) sparse scatter + // 80-105 1264 (97.5%) 873 (94.5%) the dart, sharply from 80 + // + // 2220 accepted frames across the two clips, and the real mode begins at + // 80 css = 0.0610 W in both. Live agrees: 83.0-85.8 on W=1327.9 = 0.0625 + // -0.0646 W. The one measured no-dart march was 42.9 css = 0.0323 W, well + // inside the scatter. 0.055 sits in the empty region between the modes -- + // 11% under the real mode's edge and 41% over the bogus reading -- rather + // than being fitted to either edge. It discards the sub-mode scatter too, + // which costs nothing: that is 2-5% of frames and the aim survives 400ms + // of staleness anyway. + // + // Note this is a floor, NOT the reach window rejected earlier in this file: + // that needed an upper bound too, and the upper end did not transfer across + // resolutions. A floor is set from the real distribution, which is well + // sampled at both resolutions, and does not care what the top end does. + // Caveat for whoever tunes this next: the real side has 800+ samples, the + // no-dart side has exactly one. + const REACH_MIN_W = 0.055; // fraction of canvas width + if (!best || best.reach < REACH_MIN_W * B.cvW) return null; // Narrowing the scan alone only moves the problem: a march that wants to // point at the floor now pins at SWEEP_LO instead of -75. But that is the // tell. A real aim is an interior maximum — the reach falls away on both From 0dd22bfa9ef9af2a2bd39fd0fc33355772cd8d8a Mon Sep 17 00:00:00 2001 From: averagenative Date: Thu, 10 Sep 2026 15:17:53 -0400 Subject: [PATCH 3/8] Actually track the thrown dart, instead of pretending to "Track thrown dart" has been a checkbox with nothing behind it. dartPts was declared, cleared once when the screen was gated out, and never written; the only read was `if (cfg.live && hand && dartPts.length) { /* nothing to do */ }`. lastDartT, flightWind and flightAim were declared on the same line and never referenced again. So the probe reported dart:0 for every frame ever recorded, and the panel offered a feature that did not exist. It matters because the flight is the only place the model can be checked against reality. A landing point alone cannot separate vN from gN from landN -- they trade off against each other -- but a tracked flight gives position against time, which fits speed and gravity directly. The tracker takes gold blobs from a corridor between the thrower and the board. The right edge stops short of the board because darts stuck in it keep their fletchings and would otherwise look like a permanent crowd of candidates: measured on the live canvas, stuck fletchings sit at css x 1191 against a board at 1272.6, so 0.08 W clears them. isGold already separates a fletching from the board's own tan bands -- the fletching palette is h=50 s=0.74 and h=40 s=0.94, the bands are all s<=0.45 -- so no new colour rule was needed. A launch is a blob that was not there last frame, which avoids needing to know where the hand is: the moment the dart leaves, the hand search has no fletching left and falls back to whatever else the character is wearing. The aim and wind are captured AT RELEASE and carried with the flight, so a residual no longer has to be guessed backwards from a landing. Continuing a track REQUIRES forward progress, not merely "not backwards". There is no horizontal drag, so a real dart advances the same amount every frame, always well over the STILL threshold. A first cut accepted same-place matches and let finished tracks latch onto a stationary fletching forever: flights of 3.2 and 3.7 seconds, and a dart reported airborne for 63% of all frames. With forward progress required, replayed against two recordings: 2026-08-14 9 flights dur 0.133 / 0.767 / 1.267 airborne 31% 2026-07-28 34 flights dur 0.200 / 0.800 / 0.967 airborne 53% none over 2s, where before there were several. The finished flight is published on the probe with every observed position, and the observed path is drawn so a wrong track is visible rather than silent. First fit off 8 flights of the 08-14 clip, which has no wind: |v| clusters at 741-827 px/s (median 781 -> vN 0.643 against the configured 0.548), while g scatters from 74 to 909 and is not usable -- a t^2 coefficient fitted to ~20 points across a shallow 0.8s arc is badly conditioned. The fitted launch angle tracks aimAtRelease to a median of 3.6 degrees, which is a decent check on the aim read. No calibration constant is changed here: that wants many more flights across all four recordings, and a gN measurement that is actually determined. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-darts.user.js | 125 +++++++++++++++++++++++++++++++++++++++++-- idleon-suite.user.js | 125 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 244 insertions(+), 6 deletions(-) diff --git a/idleon-darts.user.js b/idleon-darts.user.js index b474d87..602ac66 100644 --- a/idleon-darts.user.js +++ b/idleon-darts.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Darts Helper // @namespace nativerobot -// @version 1.9 +// @version 1.10 // @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 @@ -630,6 +630,36 @@ let frame = 0, board = null, boardT = 0, wind = { key: 'none', deg: 0 }; let aimDeg = null, aimT = 0, lastAim = null, lastAimF = -99; let dartPts = [], lastDartT = 0, flightWind = 'none', flightAim = null; + let prevFly = [], lastFlight = null, flightT0 = 0; + + // Every gold blob inside a rectangle of the downscaled frame, in css coords. + // The hand search does its own copy of this over the LEFT of the screen; this + // one exists for the right, where a thrown dart lives. Kept separate rather + // than shared because the two want different rejection rules: the hand search + // has to pick one blob out of a cluster on the character, this one wants all + // of them so motion can be matched frame to frame. + function goldBlobs(I, xa, xb, ya, yb, kx, ky) { + xa = Math.max(0, xa | 0); xb = Math.min(I.w, xb | 0); + ya = Math.max(0, ya | 0); yb = Math.min(I.h, yb | 0); + const seen = new Uint8Array(I.w * I.h), stack = [], out = []; + for (let y = ya; y < yb; y++) for (let x = xa; x < xb; x++) { + const i = y * I.w + x; + if (seen[i] || !isGold(...px(I, x, y))) continue; + stack.length = 0; stack.push(i); seen[i] = 1; + let n = 0, sx = 0, sy = 0; + while (stack.length) { + const q = stack.pop(), qx = q % I.w, qy = (q / I.w) | 0; + n++; sx += qx; sy += qy; + for (const nb of [q - 1, q + 1, q - I.w, q + I.w]) { + const nx = nb % I.w, ny = (nb / I.w) | 0; + if (ny < ya || ny >= yb || nx < xa || nx >= xb || seen[nb]) continue; + if (isGold(...px(I, nx, ny))) { seen[nb] = 1; stack.push(nb); } + } + } + if (n >= 4) out.push({ x: sx / n * kx, y: sy / n * ky, n }); + } + return out; + } // Predict the flight from a launch point and angle. function predict(x0, y0, deg, W, H, wnd) { @@ -704,7 +734,7 @@ if (!I) { stEl.textContent = readErr; probe({ frame, idle: readErr }); return; } if (wallFrac(I) < 0.35) { - board = null; dartPts = []; aimDeg = null; + board = null; dartPts = []; aimDeg = null; prevFly = []; if (frame % 15 === 0) stEl.textContent = 'idle\nnot in Throwy Darts'; probe({ frame, idle: 'gated out: wall < 35%' }); return; @@ -834,7 +864,90 @@ } // ---- a dart already in the air ---- - if (cfg.live && hand && dartPts.length) { /* hand still holds one; nothing to do */ } + // This used to be a stub: dartPts was declared, cleared once, and never + // written, so "Track thrown dart" did nothing and the probe reported + // dart:0 forever. It matters because the flight is the only place the + // model can actually be checked -- comparing predicted to observed + // positions measures vN and gN directly, where a landing point alone + // cannot separate them from landN. + // + // The corridor: left edge past the thrower, right edge short of the board, + // because darts already stuck in it keep their fletchings and would look + // like a permanent crowd of candidates. Measured on the live canvas, stuck + // fletchings sit at css x 1191 against a board at 1272.6, i.e. 0.061 W + // clear of it, so 0.08 W excludes them with room to spare. The cost is + // that the last stretch of flight is not seen; that is fine, the fit does + // not need the impact point. + if (cfg.live && board) { + const xa = 0.30 * W, xb = board.x - 0.08 * W; + const fly = goldBlobs(I, xa / kx, xb / kx, I.h * 0.14, I.h * 0.88, kx, ky); + // A dart in flight MOVES; the helmet and the stuck darts do not. Launch + // speed is cfg.vN*W ~ 728 css px/s on this canvas, so at rAF rates a + // real dart steps roughly 12px per frame. Anything that reappears within + // a few px of where it sat last frame is scenery. + const STILL = 0.004 * W; // ~5px, below one frame of travel + const STEP = 0.06 * W; // ~80px, well over one frame + if (dartPts.length) { + const last = dartPts[dartPts.length - 1]; + let pick = null, bd = Infinity; + for (const f of fly) { + // Forward progress is REQUIRED, not just "not backwards". There is no + // drag on the horizontal axis, so a real dart advances by the same + // amount every frame for the whole flight -- cfg.vN*W ~ 728 css px/s, + // which is ~12px at rAF rates and more in a 30fps replay, always well + // over STILL. Accepting a same-place match instead let a finished + // track latch onto a stationary fletching and never time out: flights + // of 3.2 and 3.7 seconds, and a dart reported in the air for 63% of + // all frames when the real duty cycle is nearer a third. + if (f.x < last.x + STILL) continue; + const d = Math.hypot(f.x - last.x, f.y - last.y); + if (d < bd && d <= STEP) { bd = d; pick = f; } + } + if (pick) { dartPts.push({ t, x: pick.x, y: pick.y }); lastDartT = t; } + else if (t - lastDartT > 250) { + // Flight over: hand the whole thing to the probe in one piece, with + // the aim and wind captured at RELEASE rather than whatever the + // sweep has moved on to since. + if (dartPts.length >= 4) { + lastFlight = { + n: dartPts.length, t0: flightT0, dur: +((lastDartT - flightT0) / 1000).toFixed(3), + aim: flightAim, wind: flightWind, + x0: +dartPts[0].x.toFixed(1), y0: +dartPts[0].y.toFixed(1), + pts: dartPts.map(p => ({ dt: +((p.t - flightT0) / 1000).toFixed(3), + x: +p.x.toFixed(1), y: +p.y.toFixed(1) })) + }; + } + dartPts = []; + } + } else { + // No flight in progress: a dart is one that was NOT sitting there last + // frame. Matching against the previous frame is what separates a + // launch from the scenery, without needing to know where the hand is — + // which matters because the moment the dart leaves, the hand search + // has no fletching left to find and falls back to the helmet. + for (const f of fly) { + const wasThere = prevFly.some(p => Math.hypot(p.x - f.x, p.y - f.y) <= STILL); + if (wasThere) continue; + dartPts = [{ t, x: f.x, y: f.y }]; + flightT0 = t; lastDartT = t; + flightAim = aimDeg !== null ? +aimDeg.toFixed(2) : null; + flightWind = { key: wind.key, deg: +(wind.deg || 0).toFixed(1), mph: wind.mph || null }; + break; + } + } + prevFly = fly; + // Draw what was actually observed, so the checkbox does something + // visible and a wrong track is obvious rather than silent. + if (dartPts.length > 1) { + octx.save(); + octx.strokeStyle = '#38bdf8'; octx.lineWidth = 2; + octx.shadowColor = 'rgba(0,0,0,.7)'; octx.shadowBlur = 3; + octx.beginPath(); octx.moveTo(dartPts[0].x, dartPts[0].y); + for (const p of dartPts) octx.lineTo(p.x, p.y); + octx.stroke(); + octx.restore(); + } + } else { prevFly = []; } if (frame % 8 === 0) { const w = wind.key === 'none' ? 'no wind' @@ -847,6 +960,12 @@ probe({ frame, board, wind, aimDeg, hand, hitBand, hitY, dart: dartPts.length, + // The finished flight, published once and then left in place until the + // next one replaces it: how long it took, where it started, the aim and + // wind AT RELEASE, and every observed position. This is what a residual + // is computed from -- predicted vs observed at matching dt -- instead of + // guessing the release moment backwards from a landing. + flight: lastFlight, // How far the winning march actually got, in css px. Published because // it is the value that says whether findAim followed a DART or just ran // off the end of its own search: a dart is a protrusion of finite length, diff --git a/idleon-suite.user.js b/idleon-suite.user.js index db0a82c..4208bd1 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.24 +// @version 1.26 // @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 @@ -3068,6 +3068,36 @@ let frame = 0, board = null, boardT = 0, wind = { key: 'none', deg: 0 }; let aimDeg = null, aimT = 0, lastAim = null, lastAimF = -99; let dartPts = [], lastDartT = 0, flightWind = 'none', flightAim = null; + let prevFly = [], lastFlight = null, flightT0 = 0; + + // Every gold blob inside a rectangle of the downscaled frame, in css coords. + // The hand search does its own copy of this over the LEFT of the screen; this + // one exists for the right, where a thrown dart lives. Kept separate rather + // than shared because the two want different rejection rules: the hand search + // has to pick one blob out of a cluster on the character, this one wants all + // of them so motion can be matched frame to frame. + function goldBlobs(I, xa, xb, ya, yb, kx, ky) { + xa = Math.max(0, xa | 0); xb = Math.min(I.w, xb | 0); + ya = Math.max(0, ya | 0); yb = Math.min(I.h, yb | 0); + const seen = new Uint8Array(I.w * I.h), stack = [], out = []; + for (let y = ya; y < yb; y++) for (let x = xa; x < xb; x++) { + const i = y * I.w + x; + if (seen[i] || !isGold(...px(I, x, y))) continue; + stack.length = 0; stack.push(i); seen[i] = 1; + let n = 0, sx = 0, sy = 0; + while (stack.length) { + const q = stack.pop(), qx = q % I.w, qy = (q / I.w) | 0; + n++; sx += qx; sy += qy; + for (const nb of [q - 1, q + 1, q - I.w, q + I.w]) { + const nx = nb % I.w, ny = (nb / I.w) | 0; + if (ny < ya || ny >= yb || nx < xa || nx >= xb || seen[nb]) continue; + if (isGold(...px(I, nx, ny))) { seen[nb] = 1; stack.push(nb); } + } + } + if (n >= 4) out.push({ x: sx / n * kx, y: sy / n * ky, n }); + } + return out; + } // Predict the flight from a launch point and angle. function predict(x0, y0, deg, W, H, wnd) { @@ -3141,7 +3171,7 @@ if (!I) { stEl.textContent = readErr; probe({ frame, idle: readErr }); return; } if (wallFrac(I) < 0.35) { - board = null; dartPts = []; aimDeg = null; + board = null; dartPts = []; aimDeg = null; prevFly = []; if (frame % 15 === 0) stEl.textContent = 'idle\nnot in Throwy Darts'; probe({ frame, idle: 'gated out: wall < 35%' }); return; @@ -3271,7 +3301,90 @@ } // ---- a dart already in the air ---- - if (cfg.live && hand && dartPts.length) { /* hand still holds one; nothing to do */ } + // This used to be a stub: dartPts was declared, cleared once, and never + // written, so "Track thrown dart" did nothing and the probe reported + // dart:0 forever. It matters because the flight is the only place the + // model can actually be checked -- comparing predicted to observed + // positions measures vN and gN directly, where a landing point alone + // cannot separate them from landN. + // + // The corridor: left edge past the thrower, right edge short of the board, + // because darts already stuck in it keep their fletchings and would look + // like a permanent crowd of candidates. Measured on the live canvas, stuck + // fletchings sit at css x 1191 against a board at 1272.6, i.e. 0.061 W + // clear of it, so 0.08 W excludes them with room to spare. The cost is + // that the last stretch of flight is not seen; that is fine, the fit does + // not need the impact point. + if (cfg.live && board) { + const xa = 0.30 * W, xb = board.x - 0.08 * W; + const fly = goldBlobs(I, xa / kx, xb / kx, I.h * 0.14, I.h * 0.88, kx, ky); + // A dart in flight MOVES; the helmet and the stuck darts do not. Launch + // speed is cfg.vN*W ~ 728 css px/s on this canvas, so at rAF rates a + // real dart steps roughly 12px per frame. Anything that reappears within + // a few px of where it sat last frame is scenery. + const STILL = 0.004 * W; // ~5px, below one frame of travel + const STEP = 0.06 * W; // ~80px, well over one frame + if (dartPts.length) { + const last = dartPts[dartPts.length - 1]; + let pick = null, bd = Infinity; + for (const f of fly) { + // Forward progress is REQUIRED, not just "not backwards". There is no + // drag on the horizontal axis, so a real dart advances by the same + // amount every frame for the whole flight -- cfg.vN*W ~ 728 css px/s, + // which is ~12px at rAF rates and more in a 30fps replay, always well + // over STILL. Accepting a same-place match instead let a finished + // track latch onto a stationary fletching and never time out: flights + // of 3.2 and 3.7 seconds, and a dart reported in the air for 63% of + // all frames when the real duty cycle is nearer a third. + if (f.x < last.x + STILL) continue; + const d = Math.hypot(f.x - last.x, f.y - last.y); + if (d < bd && d <= STEP) { bd = d; pick = f; } + } + if (pick) { dartPts.push({ t, x: pick.x, y: pick.y }); lastDartT = t; } + else if (t - lastDartT > 250) { + // Flight over: hand the whole thing to the probe in one piece, with + // the aim and wind captured at RELEASE rather than whatever the + // sweep has moved on to since. + if (dartPts.length >= 4) { + lastFlight = { + n: dartPts.length, t0: flightT0, dur: +((lastDartT - flightT0) / 1000).toFixed(3), + aim: flightAim, wind: flightWind, + x0: +dartPts[0].x.toFixed(1), y0: +dartPts[0].y.toFixed(1), + pts: dartPts.map(p => ({ dt: +((p.t - flightT0) / 1000).toFixed(3), + x: +p.x.toFixed(1), y: +p.y.toFixed(1) })) + }; + } + dartPts = []; + } + } else { + // No flight in progress: a dart is one that was NOT sitting there last + // frame. Matching against the previous frame is what separates a + // launch from the scenery, without needing to know where the hand is — + // which matters because the moment the dart leaves, the hand search + // has no fletching left to find and falls back to the helmet. + for (const f of fly) { + const wasThere = prevFly.some(p => Math.hypot(p.x - f.x, p.y - f.y) <= STILL); + if (wasThere) continue; + dartPts = [{ t, x: f.x, y: f.y }]; + flightT0 = t; lastDartT = t; + flightAim = aimDeg !== null ? +aimDeg.toFixed(2) : null; + flightWind = { key: wind.key, deg: +(wind.deg || 0).toFixed(1), mph: wind.mph || null }; + break; + } + } + prevFly = fly; + // Draw what was actually observed, so the checkbox does something + // visible and a wrong track is obvious rather than silent. + if (dartPts.length > 1) { + octx.save(); + octx.strokeStyle = '#38bdf8'; octx.lineWidth = 2; + octx.shadowColor = 'rgba(0,0,0,.7)'; octx.shadowBlur = 3; + octx.beginPath(); octx.moveTo(dartPts[0].x, dartPts[0].y); + for (const p of dartPts) octx.lineTo(p.x, p.y); + octx.stroke(); + octx.restore(); + } + } else { prevFly = []; } if (frame % 8 === 0) { const w = wind.key === 'none' ? 'no wind' @@ -3284,6 +3397,12 @@ probe({ frame, board, wind, aimDeg, hand, hitBand, hitY, dart: dartPts.length, + // The finished flight, published once and then left in place until the + // next one replaces it: how long it took, where it started, the aim and + // wind AT RELEASE, and every observed position. This is what a residual + // is computed from -- predicted vs observed at matching dt -- instead of + // guessing the release moment backwards from a landing. + flight: lastFlight, // How far the winning march actually got, in css px. Published because // it is the value that says whether findAim followed a DART or just ran // off the end of its own search: a dart is a protrusion of finite length, From 5a407a6c58131458f9d29b046f90b0fbe9c17a1e Mon Sep 17 00:00:00 2001 From: averagenative Date: Fri, 11 Sep 2026 10:40:22 -0400 Subject: [PATCH 4/8] Correct the launch angle findAim has been under-reading by 4.18 degrees Darts have been landing above the predicted line for as long as anyone has looked. It is not vN, not gN, not the wind: findAim reads the dart's visual axis, and the dart does not fly along it. Measured against 12 no-wind flights tracked by the code added in 0dd22bf, fitting position against time and comparing the angle flown to the angle reported at release: aim 4.50 -> flown 9.48 +4.98 aim 21.50 -> flown 25.59 +4.09 aim 23.50 -> flown 27.61 +4.11 aim 28.09 -> flown 32.69 +4.60 aim 35.22 -> flown 38.69 +3.47 aim 15.28 -> flown 19.64 +4.36 aim 21.94 -> flown 25.85 +3.91 aim 22.57 -> flown 27.20 +4.63 aim 38.00 -> flown 41.27 +3.27 aim 10.97 -> flown 15.35 +4.38 aim 18.54 -> flown 22.98 +4.44 aim 13.77 -> flown 18.11 +4.34 mean +4.18, sd 0.47, and a slope against aim angle of -0.04 deg/deg: a constant offset, not a scaling error. Propagated to the board that puts the line 44-60px below the dart, worse at shallow aims, which matches the residuals measured off the board directly (-55 to -90px, 22 of 23 negative). Why it went undetected: the note on findAim claimed "validated against 16 real throws: r = 0.97 against the launch angle actually flown". r is a CORRELATION and cannot see a constant offset -- a reading biased by a fixed 4 degrees still scores 0.97. The validation measured the wrong statistic. The comment now says so, because the obvious way to re-check this is to compute r again and conclude everything is fine. vN and gN are NOT the problem and are left alone. The same 12 flights give |v| median 734 px/s (sd 6) -> 0.553 against the configured 0.548, and g median 454 px/s^2 (sd 16) -> 0.607 against 0.612. Both inside 1%. An earlier fit off a recording put vN 17% low; that was 8 sparse flights with a badly conditioned quadratic, and it was wrong. landN goes to zero and calVer to 5. That term only ever existed to absorb this residual -- at -0.023 it was cancelling about a third of the bias -- so with the angle corrected at source, keeping it would over-correct the other way. Zero rather than deleted, because a real residual may remain now that the aim is right; it should be measured off a tracked flight, not fitted through the other three constants. AIM_BIAS is the figure measured at the first tracked point. Extrapolating back to the launch point suggests slightly more (+5.23, sd 0.98), but that rests on pairing releases to flights by index -- 33 releases against 30 flights -- and is driven by the rows with the largest inferred gaps. The flight record now carries its own launch point so the next session measures it directly; refine then. Replayed against 2026-08-14: 722 accepted aims, unchanged; range moves -25.4.. 65.3 to -21.2..69.5, exactly the shift; nothing newly rejected at either end; flight tracking unaffected at 315 airborne frames. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-darts.user.js | 74 ++++++++++++++++++++++++++++++++++---------- idleon-suite.user.js | 74 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 114 insertions(+), 34 deletions(-) diff --git a/idleon-darts.user.js b/idleon-darts.user.js index 602ac66..eb745ca 100644 --- a/idleon-darts.user.js +++ b/idleon-darts.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Darts Helper // @namespace nativerobot -// @version 1.10 +// @version 1.11 // @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 @@ -29,9 +29,13 @@ band: true, // name the band you would hit live: true, // track a dart already in the air debug: false, - calVer: 4, - // Measured from 16 tracked throws. Speed is normalised by canvas width, - // gravity and wind by width too (the game keeps its aspect ratio). + calVer: 5, + // Confirmed v5 against 12 no-wind flights tracked at 1327.9x747, fitting + // position against time directly rather than inferring from landings: + // |v| median 734 px/s (sd 6) -> 0.553, and g median 454 px/s^2 (sd 16) -> + // 0.607. Both within 1% of the values below, so these are left alone. An + // earlier fit off a recording suggested vN was 17% low; that came from 8 + // sparse flights with a badly conditioned quadratic and was wrong. vN: 0.548, // launch speed / width, per second gN: 0.612, // gravity / height // v4: windK re-measured from a recording holding two wind states — four @@ -41,14 +45,21 @@ // degeneracy, and both clusters agree: 0.0158 up, 0.0157 down. Symmetric // and well-determined, unlike the old 0.0135 (fit tangled with landN). windK: 0.0158, // acceleration per mph, as a fraction of canvas width - // The landing residual soaked up part of the wind error while windK was - // low — the old -0.074 predicted ~30px high on every throw once windK is - // right. Re-fit with the wind term fixed at its measured value: 9 of the - // 10 recorded throws land within half a band (the 10th misses by 44px, - // just over). The unexplained leftover splits +-20px WITH the wind sign, - // so some vertical wind coupling is still not understood — but it is well - // inside the 77px band and not worth chasing on 10 throws. - landN: -0.023, // landing correction / height + // v5: ZERO, because the thing it was correcting turned out to be a bug. + // This term only ever existed to soak up an unexplained landing residual, + // and the residual is now explained: findAim under-read the launch angle + // by a constant 4.18 deg (see AIM_BIAS), which puts the predicted line + // 44-60px below the dart. landN was absorbing roughly a third of that at + // -0.023 (-17px on a 747px canvas). With the angle corrected at source, + // keeping landN would over-correct in the opposite direction. + // + // It is zero rather than deleted because a real residual may remain once + // the aim is right — vN and gN measure true to 1% (see below), so if + // anything is still left it belongs here. Measure it before setting it: + // the flight record now carries the launch point and every observed + // position, so a residual can be read off directly instead of fitted + // through the other three constants. + landN: 0, // landing correction / height // Magenta wind stays gated to zero in predict(): its arrow glyph is a // third the size of cyan's and its direction read is unreliable — see v3 // history in git. Zero measures best; not a claim that magenta does nothing. @@ -56,8 +67,8 @@ hidden: false, px: null, py: null // dragged panel position, viewport px }, JSON.parse(localStorage.getItem(KEY) || '{}')); - if (cfg.calVer !== 4) { - cfg.calVer = 4; cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = -0.023; + if (cfg.calVer !== 5) { + cfg.calVer = 5; cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = 0; cfg.windK = 0.0158; } let saveAt = 0; @@ -611,7 +622,29 @@ if (near.length > 34) return null; // a broad plateau is the body, not a dart let sw = 0, sd = 0; for (const e of near) { const w = e.reach - (best.reach - 5 * scale); sw += w; sd += w * e.deg; } - return { x: sx + gx * kx, y: sy + gy * ky, deg: sd / sw, reach: best.reach / scale }; + // The march reads the dart's visual axis, and the dart does not fly along + // it: measured against 12 no-wind flights tracked by the code below, the + // angle actually flown is +4.18 deg steeper than this march reports, with + // sd 0.47 and a slope against aim angle of -0.04 deg/deg — a constant + // offset, not a scaling error. Uncorrected it puts the predicted line + // 44-60px below where the dart lands (shallower aims worse), which is the + // long-standing "darts land higher than the line" complaint. + // + // The old note here claimed this was "validated against 16 real throws: + // r = 0.97 against the launch angle actually flown". r is a CORRELATION and + // is blind to a constant offset — a reading biased by a fixed 4 degrees + // still scores 0.97. That is why this sat undetected: the validation + // checked the wrong statistic. Do not re-validate this with a correlation. + // + // AIM_BIAS is the value measured at the first tracked point of the flight. + // Extrapolating back to the launch point suggests the true figure is a + // little higher (+5.2 deg, sd 0.98), but that estimate relies on pairing + // releases to flights by index — 33 releases against 30 flights — and the + // rows with the largest inferred gaps drive it. The flight record now + // carries its own launch point (lx, ly) so the next session measures this + // directly instead of inferring it; refine AIM_BIAS then, not before. + const AIM_BIAS = 4.18; + return { x: sx + gx * kx, y: sy + gy * ky, deg: sd / sw + AIM_BIAS, reach: best.reach / scale }; } // ---------- debug probe ---------- @@ -630,7 +663,7 @@ let frame = 0, board = null, boardT = 0, wind = { key: 'none', deg: 0 }; let aimDeg = null, aimT = 0, lastAim = null, lastAimF = -99; let dartPts = [], lastDartT = 0, flightWind = 'none', flightAim = null; - let prevFly = [], lastFlight = null, flightT0 = 0; + let prevFly = [], lastFlight = null, flightT0 = 0, flightLX = null, flightLY = null; // Every gold blob inside a rectangle of the downscaled frame, in css coords. // The hand search does its own copy of this over the LEFT of the screen; this @@ -912,6 +945,11 @@ lastFlight = { n: dartPts.length, t0: flightT0, dur: +((lastDartT - flightT0) / 1000).toFixed(3), aim: flightAim, wind: flightWind, + // Where predict() was told the dart starts, captured at release. + // Without this the launch point has to be recovered by pairing + // releases to flights by index, which does not survive a release + // that produces too short a track to publish. + lx: flightLX, ly: flightLY, x0: +dartPts[0].x.toFixed(1), y0: +dartPts[0].y.toFixed(1), pts: dartPts.map(p => ({ dt: +((p.t - flightT0) / 1000).toFixed(3), x: +p.x.toFixed(1), y: +p.y.toFixed(1) })) @@ -931,6 +969,8 @@ dartPts = [{ t, x: f.x, y: f.y }]; flightT0 = t; lastDartT = t; flightAim = aimDeg !== null ? +aimDeg.toFixed(2) : null; + flightLX = aim ? +aim.x.toFixed(1) : (hand ? +hand.x.toFixed(1) : null); + flightLY = aim ? +aim.y.toFixed(1) : (hand ? +hand.y.toFixed(1) : null); flightWind = { key: wind.key, deg: +(wind.deg || 0).toFixed(1), mph: wind.mph || null }; break; } @@ -986,7 +1026,7 @@ $('#live').onchange = e => { cfg.live = e.target.checked; save(); }; $('#debug').onchange = e => { cfg.debug = e.target.checked; save(); }; $('#cal').onclick = () => { - cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = -0.023; + cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = 0; cfg.windK = 0.0158; save(); }; diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 4208bd1..c276aae 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.26 +// @version 1.27 // @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 @@ -2543,9 +2543,13 @@ band: true, // name the band you would hit live: true, // track a dart already in the air debug: false, - calVer: 4, - // Measured from 16 tracked throws. Speed is normalised by canvas width, - // gravity and wind by width too (the game keeps its aspect ratio). + calVer: 5, + // Confirmed v5 against 12 no-wind flights tracked at 1327.9x747, fitting + // position against time directly rather than inferring from landings: + // |v| median 734 px/s (sd 6) -> 0.553, and g median 454 px/s^2 (sd 16) -> + // 0.607. Both within 1% of the values below, so these are left alone. An + // earlier fit off a recording suggested vN was 17% low; that came from 8 + // sparse flights with a badly conditioned quadratic and was wrong. vN: 0.548, // launch speed / width, per second gN: 0.612, // gravity / height // v4: windK re-measured from a recording holding two wind states — four @@ -2555,20 +2559,27 @@ // degeneracy, and both clusters agree: 0.0158 up, 0.0157 down. Symmetric // and well-determined, unlike the old 0.0135 (fit tangled with landN). windK: 0.0158, // acceleration per mph, as a fraction of canvas width - // The landing residual soaked up part of the wind error while windK was - // low — the old -0.074 predicted ~30px high on every throw once windK is - // right. Re-fit with the wind term fixed at its measured value: 9 of the - // 10 recorded throws land within half a band (the 10th misses by 44px, - // just over). The unexplained leftover splits +-20px WITH the wind sign, - // so some vertical wind coupling is still not understood — but it is well - // inside the 77px band and not worth chasing on 10 throws. - landN: -0.023, // landing correction / height + // v5: ZERO, because the thing it was correcting turned out to be a bug. + // This term only ever existed to soak up an unexplained landing residual, + // and the residual is now explained: findAim under-read the launch angle + // by a constant 4.18 deg (see AIM_BIAS), which puts the predicted line + // 44-60px below the dart. landN was absorbing roughly a third of that at + // -0.023 (-17px on a 747px canvas). With the angle corrected at source, + // keeping landN would over-correct in the opposite direction. + // + // It is zero rather than deleted because a real residual may remain once + // the aim is right — vN and gN measure true to 1% (see below), so if + // anything is still left it belongs here. Measure it before setting it: + // the flight record now carries the launch point and every observed + // position, so a residual can be read off directly instead of fitted + // through the other three constants. + landN: 0, // landing correction / height // Magenta wind stays gated to zero in predict(): its arrow glyph is a // third the size of cyan's and its direction read is unreliable — see v3 // history in git. Zero measures best; not a claim that magenta does nothing. }, cfg => { - if (cfg.calVer !== 4) { - cfg.calVer = 4; cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = -0.023; + if (cfg.calVer !== 5) { + cfg.calVer = 5; cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = 0; cfg.windK = 0.0158; } }); @@ -3049,7 +3060,29 @@ if (near.length > 34) return null; // a broad plateau is the body, not a dart let sw = 0, sd = 0; for (const e of near) { const w = e.reach - (best.reach - 5 * scale); sw += w; sd += w * e.deg; } - return { x: sx + gx * kx, y: sy + gy * ky, deg: sd / sw, reach: best.reach / scale }; + // The march reads the dart's visual axis, and the dart does not fly along + // it: measured against 12 no-wind flights tracked by the code below, the + // angle actually flown is +4.18 deg steeper than this march reports, with + // sd 0.47 and a slope against aim angle of -0.04 deg/deg — a constant + // offset, not a scaling error. Uncorrected it puts the predicted line + // 44-60px below where the dart lands (shallower aims worse), which is the + // long-standing "darts land higher than the line" complaint. + // + // The old note here claimed this was "validated against 16 real throws: + // r = 0.97 against the launch angle actually flown". r is a CORRELATION and + // is blind to a constant offset — a reading biased by a fixed 4 degrees + // still scores 0.97. That is why this sat undetected: the validation + // checked the wrong statistic. Do not re-validate this with a correlation. + // + // AIM_BIAS is the value measured at the first tracked point of the flight. + // Extrapolating back to the launch point suggests the true figure is a + // little higher (+5.2 deg, sd 0.98), but that estimate relies on pairing + // releases to flights by index — 33 releases against 30 flights — and the + // rows with the largest inferred gaps drive it. The flight record now + // carries its own launch point (lx, ly) so the next session measures this + // directly instead of inferring it; refine AIM_BIAS then, not before. + const AIM_BIAS = 4.18; + return { x: sx + gx * kx, y: sy + gy * ky, deg: sd / sw + AIM_BIAS, reach: best.reach / scale }; } // ---------- debug probe ---------- @@ -3068,7 +3101,7 @@ let frame = 0, board = null, boardT = 0, wind = { key: 'none', deg: 0 }; let aimDeg = null, aimT = 0, lastAim = null, lastAimF = -99; let dartPts = [], lastDartT = 0, flightWind = 'none', flightAim = null; - let prevFly = [], lastFlight = null, flightT0 = 0; + let prevFly = [], lastFlight = null, flightT0 = 0, flightLX = null, flightLY = null; // Every gold blob inside a rectangle of the downscaled frame, in css coords. // The hand search does its own copy of this over the LEFT of the screen; this @@ -3349,6 +3382,11 @@ lastFlight = { n: dartPts.length, t0: flightT0, dur: +((lastDartT - flightT0) / 1000).toFixed(3), aim: flightAim, wind: flightWind, + // Where predict() was told the dart starts, captured at release. + // Without this the launch point has to be recovered by pairing + // releases to flights by index, which does not survive a release + // that produces too short a track to publish. + lx: flightLX, ly: flightLY, x0: +dartPts[0].x.toFixed(1), y0: +dartPts[0].y.toFixed(1), pts: dartPts.map(p => ({ dt: +((p.t - flightT0) / 1000).toFixed(3), x: +p.x.toFixed(1), y: +p.y.toFixed(1) })) @@ -3368,6 +3406,8 @@ dartPts = [{ t, x: f.x, y: f.y }]; flightT0 = t; lastDartT = t; flightAim = aimDeg !== null ? +aimDeg.toFixed(2) : null; + flightLX = aim ? +aim.x.toFixed(1) : (hand ? +hand.x.toFixed(1) : null); + flightLY = aim ? +aim.y.toFixed(1) : (hand ? +hand.y.toFixed(1) : null); flightWind = { key: wind.key, deg: +(wind.deg || 0).toFixed(1), mph: wind.mph || null }; break; } @@ -3422,7 +3462,7 @@ $('#live').onchange = e => { cfg.live = e.target.checked; save(); }; $('#debug').onchange = e => { cfg.debug = e.target.checked; save(); }; $('#cal').onclick = () => { - cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = -0.023; + cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = 0; cfg.windK = 0.0158; save(); }; From e9cb5c3f0b0178860b45fbc24b81678e4f4c5ec6 Mon Sep 17 00:00:00 2001 From: averagenative Date: Fri, 11 Sep 2026 20:43:42 -0400 Subject: [PATCH 5/8] Record that landN really is zero, measured against 19 tracked flights The previous commit set landN to zero on the argument that the term only existed to absorb the aim bias, and left a note saying a real residual might remain and should be measured. It has been measured, and there is none. With the aim correction in place, the shipped predict() was run from each recorded launch point and compared against every observed position of 19 no-wind flights. 16 of the 19 track the real dart at 1.6-8.3px rms across the whole arc, and observed minus predicted at the end of tracking averages +0.1px with sd 11.2. The three that miss start wrong rather than drift wrong: their launch point was captured far from where the dart was first seen, so they measure the launch capture and not the flight model. The same run re-confirms the other constants from 15 clean flights, with the launch point now recorded in the flight rather than inferred by pairing: residual aim bias after the +4.18 correction: -0.03 deg, sd 0.43 vN 0.554 (sd 0.002) against 0.548 gN 0.616 (sd 0.015) against 0.612 so AIM_BIAS measures 4.15 where 4.18 shipped -- inside the noise, left alone. The +5.23 figure the previous commit warned about was indeed an artifact of pairing releases to flights by index; flights with a large launch-to-first-seen gap give +2.44 while the clean ones give -0.03, which is exactly the signature of a bad extrapolation rather than a real effect. One trap is written into the comment because it is genuinely misleading: pairing a landing on the board against "the last prediction before it landed" still reports a mean residual of -75px with sd 88, even now that the model is correct. The dart is airborne for about a second while the aim sweep moves on, so the prediction being compared belongs to a later aim entirely. That number looks like evidence and is not. Compare against the tracked flight. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-darts.user.js | 24 +++++++++++++++++------- idleon-suite.user.js | 24 +++++++++++++++++------- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/idleon-darts.user.js b/idleon-darts.user.js index eb745ca..9c46904 100644 --- a/idleon-darts.user.js +++ b/idleon-darts.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Darts Helper // @namespace nativerobot -// @version 1.11 +// @version 1.12 // @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 @@ -53,12 +53,22 @@ // -0.023 (-17px on a 747px canvas). With the angle corrected at source, // keeping landN would over-correct in the opposite direction. // - // It is zero rather than deleted because a real residual may remain once - // the aim is right — vN and gN measure true to 1% (see below), so if - // anything is still left it belongs here. Measure it before setting it: - // the flight record now carries the launch point and every observed - // position, so a residual can be read off directly instead of fitted - // through the other three constants. + // Zero is now MEASURED, not provisional. With the aim corrected, the + // shipped predict() was run from each recorded launch point and compared + // against every observed position of 19 no-wind tracked flights: 16 of the + // 19 track the real dart at 1.6-8.3px rms over the whole arc, and observed + // minus predicted at the end of tracking averages +0.1px (sd 11.2). There + // is no residual left for this term to hold. The three that miss start + // wrong rather than drift wrong -- their launch point was recorded far from + // where the dart was first seen -- so they measure the launch capture, not + // the flight model. + // + // Beware the trap that made this look otherwise: pairing a landing on the + // board against "the last prediction before it landed" gives a mean of + // -75px with sd 88 even now, because the dart is airborne for about a + // second while the aim sweep moves on, so the prediction being compared + // belongs to a later aim. That method cannot measure this and should not be + // used to re-tune landN. Compare against the tracked flight instead. landN: 0, // landing correction / height // Magenta wind stays gated to zero in predict(): its arrow glyph is a // third the size of cyan's and its direction read is unreliable — see v3 diff --git a/idleon-suite.user.js b/idleon-suite.user.js index c276aae..2ab97d0 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.27 +// @version 1.28 // @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 @@ -2567,12 +2567,22 @@ // -0.023 (-17px on a 747px canvas). With the angle corrected at source, // keeping landN would over-correct in the opposite direction. // - // It is zero rather than deleted because a real residual may remain once - // the aim is right — vN and gN measure true to 1% (see below), so if - // anything is still left it belongs here. Measure it before setting it: - // the flight record now carries the launch point and every observed - // position, so a residual can be read off directly instead of fitted - // through the other three constants. + // Zero is now MEASURED, not provisional. With the aim corrected, the + // shipped predict() was run from each recorded launch point and compared + // against every observed position of 19 no-wind tracked flights: 16 of the + // 19 track the real dart at 1.6-8.3px rms over the whole arc, and observed + // minus predicted at the end of tracking averages +0.1px (sd 11.2). There + // is no residual left for this term to hold. The three that miss start + // wrong rather than drift wrong -- their launch point was recorded far from + // where the dart was first seen -- so they measure the launch capture, not + // the flight model. + // + // Beware the trap that made this look otherwise: pairing a landing on the + // board against "the last prediction before it landed" gives a mean of + // -75px with sd 88 even now, because the dart is airborne for about a + // second while the aim sweep moves on, so the prediction being compared + // belongs to a later aim. That method cannot measure this and should not be + // used to re-tune landN. Compare against the tracked flight instead. landN: 0, // landing correction / height // Magenta wind stays gated to zero in predict(): its arrow glyph is a // third the size of cyan's and its direction read is unreliable — see v3 From 1fee8d71cecfa38dac4d4547ade4fa360ac0ec5a Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 10:39:22 -0400 Subject: [PATCH 6/8] Read the wind arrow at native resolution, and stop claiming its axis is its point Two separate wrongs in readWind, one of them in the comment. The direction was read off the /scale frame, where the arrow survives as about 47 pixels. That is where its noise came from -- not from the method, which is what the evidence first suggested. Rotating a captured glyph through a known sweep and re-reading it at each resolution: scale 1 451px error sd 0.6 deg worst 1.3 scale 2 148px error sd 2.2 deg worst 7.0 scale 4 47px error sd 9.7 deg worst 22.4 <- what this used scale 6 25px error sd 14.5 deg worst 40.3 At native resolution the principal axis tracks rotation to about a degree. Live, the old read swung 57 degrees across resolutions on one unchanged arrow (-25.0, -48.2, +9.4, -42.8 at scales 1/2/4/6) and disagreed with what was on screen. Same failure as the fishing gauge in 2232d91 and the mph glyph gates in 28d547b: a measurement taken through the downscale that only ever needed the full frame. The comment claimed "its principal axis gives that direction". It does not. The glyph is a chunky double chevron that narrows at BOTH ends, and its axis of greatest variance sits at a fixed angle to its point -- the rotation sweep shows a constant offset of about 45 degrees against the captured frame. So the value returned is rotation-correct and origin-wrong: differences between readings are trustworthy, the absolute bearing is not. That is now written down, along with the fact that anchoring the offset needs one arrow of independently known direction, probably one per colour, since the magenta glyph is a different sprite from the cyan one. This matters beyond the reading itself: every attempt to fit windK's vertical component or the HV ratio from flight data takes sin(deg) as input, so all of them were being fed a bearing with an unknown constant error. Measured wind acceleration per state is consistent in MAGNITUDE (|a|/mph 12-25, median ~18 against the 21.0 that windK 0.0158 implies) and incoherent in DIRECTION (-49 to +67 deg, uncorrelated with what was read), which is exactly the signature. Also drops stray pixels hard against the window's left edge before measuring. A captured mask shows a column of matching pixels many pixels clear of the glyph; they are far enough out to drag the centroid and the axis with it. Replayed against 2026-08-14: 722 accepted aims, aim range -21.2..69.5, 315 airborne frames -- identical to before. That clip carries no wind, so it exercises everything around the change without touching the new path. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-darts.user.js | 84 ++++++++++++++++++++++++++++++++++++++------ idleon-suite.user.js | 84 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 146 insertions(+), 22 deletions(-) diff --git a/idleon-darts.user.js b/idleon-darts.user.js index 9c46904..d1a4258 100644 --- a/idleon-darts.user.js +++ b/idleon-darts.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Darts Helper // @namespace nativerobot -// @version 1.12 +// @version 1.13 // @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 @@ -253,6 +253,33 @@ } catch (e) { return null; } } + // Native-resolution crop of the wind arrow. The direction used to be read off + // the /scale frame, where the arrow survives as ~47 pixels, and that is where + // its noise came from -- not from the method. Rotating the real glyph through + // a known sweep and re-reading it at each resolution: + // + // scale 1 451px error sd 0.6 deg worst 1.3 + // scale 2 148px error sd 2.2 deg worst 7.0 + // scale 4 47px error sd 9.7 deg worst 22.4 <- what this used to use + // scale 6 25px error sd 14.5 deg worst 40.3 + // + // At native resolution the principal axis tracks rotation to about a degree. + // Same failure as the fishing gauge in 2232d91 and the mph glyph gates: a + // measurement taken through the downscale that only needed the full frame. + const windC = document.createElement('canvas'); + const wctx = windC.getContext('2d', { willReadFrequently: true }); + function grabWind(cv) { + const sx = Math.round(cv.width * 0.56), sw = Math.round(cv.width * 0.12); + const sy = Math.round(cv.height * 0.02), sh = Math.round(cv.height * 0.10); + if (sw < 8 || sh < 8) return null; + if (windC.width !== sw || windC.height !== sh) { windC.width = sw; windC.height = sh; } + try { + wctx.clearRect(0, 0, sw, sh); + wctx.drawImage(cv, sx, sy, sw, sh, 0, 0, sw, sh); + return { d: wctx.getImageData(0, 0, sw, sh).data, w: sw, h: sh }; + } catch (e) { return null; } + } + function hsv(r, g, b) { const mx = r > g ? (r > b ? r : b) : (g > b ? g : b); const mn = r < g ? (r < b ? r : b) : (g < b ? g : b); @@ -340,18 +367,53 @@ // Read from the colour of the HUD arrow rather than the "N mph" text: cyan and // magenta are unmistakable and need no OCR. // The arrow ROTATES — the same 9 mph shows pointing up-right, level, and - // down-right — so wind has a 2D direction, not just a strength. Its principal - // axis gives that direction; every arrow observed so far points rightward, so - // the axis is resolved toward +x. Colour is only a coarse strength band: 4 mph - // and 9 mph are both cyan, so colour cannot stand in for speed. - function readWind(I) { - const pts = []; - for (let y = Math.round(I.h * 0.02); y < Math.round(I.h * 0.12); y++) - for (let x = Math.round(I.w * 0.56); x < Math.round(I.w * 0.68); x++) { - const [h, s, v] = px(I, x, y); + // down-right — so wind has a 2D direction, not just a strength. Colour is only + // a coarse strength band: 4 mph and 9 mph are both cyan, so colour cannot + // stand in for speed. + // + // CAUTION: the principal axis is NOT the direction the arrow points, and the + // old note here saying it was is wrong. The glyph is a chunky double chevron + // that narrows at both ends, and its axis of greatest variance sits at a fixed + // angle to its point. Rotating a captured glyph through a known sweep shows + // the axis tracking rotation almost exactly — error sd 0.6 deg at native + // resolution — but with a CONSTANT offset of about 45 deg against the frame it + // was captured in. So this function returns a value that is rotation-correct + // and origin-wrong: differences between two readings are trustworthy, the + // absolute bearing is not. + // + // Pinning the offset needs one arrow whose true direction is independently + // known, and it probably needs one PER COLOUR: the magenta glyph is a + // different sprite from the cyan one (a third the size, per the v3 notes), so + // there is no reason for their axes to sit at the same angle to their points. + // Until that is measured, predict() is being handed a bearing with an unknown + // constant error, which is why windK's vertical component and the HV ratio + // cannot be fitted from flight data — every such fit takes sin(deg) as input. + // Do not "calibrate" windK against this until the offset is anchored. + // S is the native-resolution crop from grabWind, so the whole image IS the + // window -- no sub-window arithmetic here any more. + function readWind(S) { + if (!S) return { key: 'none', deg: 0 }; + let pts = []; + for (let y = 0; y < S.h; y++) + for (let x = 0; x < S.w; x++) { + const [h, s, v] = px(S, x, y); if (s > 0.35 && v > 0.6 && ((h > 165 && h < 215) || (h > 270 && h < 335))) pts.push({ x, y, h }); } if (pts.length < 8) return { key: 'none', deg: 0 }; + // The window catches a few matching pixels hard against its left edge that + // are not part of the arrow at all -- seen as a stray column many pixels + // clear of the glyph in a captured mask. They are far enough out to drag + // the centroid, and the principal axis with it, so cut anything well + // outside the main mass before measuring. + { + let cx = 0, cy = 0; + for (const q of pts) { cx += q.x; cy += q.y; } + cx /= pts.length; cy /= pts.length; + const d = pts.map(q => Math.hypot(q.x - cx, q.y - cy)).sort((a, b) => a - b); + const cut = d[Math.floor(d.length * 0.95)] * 1.6; + const core = pts.filter(q => Math.hypot(q.x - cx, q.y - cy) <= cut); + if (core.length >= 8) pts = core; + } const n = pts.length; let mx = 0, my = 0; for (const q of pts) { mx += q.x; my += q.y; } @@ -786,7 +848,7 @@ const b = findBoard(I, W, H); if (b) { board = b; boardT = performance.now(); } else if (performance.now() - boardT > 900) board = null; - wind = readWind(I); + wind = readWind(grabWind(cv)); if (wind.key !== 'none') wind.mph = readMph(grabMph(cv)); const t = performance.now(); diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 2ab97d0..4dfaa2f 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.28 +// @version 1.29 // @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 @@ -2692,6 +2692,33 @@ } catch (e) { return null; } } + // Native-resolution crop of the wind arrow. The direction used to be read off + // the /scale frame, where the arrow survives as ~47 pixels, and that is where + // its noise came from -- not from the method. Rotating the real glyph through + // a known sweep and re-reading it at each resolution: + // + // scale 1 451px error sd 0.6 deg worst 1.3 + // scale 2 148px error sd 2.2 deg worst 7.0 + // scale 4 47px error sd 9.7 deg worst 22.4 <- what this used to use + // scale 6 25px error sd 14.5 deg worst 40.3 + // + // At native resolution the principal axis tracks rotation to about a degree. + // Same failure as the fishing gauge in 2232d91 and the mph glyph gates: a + // measurement taken through the downscale that only needed the full frame. + const windC = document.createElement('canvas'); + const wctx = windC.getContext('2d', { willReadFrequently: true }); + function grabWind(cv) { + const sx = Math.round(cv.width * 0.56), sw = Math.round(cv.width * 0.12); + const sy = Math.round(cv.height * 0.02), sh = Math.round(cv.height * 0.10); + if (sw < 8 || sh < 8) return null; + if (windC.width !== sw || windC.height !== sh) { windC.width = sw; windC.height = sh; } + try { + wctx.clearRect(0, 0, sw, sh); + wctx.drawImage(cv, sx, sy, sw, sh, 0, 0, sw, sh); + return { d: wctx.getImageData(0, 0, sw, sh).data, w: sw, h: sh }; + } catch (e) { return null; } + } + function hsv(r, g, b) { const mx = r > g ? (r > b ? r : b) : (g > b ? g : b); const mn = r < g ? (r < b ? r : b) : (g < b ? g : b); @@ -2779,18 +2806,53 @@ // Read from the colour of the HUD arrow rather than the "N mph" text: cyan and // magenta are unmistakable and need no OCR. // The arrow ROTATES — the same 9 mph shows pointing up-right, level, and - // down-right — so wind has a 2D direction, not just a strength. Its principal - // axis gives that direction; every arrow observed so far points rightward, so - // the axis is resolved toward +x. Colour is only a coarse strength band: 4 mph - // and 9 mph are both cyan, so colour cannot stand in for speed. - function readWind(I) { - const pts = []; - for (let y = Math.round(I.h * 0.02); y < Math.round(I.h * 0.12); y++) - for (let x = Math.round(I.w * 0.56); x < Math.round(I.w * 0.68); x++) { - const [h, s, v] = px(I, x, y); + // down-right — so wind has a 2D direction, not just a strength. Colour is only + // a coarse strength band: 4 mph and 9 mph are both cyan, so colour cannot + // stand in for speed. + // + // CAUTION: the principal axis is NOT the direction the arrow points, and the + // old note here saying it was is wrong. The glyph is a chunky double chevron + // that narrows at both ends, and its axis of greatest variance sits at a fixed + // angle to its point. Rotating a captured glyph through a known sweep shows + // the axis tracking rotation almost exactly — error sd 0.6 deg at native + // resolution — but with a CONSTANT offset of about 45 deg against the frame it + // was captured in. So this function returns a value that is rotation-correct + // and origin-wrong: differences between two readings are trustworthy, the + // absolute bearing is not. + // + // Pinning the offset needs one arrow whose true direction is independently + // known, and it probably needs one PER COLOUR: the magenta glyph is a + // different sprite from the cyan one (a third the size, per the v3 notes), so + // there is no reason for their axes to sit at the same angle to their points. + // Until that is measured, predict() is being handed a bearing with an unknown + // constant error, which is why windK's vertical component and the HV ratio + // cannot be fitted from flight data — every such fit takes sin(deg) as input. + // Do not "calibrate" windK against this until the offset is anchored. + // S is the native-resolution crop from grabWind, so the whole image IS the + // window -- no sub-window arithmetic here any more. + function readWind(S) { + if (!S) return { key: 'none', deg: 0 }; + let pts = []; + for (let y = 0; y < S.h; y++) + for (let x = 0; x < S.w; x++) { + const [h, s, v] = px(S, x, y); if (s > 0.35 && v > 0.6 && ((h > 165 && h < 215) || (h > 270 && h < 335))) pts.push({ x, y, h }); } if (pts.length < 8) return { key: 'none', deg: 0 }; + // The window catches a few matching pixels hard against its left edge that + // are not part of the arrow at all -- seen as a stray column many pixels + // clear of the glyph in a captured mask. They are far enough out to drag + // the centroid, and the principal axis with it, so cut anything well + // outside the main mass before measuring. + { + let cx = 0, cy = 0; + for (const q of pts) { cx += q.x; cy += q.y; } + cx /= pts.length; cy /= pts.length; + const d = pts.map(q => Math.hypot(q.x - cx, q.y - cy)).sort((a, b) => a - b); + const cut = d[Math.floor(d.length * 0.95)] * 1.6; + const core = pts.filter(q => Math.hypot(q.x - cx, q.y - cy) <= cut); + if (core.length >= 8) pts = core; + } const n = pts.length; let mx = 0, my = 0; for (const q of pts) { mx += q.x; my += q.y; } @@ -3223,7 +3285,7 @@ const b = findBoard(I, W, H); if (b) { board = b; boardT = performance.now(); } else if (performance.now() - boardT > 900) board = null; - wind = readWind(I); + wind = readWind(grabWind(cv)); if (wind.key !== 'none') wind.mph = readMph(grabMph(cv)); const t = performance.now(); From 2e4a0abaacc1f7e3c7dd77bda4b32dffb597c73f Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 11:04:17 -0400 Subject: [PATCH 7/8] Stop throwing away every wind over 10 mph, and derive windK instead of fitting it Three corrections, all from the game's own flight code rather than from fitting screen pixels, and all of them things the empirical route could not settle. MAGENTA WAS NEVER A DIFFERENT KIND OF WIND. The arrow sprite is chosen as mag < 10 ? DartWind0 : mag < 18 ? DartWind1 : DartWind2 so the colour is a strength tier and nothing else. Every cyan logged here came in at 4/6/8/9 mph and every magenta at 10/11/13 -- that boundary exactly. The `trust = cyan ? 1 : 0` gate was therefore discarding the STRONGEST winds, modelling a 13 mph crosswind as still air. Measured off 104 tracked flights, magenta pushes at 18.9-24.9 px/s^2 per mph against cyan's 14.5-20.5: the same wind, harder. The unreliable direction read that justified the gate was real but was not about magenta. It was the downscale and the stray pixels, both fixed in 1fee8d7. Measured on the sprites themselves, the unrotated arrow's principal axis sits at +1.43 deg (DartWind0) and +2.13 deg (DartWind1) -- under a degree apart, so there is no per-colour correction to make. That also retires the "constant 45 degree offset" from the previous commit: it was the captured glyph's own rotation, not a property of the shape. With strays dropped that capture reads -45.6, i.e. the arrow pointed at about -47, which is what was on screen. windK IS NOW DERIVED. The flight step is vx += windX/600, vy += windY/750 at STEP_SIZE 10ms -- 100 updates a second -- on a 960x540 design canvas. A per-step bump of k is k*10000 px/s^2, so the vertical term is windY*13.333, and the displayed mph IS the wind magnitude (the game shows ceil(hypot(windX,windY))). Scaling to this canvas gives windK = 13.333/960 = 0.01389. The horizontal gives the same number once HV=1.25 is applied, that ratio being 750/600 and the actual origin of HV. The old 0.0158 implied 21.0 px/s^2 per mph; the derived value gives 18.4, against 18 measured off the flights. The old value was ~14% high. SWEEP_LO GOES TO -50, fixing a bug introduced in 22140b4. The arm sweeps -20 + (38 + 15t/(t+30))*Trigg(sin, ...) and launches at vy = speed*sin(arm) with screen y down, so this file's angle is -arm and the true range is -33..+73. Since AIM_BIAS is added after the scan, a genuine -33 reaches the boundary test at about -37.2 raw, and the -40 floor rejected anything at or under -35 -- clipping the bottom of a legitimate sweep. Readings had only reached -28 so it had not bitten, but it would have on a long run at full amplitude. Known gap, not fixed here: DartWind2, the >=18 mph sprite, is red-orange and matches neither hue window, so those winds read as 'none' and are modelled as still air. Detecting it needs care because the HUD behind it is also brown. calVer 6 with windK reseeded. Replayed against 2026-08-14: 722 accepted aims, range -21.2..69.5, 315 airborne frames, all unchanged -- that clip carries no wind, so it checks the surrounding code without exercising the new paths. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-darts.user.js | 61 +++++++++++++++++++++++++++++++++++--------- idleon-suite.user.js | 61 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 98 insertions(+), 24 deletions(-) diff --git a/idleon-darts.user.js b/idleon-darts.user.js index d1a4258..1f563f6 100644 --- a/idleon-darts.user.js +++ b/idleon-darts.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Darts Helper // @namespace nativerobot -// @version 1.13 +// @version 1.14 // @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 @@ -29,7 +29,7 @@ band: true, // name the band you would hit live: true, // track a dart already in the air debug: false, - calVer: 5, + calVer: 6, // Confirmed v5 against 12 no-wind flights tracked at 1327.9x747, fitting // position against time directly rather than inferring from landings: // |v| median 734 px/s (sd 6) -> 0.553, and g median 454 px/s^2 (sd 16) -> @@ -44,7 +44,21 @@ // the clusters solves for the wind strength independently of the v/g/land // degeneracy, and both clusters agree: 0.0158 up, 0.0157 down. Symmetric // and well-determined, unlike the old 0.0135 (fit tangled with landN). - windK: 0.0158, // acceleration per mph, as a fraction of canvas width + // v6: derived, not fitted. The minigame's flight step is + // vx += windX/600 ; vy += windY/750 + // at Engine.STEP_SIZE = 10ms, i.e. 100 logic updates a second, on a 960x540 + // design canvas. A per-step velocity bump of k converts to k*10000 px/s^2, + // so the vertical term is windY*13.333 game px/s^2, and windX/windY are the + // wind vector whose magnitude is exactly the displayed mph (the game takes + // mag = ceil(hypot(windX,windY)) for the readout). Scaling to this canvas: + // windK = 13.333 / 960 = 0.01389 + // The horizontal works out to the same number once HV=1.25 is applied, + // which is the 750/600 ratio and is where HV comes from in the first place. + // + // This lands on top of the empirical figure: wind acceleration measured off + // 104 tracked flights came to |a| ~18 px/s^2 per mph, against 13.333*W/960 + // = 18.4 for this canvas. The old 0.0158 implied 21.0 and was ~14% high. + windK: 0.01389, // acceleration per mph, as a fraction of canvas width // v5: ZERO, because the thing it was correcting turned out to be a bug. // This term only ever existed to soak up an unexplained landing residual, // and the residual is now explained: findAim under-read the launch angle @@ -70,16 +84,27 @@ // belongs to a later aim. That method cannot measure this and should not be // used to re-tune landN. Compare against the tracked flight instead. landN: 0, // landing correction / height - // Magenta wind stays gated to zero in predict(): its arrow glyph is a - // third the size of cyan's and its direction read is unreliable — see v3 - // history in git. Zero measures best; not a claim that magenta does nothing. + // v6: magenta is NO LONGER gated. The colour was never a kind of wind, it is + // a strength tier — the game picks the arrow sprite as + // mag < 10 ? DartWind0 : mag < 18 ? DartWind1 : DartWind2 + // so cyan is simply every wind under 10 mph and magenta is 10-17. Every + // cyan logged here came in at 4/6/8/9 mph and every magenta at 10/11/13, + // which is that boundary exactly. Gating magenta therefore threw away the + // STRONGEST winds, modelling a 13 mph crosswind as still air. + // + // The direction read that justified the gate was genuinely broken, but not + // because of magenta: it was measured through the /scale downscale and + // dragged by stray pixels at the window edge. Both are fixed in readWind. + // Measured on the sprites themselves, the unrotated arrow's principal axis + // sits at +1.43 deg (DartWind0) and +2.13 deg (DartWind1) — the two glyphs + // agree to under a degree, so there is no per-colour correction to make. collapsed: false, hidden: false, px: null, py: null // dragged panel position, viewport px }, JSON.parse(localStorage.getItem(KEY) || '{}')); - if (cfg.calVer !== 5) { - cfg.calVer = 5; cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = 0; - cfg.windK = 0.0158; + if (cfg.calVer !== 6) { + cfg.calVer = 6; cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = 0; + cfg.windK = 0.01389; } let saveAt = 0; const save = () => localStorage.setItem(KEY, JSON.stringify(cfg)); @@ -621,7 +646,18 @@ // measurement off the recording spread to 82-100, and normalised by canvas // width the two disagreed by 10%. A reach window wide enough for both lets // the dives back in, so it is deliberately not used here. - const SWEEP_LO = -40; + // -50, not -40. The game sweeps the arm as + // arm = -20 + (38 + 15t/(t+30)) * Trigg(sin, ...) + // and launches at vy = speed*sin(arm) with screen y DOWN, so this file's + // angle is -arm. The amplitude grows from 38 to 53 over a run, which puts + // the true aim range at -33 .. +73 deg here. AIM_BIAS is added after the + // scan, so a genuine -33 reaches the boundary test as about -37.2 raw — and + // the old -40 floor rejected anything at or under -35, clipping the bottom + // of a legitimate sweep. Observed readings only reached -28, so this had not + // bitten yet, but it would have on a long run at full amplitude. -50 leaves + // the rejection band at -45, clear of -37.2, and still catches a march that + // ran out of range since those pin within ~4.2 deg of the floor. + const SWEEP_LO = -50; for (let deg = SWEEP_LO; deg <= 80; deg++) { const th = deg * Math.PI / 180, ux = Math.cos(th), uy = -Math.sin(th); let reach = R0, gap = 0; @@ -780,7 +816,8 @@ // its direction reads unreliably, and every magenta throw measured was // 32-99px out in the same direction. Scaling magnitude up while the // direction is wrong only makes it worse, so it is gated until fixed. - const trust = wnd.key === 'cyan' ? 1 : 0; + // Any detected wind is a real wind; see the config note on the colour tiers. + const trust = wnd.key === 'none' ? 0 : 1; const A = trust * cfg.windK * (wnd.mph || 6) * W; const wr = (wnd.deg || 0) * Math.PI / 180; // The wind is ONE vector, but the game does not push equally hard along @@ -1099,7 +1136,7 @@ $('#debug').onchange = e => { cfg.debug = e.target.checked; save(); }; $('#cal').onclick = () => { cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = 0; - cfg.windK = 0.0158; + cfg.windK = 0.01389; save(); }; minBtn.onclick = () => { cfg.collapsed = !cfg.collapsed; save(); sync(); }; diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 4dfaa2f..e45580b 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.29 +// @version 1.30 // @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 @@ -2543,7 +2543,7 @@ band: true, // name the band you would hit live: true, // track a dart already in the air debug: false, - calVer: 5, + calVer: 6, // Confirmed v5 against 12 no-wind flights tracked at 1327.9x747, fitting // position against time directly rather than inferring from landings: // |v| median 734 px/s (sd 6) -> 0.553, and g median 454 px/s^2 (sd 16) -> @@ -2558,7 +2558,21 @@ // the clusters solves for the wind strength independently of the v/g/land // degeneracy, and both clusters agree: 0.0158 up, 0.0157 down. Symmetric // and well-determined, unlike the old 0.0135 (fit tangled with landN). - windK: 0.0158, // acceleration per mph, as a fraction of canvas width + // v6: derived, not fitted. The minigame's flight step is + // vx += windX/600 ; vy += windY/750 + // at Engine.STEP_SIZE = 10ms, i.e. 100 logic updates a second, on a 960x540 + // design canvas. A per-step velocity bump of k converts to k*10000 px/s^2, + // so the vertical term is windY*13.333 game px/s^2, and windX/windY are the + // wind vector whose magnitude is exactly the displayed mph (the game takes + // mag = ceil(hypot(windX,windY)) for the readout). Scaling to this canvas: + // windK = 13.333 / 960 = 0.01389 + // The horizontal works out to the same number once HV=1.25 is applied, + // which is the 750/600 ratio and is where HV comes from in the first place. + // + // This lands on top of the empirical figure: wind acceleration measured off + // 104 tracked flights came to |a| ~18 px/s^2 per mph, against 13.333*W/960 + // = 18.4 for this canvas. The old 0.0158 implied 21.0 and was ~14% high. + windK: 0.01389, // acceleration per mph, as a fraction of canvas width // v5: ZERO, because the thing it was correcting turned out to be a bug. // This term only ever existed to soak up an unexplained landing residual, // and the residual is now explained: findAim under-read the launch angle @@ -2584,13 +2598,24 @@ // belongs to a later aim. That method cannot measure this and should not be // used to re-tune landN. Compare against the tracked flight instead. landN: 0, // landing correction / height - // Magenta wind stays gated to zero in predict(): its arrow glyph is a - // third the size of cyan's and its direction read is unreliable — see v3 - // history in git. Zero measures best; not a claim that magenta does nothing. + // v6: magenta is NO LONGER gated. The colour was never a kind of wind, it is + // a strength tier — the game picks the arrow sprite as + // mag < 10 ? DartWind0 : mag < 18 ? DartWind1 : DartWind2 + // so cyan is simply every wind under 10 mph and magenta is 10-17. Every + // cyan logged here came in at 4/6/8/9 mph and every magenta at 10/11/13, + // which is that boundary exactly. Gating magenta therefore threw away the + // STRONGEST winds, modelling a 13 mph crosswind as still air. + // + // The direction read that justified the gate was genuinely broken, but not + // because of magenta: it was measured through the /scale downscale and + // dragged by stray pixels at the window edge. Both are fixed in readWind. + // Measured on the sprites themselves, the unrotated arrow's principal axis + // sits at +1.43 deg (DartWind0) and +2.13 deg (DartWind1) — the two glyphs + // agree to under a degree, so there is no per-colour correction to make. }, cfg => { - if (cfg.calVer !== 5) { - cfg.calVer = 5; cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = 0; - cfg.windK = 0.0158; + if (cfg.calVer !== 6) { + cfg.calVer = 6; cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = 0; + cfg.windK = 0.01389; } }); @@ -3059,7 +3084,18 @@ // measurement off the recording spread to 82-100, and normalised by canvas // width the two disagreed by 10%. A reach window wide enough for both lets // the dives back in, so it is deliberately not used here. - const SWEEP_LO = -40; + // -50, not -40. The game sweeps the arm as + // arm = -20 + (38 + 15t/(t+30)) * Trigg(sin, ...) + // and launches at vy = speed*sin(arm) with screen y DOWN, so this file's + // angle is -arm. The amplitude grows from 38 to 53 over a run, which puts + // the true aim range at -33 .. +73 deg here. AIM_BIAS is added after the + // scan, so a genuine -33 reaches the boundary test as about -37.2 raw — and + // the old -40 floor rejected anything at or under -35, clipping the bottom + // of a legitimate sweep. Observed readings only reached -28, so this had not + // bitten yet, but it would have on a long run at full amplitude. -50 leaves + // the rejection band at -45, clear of -37.2, and still catches a march that + // ran out of range since those pin within ~4.2 deg of the floor. + const SWEEP_LO = -50; for (let deg = SWEEP_LO; deg <= 80; deg++) { const th = deg * Math.PI / 180, ux = Math.cos(th), uy = -Math.sin(th); let reach = R0, gap = 0; @@ -3218,7 +3254,8 @@ // its direction reads unreliably, and every magenta throw measured was // 32-99px out in the same direction. Scaling magnitude up while the // direction is wrong only makes it worse, so it is gated until fixed. - const trust = wnd.key === 'cyan' ? 1 : 0; + // Any detected wind is a real wind; see the config note on the colour tiers. + const trust = wnd.key === 'none' ? 0 : 1; const A = trust * cfg.windK * (wnd.mph || 6) * W; const wr = (wnd.deg || 0) * Math.PI / 180; // The wind is ONE vector, but the game does not push equally hard along @@ -3535,7 +3572,7 @@ $('#debug').onchange = e => { cfg.debug = e.target.checked; save(); }; $('#cal').onclick = () => { cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = 0; - cfg.windK = 0.0158; + cfg.windK = 0.01389; save(); }; From c3b34794b77fb3489c192b9beff5432942f38ad6 Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 11:20:34 -0400 Subject: [PATCH 8/8] Drive the hoops shot from the oscillator that also moves the platform The note on shotL/shotR called the platform coupling "KNOWN, UNEXPLAINED, and the biggest error left". It is explainable. The game sets platY = 335 + 110 * Trigg('sin', 0, 1.1) vy = -2.9 + 0.7 * Trigg('cos', 0, 1.1) and Trigg takes the SAME argument for both, so where the platform is and how hard the ball leaves are one oscillator in quadrature. sin comes from the platform's height, cos from which way it is travelling. That accounts for every part of the measurement that looked contradictory. The coupling is real, which is why corr(platY, shotL) came out -0.79 and -0.86. But a given height maps to TWO shots, one rising and one falling, so nothing linear in height can separate them -- and the relationship is not even monotonic: the shot is at its EXTREMES when the platform is at mid height and average at the top and bottom of its travel. Over 8 and 5 flights inside one ~5s cycle that looks locally linear and correlates strongly, then fails out of sample. Which is exactly the 43%-better-on-shotL, 3%-better-at-the-rim split that was recorded and could not be accounted for. shotCurve now re-cuts its roots for the vy a particular throw will get. Curvature is g/2vx^2 and cannot move -- neither g nor vx sees the oscillator -- so only the launch slope changes, by d(vy/vx) = 0.7*cos/3.9. The release point stays where the shipped constants put it, so cos=0 reproduces the old curve to the pixel: this can only add variation that was missing, never shift the average. Across the cycle it moves the predicted landing by +-6.5% of screen width. The correction is gated until most of one platform swing has been observed, because the midpoint is a guess before that and a wrong midpoint is worse than no correction. Near the turning points the direction of travel cannot be read, so it falls back to cos=0 -- which is also where cos really is near zero, so that failure is self-limiting. shotA's seed becomes 2.177, derived as 0.069/(2*3.9^2)*960 rather than fitted. The old 2.233 came off 13 flights (sd 0.034, range 2.195..2.288) and sits just outside that, so it is a systematic 2.6% and not noise -- same direction and size as the tracking bias found in the darts helper. Self-calibration still runs; this only moves where a fresh install starts. NOT VALIDATED AGAINST FLIGHTS. Two checks were run and both turned out to be tautological -- cos is computed from platY, so "cos^2 + sin^2 = 1" and "|cos| peaks at mid height" are true by construction and confirm the arithmetic only. The real test is whether each shot's fitted R tracks the cos it was thrown on, and each flight's fit is now published on the probe for exactly that. It could not be run here: the one hoops recording available has 655 airborne frames but never commits a per-flight fit, so there was nothing to correlate. What is claimed is that the physics is the game's own and that cos=0 is unchanged; what is not claimed is that it measurably improves a real shot yet. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-hoops.user.js | 122 +++++++++++++++++++++++++++++++++++++------ idleon-suite.user.js | 122 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 214 insertions(+), 30 deletions(-) diff --git a/idleon-hoops.user.js b/idleon-hoops.user.js index 548af7b..b972aeb 100644 --- a/idleon-hoops.user.js +++ b/idleon-hoops.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Hoops Helper // @namespace nativerobot -// @version 1.10 +// @version 1.11 // @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 @@ -54,7 +54,7 @@ debug: false, // outline every detected blob // Calibration is stored as fractions of canvas size so it survives resizing // the window — the game scales its physics with the viewport. - calVer: 6, // bump to throw away calibration learned by an older build + calVer: 7, // bump to throw away calibration learned by an older build // The shot is a fixed parabola anchored to the PLATFORM, not to the ball in // your hands. Written as y = platY + A*(u - uL)*(u - R) where u is distance // right of the platform centre: A is curvature, uL and R are where the path @@ -85,10 +85,17 @@ // own spread across shots is a quarter of its value. Treat a disagreement // there as unsettled rather than as this seed being right. // - // KNOWN, UNEXPLAINED, and the biggest error left. These two are supposed to - // describe the SHOT, so anchoring to the platform should make them - // invariant to where the platform happens to be. They are not. Across two - // independent runs read off the live game: + // EXPLAINED as of v7, and no longer the biggest error left -- see platCos() + // in the state section. Platform height and release velocity are the same + // oscillator in quadrature (platY = 335 + 110*sin(phi), vy = -2.9 + + // 0.7*cos(phi), identical argument), so the coupling below is real but is + // neither linear nor even single-valued: one height means two shots, one + // rising and one falling. The correction now comes from the oscillator + // instead of from these constants, which stay as the cos(phi)=0 case. + // + // The measurements that led here, kept because they are what a linear + // reading of a quadrature coupling looks like. Across two independent runs + // read off the live game: // // corr(platY, shotL) corr(platY, shotR) // 8 flights -0.79 +0.71 @@ -181,7 +188,16 @@ // platform-relative, the arc meets platform height further out when the // platform sits lower, which is the observed sign. Settling it needs the // release instant, which nothing currently measures. - shotA: 2.233, // curvature x canvas width + // v7: the seed is now derived rather than fitted. Curvature is g/2vx^2 with + // g = 0.069 and vx = 3.9 per 10ms step, which on the 960-wide design canvas + // is 0.069/(2*3.9^2)*960 = 2.177. The old 2.233 came off 13 tracked flights + // (sd 0.034, range 2.195..2.288) and sits just outside that, i.e. it is a + // systematic 2.6% rather than noise -- the same direction and size as the + // tracking bias found in the darts helper, where following a blob centroid + // through a rotating sprite inflated fitted accelerations. Self-calibration + // still runs and will pull toward whatever the tracker sees; this only + // changes where a fresh install starts. + shotA: 2.177, // curvature x canvas width shotL: -0.119, // upward crossing, fraction of width left of the platform shotR: 0.547, // landing range, fraction of width right of the platform calSeeded: true, @@ -195,9 +211,9 @@ // live flights the committed curvature ranged 1.865-2.941 around a true // 2.23 — a live config caught mid-session held 2.486. That is not stale, it // is contaminated, and averaging more shots into it does not wash it out. - if (cfg.calVer !== 6) { - cfg.calVer = 6; cfg.calSeeded = true; - cfg.shotA = 2.233; cfg.shotL = -0.119; cfg.shotR = 0.547; + if (cfg.calVer !== 7) { + cfg.calVer = 7; cfg.calSeeded = true; + cfg.shotA = 2.177; cfg.shotL = -0.119; cfg.shotR = 0.547; } delete cfg.grav; delete cfg.launch; delete cfg.launchN; delete cfg.gravN; const save = () => localStorage.setItem(KEY, JSON.stringify(cfg)); @@ -602,8 +618,50 @@ // ---------- state ---------- let plat = null, platT = 0; // the platform, re-found every frame + + // ---- the platform IS the shot ---- + // The game sets platY = 335 + 110*Trigg('sin', 0, 1.1) and releases at + // vy = -2.9 + 0.7*Trigg('cos', 0, 1.1). Trigg takes the SAME argument for + // both, so where the platform is and how hard the ball is thrown are one + // oscillator in quadrature: sin says where it is, cos says how fast the shot + // leaves. sin comes from the platform's height, cos from which way it is + // travelling. + // + // This is what the note on shotL/shotR above could not explain. Platform + // height really is coupled to the shot, which is why the correlations were + // -0.79 and +0.71 -- but a given height maps to TWO different shots, one on + // the way up and one on the way down, and nothing linear in height can tell + // them apart. Worse, the relationship is not even monotonic: the shot is at + // its EXTREMES when the platform is at mid height and average when the + // platform is at the top or bottom of its travel. Over 8 and 5 flights inside + // one ~5s cycle that looks locally linear and correlates strongly, then fails + // out of sample -- exactly the 43%-better-on-shotL, 3%-better-at-the-rim + // split that was measured. + let platLo = Infinity, platHi = -Infinity, platHist = []; + function platCos(H, t) { + if (!plat) return null; + platHist.push({ t, y: plat.y }); + while (platHist.length > 1 && t - platHist[0].t > 400) platHist.shift(); + if (plat.y < platLo) platLo = plat.y; + if (plat.y > platHi) platHi = plat.y; + // The full swing is 220 of 540 on the design canvas. Until most of one has + // been seen the midpoint is a guess, and a wrong midpoint is worse than no + // correction at all. + if (platHi - platLo < (200 / 540) * H) return null; + const y0 = (platLo + platHi) / 2, amp = (platHi - platLo) / 2; + const sn = Math.max(-1, Math.min(1, (plat.y - y0) / amp)); + if (platHist.length < 3) return null; + const dy = plat.y - platHist[0].y; + // Near the turning points the direction cannot be read -- but that is also + // where cos is near zero, so falling back to no correction there costs + // almost nothing. The failure is self-limiting. + if (Math.abs(dy) < 0.5) return null; + return Math.sign(dy) * Math.sqrt(Math.max(0, 1 - sn * sn)); + } let holdT = -1e9; // last time a ball was seen in your hands let flightPlat = null; // where the platform was when this shot left + let flightCos = null; // and the quadrature term it left on + let lastFit = null; // the finished shot's own fit, for the probe let calSamples = [], flyT = 0; // per-flight calibration fits, awaiting commit // Calibration used to be folded in on every frame of a flight. With a 0.25 @@ -621,6 +679,11 @@ return v[v.length >> 1]; }; const An = med('A'), Ln = med('L'), Rn = med('R'); + // Publish this shot's own fit next to the quadrature term it was thrown on. + // If the oscillator really sets the release velocity, R must track cos -- + // that is the claim, and it is testable against any recording. + lastFit = { A: +An.toFixed(4), L: +Ln.toFixed(4), R: +Rn.toFixed(4), + cos: flightCos == null ? null : +flightCos.toFixed(3), n: s.length }; const w = cfg.calSeeded ? 1 : 0.3; // first real shot replaces the seed cfg.shotA += (An - cfg.shotA) * w; cfg.shotL += (Ln - cfg.shotL) * w; @@ -631,8 +694,30 @@ // The shot as a curve in screen space, anchored to the platform. Time never // enters it, so it does not depend on when the ball was first spotted. - function shotCurve(px, py, dir, W) { - const A = cfg.shotA / W, uL = cfg.shotL * W, uR = cfg.shotR * W; + // Release x offset, 17 of 960 on the design canvas: the ball leaves the hand + // at (px+17, py-97), and only the x part is needed here because the curve is + // already anchored in y to the platform. + const RELX = 17 / 960; + function shotCurve(px, py, dir, W, cosPhi) { + const A = cfg.shotA / W; + let uL = cfg.shotL * W, uR = cfg.shotR * W; + if (cosPhi != null) { + // Re-cut the parabola for the vy this particular throw will actually get. + // Curvature is g/2vx^2 and cannot move -- neither g nor vx depends on the + // oscillator -- so the only thing that changes is the launch slope, by + // d(vy/vx) = 0.7*cos/3.9. The release point is left exactly where the + // shipped constants put it, which means cosPhi 0 reproduces the old curve + // to the pixel and this can only add the variation that was missing. + const ur = RELX * W; + const yr = A * (ur - uL) * (ur - uR); + const m = A * (2 * ur - uL - uR) + (0.7 * cosPhi) / 3.9; + const disc = m * m - 4 * A * yr; + if (disc > 0) { + const r = Math.sqrt(disc); + uL = ur + (-m - r) / (2 * A); + uR = ur + (-m + r) / (2 * A); + } + } return { at: x => { const u = (x - px) * dir; return py + A * (u - uL) * (u - uR); }, A, uL, uR, px, py, dir }; } @@ -855,6 +940,7 @@ const pl = findPlatform(img.d, img.sw, img.sh, k, W); if (pl) { plat = pl; platT = t; } else if (t - platT > 700) plat = null; + const cosPhi = platCos(H, t); if (cfg.debug) { octx.lineWidth = 1; @@ -906,13 +992,14 @@ // Where the platform was as this shot left — the frame of reference the // whole shot model is expressed in. flightPlat = plat ? { x: plat.x, y: plat.y } : null; + if (flightCos === null) flightCos = cosPhi; calSamples = []; } if (fly) flyT = t; // Tracking drops the ball for a frame or two mid-flight, so the shot is // only called over once it has stayed gone. else if (t - flyT < 400) { /* still the same shot */ } - else { flightPlat = null; if (calSamples.length) commitCal(); } + else { flightPlat = null; if (calSamples.length) commitCal(); flightCos = null; } // ---- live arc for a ball in the air ---- let made = null; @@ -1003,7 +1090,7 @@ // exactly when you need it to line up the next shot. if (cfg.ghost && plat && ready) { const dir = lastRim ? Math.sign(lastRim.x - plat.x) || 1 : 1; - const curve = shotCurve(plat.x, plat.y, dir, W); + const curve = shotCurve(plat.x, plat.y, dir, W, cosPhi); // Start the line directly above the platform rather than at the curve's // left crossing: that crossing is ~0.18 of a screen to the left, which // ran off the edge and made the arc appear to fly in from nowhere. @@ -1042,7 +1129,12 @@ probe({ frame, plat, rim: lastRim, rimWhy, blobs: cands.length, tracks: tracks.length, flying, made, ready, ghostMade, - cal: { a: cfg.shotA, l: cfg.shotL, r: cfg.shotR, seeded: cfg.calSeeded } + cal: { a: cfg.shotA, l: cfg.shotL, r: cfg.shotR, seeded: cfg.calSeeded }, + // null until most of one platform swing has been seen; then the + // quadrature term that sets how hard this particular shot leaves + cosPhi: cosPhi == null ? null : +cosPhi.toFixed(3), + platY: plat ? +plat.y.toFixed(1) : null, + fit: lastFit }); } diff --git a/idleon-suite.user.js b/idleon-suite.user.js index e45580b..b825af9 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.30 +// @version 1.32 // @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 @@ -585,7 +585,7 @@ debug: false, // outline every detected blob // Calibration is stored as fractions of canvas size so it survives resizing // the window — the game scales its physics with the viewport. - calVer: 6, // bump to throw away calibration learned by an older build + calVer: 7, // bump to throw away calibration learned by an older build // The shot is a fixed parabola anchored to the PLATFORM, not to the ball in // your hands. Written as y = platY + A*(u - uL)*(u - R) where u is distance // right of the platform centre: A is curvature, uL and R are where the path @@ -616,10 +616,17 @@ // own spread across shots is a quarter of its value. Treat a disagreement // there as unsettled rather than as this seed being right. // - // KNOWN, UNEXPLAINED, and the biggest error left. These two are supposed to - // describe the SHOT, so anchoring to the platform should make them - // invariant to where the platform happens to be. They are not. Across two - // independent runs read off the live game: + // EXPLAINED as of v7, and no longer the biggest error left -- see platCos() + // in the state section. Platform height and release velocity are the same + // oscillator in quadrature (platY = 335 + 110*sin(phi), vy = -2.9 + + // 0.7*cos(phi), identical argument), so the coupling below is real but is + // neither linear nor even single-valued: one height means two shots, one + // rising and one falling. The correction now comes from the oscillator + // instead of from these constants, which stay as the cos(phi)=0 case. + // + // The measurements that led here, kept because they are what a linear + // reading of a quadrature coupling looks like. Across two independent runs + // read off the live game: // // corr(platY, shotL) corr(platY, shotR) // 8 flights -0.79 +0.71 @@ -712,7 +719,16 @@ // platform-relative, the arc meets platform height further out when the // platform sits lower, which is the observed sign. Settling it needs the // release instant, which nothing currently measures. - shotA: 2.233, // curvature x canvas width + // v7: the seed is now derived rather than fitted. Curvature is g/2vx^2 with + // g = 0.069 and vx = 3.9 per 10ms step, which on the 960-wide design canvas + // is 0.069/(2*3.9^2)*960 = 2.177. The old 2.233 came off 13 tracked flights + // (sd 0.034, range 2.195..2.288) and sits just outside that, i.e. it is a + // systematic 2.6% rather than noise -- the same direction and size as the + // tracking bias found in the darts helper, where following a blob centroid + // through a rotating sprite inflated fitted accelerations. Self-calibration + // still runs and will pull toward whatever the tracker sees; this only + // changes where a fresh install starts. + shotA: 2.177, // curvature x canvas width shotL: -0.119, // upward crossing, fraction of width left of the platform shotR: 0.547, // landing range, fraction of width right of the platform calSeeded: true, @@ -723,9 +739,9 @@ // live flights the committed curvature ranged 1.865-2.941 around a true // 2.23 — a live config caught mid-session held 2.486. That is not stale, it // is contaminated, and averaging more shots into it does not wash it out. - if (cfg.calVer !== 6) { - cfg.calVer = 6; cfg.calSeeded = true; - cfg.shotA = 2.233; cfg.shotL = -0.119; cfg.shotR = 0.547; + if (cfg.calVer !== 7) { + cfg.calVer = 7; cfg.calSeeded = true; + cfg.shotA = 2.177; cfg.shotL = -0.119; cfg.shotR = 0.547; } delete cfg.grav; delete cfg.launch; delete cfg.launchN; delete cfg.gravN; }); @@ -1044,8 +1060,50 @@ // ---------- state ---------- let plat = null, platT = 0; // the platform, re-found every frame + + // ---- the platform IS the shot ---- + // The game sets platY = 335 + 110*Trigg('sin', 0, 1.1) and releases at + // vy = -2.9 + 0.7*Trigg('cos', 0, 1.1). Trigg takes the SAME argument for + // both, so where the platform is and how hard the ball is thrown are one + // oscillator in quadrature: sin says where it is, cos says how fast the shot + // leaves. sin comes from the platform's height, cos from which way it is + // travelling. + // + // This is what the note on shotL/shotR above could not explain. Platform + // height really is coupled to the shot, which is why the correlations were + // -0.79 and +0.71 -- but a given height maps to TWO different shots, one on + // the way up and one on the way down, and nothing linear in height can tell + // them apart. Worse, the relationship is not even monotonic: the shot is at + // its EXTREMES when the platform is at mid height and average when the + // platform is at the top or bottom of its travel. Over 8 and 5 flights inside + // one ~5s cycle that looks locally linear and correlates strongly, then fails + // out of sample -- exactly the 43%-better-on-shotL, 3%-better-at-the-rim + // split that was measured. + let platLo = Infinity, platHi = -Infinity, platHist = []; + function platCos(H, t) { + if (!plat) return null; + platHist.push({ t, y: plat.y }); + while (platHist.length > 1 && t - platHist[0].t > 400) platHist.shift(); + if (plat.y < platLo) platLo = plat.y; + if (plat.y > platHi) platHi = plat.y; + // The full swing is 220 of 540 on the design canvas. Until most of one has + // been seen the midpoint is a guess, and a wrong midpoint is worse than no + // correction at all. + if (platHi - platLo < (200 / 540) * H) return null; + const y0 = (platLo + platHi) / 2, amp = (platHi - platLo) / 2; + const sn = Math.max(-1, Math.min(1, (plat.y - y0) / amp)); + if (platHist.length < 3) return null; + const dy = plat.y - platHist[0].y; + // Near the turning points the direction cannot be read -- but that is also + // where cos is near zero, so falling back to no correction there costs + // almost nothing. The failure is self-limiting. + if (Math.abs(dy) < 0.5) return null; + return Math.sign(dy) * Math.sqrt(Math.max(0, 1 - sn * sn)); + } let holdT = -1e9; // last time a ball was seen in your hands let flightPlat = null; // where the platform was when this shot left + let flightCos = null; // and the quadrature term it left on + let lastFit = null; // the finished shot's own fit, for the probe let calSamples = [], flyT = 0; // per-flight calibration fits, awaiting commit // Calibration used to be folded in on every frame of a flight. With a 0.25 @@ -1063,6 +1121,11 @@ return v[v.length >> 1]; }; const An = med('A'), Ln = med('L'), Rn = med('R'); + // Publish this shot's own fit next to the quadrature term it was thrown on. + // If the oscillator really sets the release velocity, R must track cos -- + // that is the claim, and it is testable against any recording. + lastFit = { A: +An.toFixed(4), L: +Ln.toFixed(4), R: +Rn.toFixed(4), + cos: flightCos == null ? null : +flightCos.toFixed(3), n: s.length }; const w = cfg.calSeeded ? 1 : 0.3; // first real shot replaces the seed cfg.shotA += (An - cfg.shotA) * w; cfg.shotL += (Ln - cfg.shotL) * w; @@ -1073,8 +1136,30 @@ // The shot as a curve in screen space, anchored to the platform. Time never // enters it, so it does not depend on when the ball was first spotted. - function shotCurve(px, py, dir, W) { - const A = cfg.shotA / W, uL = cfg.shotL * W, uR = cfg.shotR * W; + // Release x offset, 17 of 960 on the design canvas: the ball leaves the hand + // at (px+17, py-97), and only the x part is needed here because the curve is + // already anchored in y to the platform. + const RELX = 17 / 960; + function shotCurve(px, py, dir, W, cosPhi) { + const A = cfg.shotA / W; + let uL = cfg.shotL * W, uR = cfg.shotR * W; + if (cosPhi != null) { + // Re-cut the parabola for the vy this particular throw will actually get. + // Curvature is g/2vx^2 and cannot move -- neither g nor vx depends on the + // oscillator -- so the only thing that changes is the launch slope, by + // d(vy/vx) = 0.7*cos/3.9. The release point is left exactly where the + // shipped constants put it, which means cosPhi 0 reproduces the old curve + // to the pixel and this can only add the variation that was missing. + const ur = RELX * W; + const yr = A * (ur - uL) * (ur - uR); + const m = A * (2 * ur - uL - uR) + (0.7 * cosPhi) / 3.9; + const disc = m * m - 4 * A * yr; + if (disc > 0) { + const r = Math.sqrt(disc); + uL = ur + (-m - r) / (2 * A); + uR = ur + (-m + r) / (2 * A); + } + } return { at: x => { const u = (x - px) * dir; return py + A * (u - uL) * (u - uR); }, A, uL, uR, px, py, dir }; } @@ -1280,6 +1365,7 @@ const pl = findPlatform(img.d, img.sw, img.sh, k, W); if (pl) { plat = pl; platT = t; } else if (t - platT > 700) plat = null; + const cosPhi = platCos(H, t); if (cfg.debug) { octx.lineWidth = 1; @@ -1331,13 +1417,14 @@ // Where the platform was as this shot left — the frame of reference the // whole shot model is expressed in. flightPlat = plat ? { x: plat.x, y: plat.y } : null; + if (flightCos === null) flightCos = cosPhi; calSamples = []; } if (fly) flyT = t; // Tracking drops the ball for a frame or two mid-flight, so the shot is // only called over once it has stayed gone. else if (t - flyT < 400) { /* still the same shot */ } - else { flightPlat = null; if (calSamples.length) commitCal(); } + else { flightPlat = null; if (calSamples.length) commitCal(); flightCos = null; } // ---- live arc for a ball in the air ---- let made = null; @@ -1428,7 +1515,7 @@ // exactly when you need it to line up the next shot. if (cfg.ghost && plat && ready) { const dir = lastRim ? Math.sign(lastRim.x - plat.x) || 1 : 1; - const curve = shotCurve(plat.x, plat.y, dir, W); + const curve = shotCurve(plat.x, plat.y, dir, W, cosPhi); // Start the line directly above the platform rather than at the curve's // left crossing: that crossing is ~0.18 of a screen to the left, which // ran off the edge and made the arc appear to fly in from nowhere. @@ -1467,7 +1554,12 @@ probe({ frame, plat, rim: lastRim, rimWhy, blobs: cands.length, tracks: tracks.length, flying, made, ready, ghostMade, - cal: { a: cfg.shotA, l: cfg.shotL, r: cfg.shotR, seeded: cfg.calSeeded } + cal: { a: cfg.shotA, l: cfg.shotL, r: cfg.shotR, seeded: cfg.calSeeded }, + // null until most of one platform swing has been seen; then the + // quadrature term that sets how hard this particular shot leaves + cosPhi: cosPhi == null ? null : +cosPhi.toFixed(3), + platY: plat ? +plat.y.toFixed(1) : null, + fit: lastFit }); } // ---------- wiring ----------