// ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot // @version 1.50 // @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 // @match https://www.legendsofidleon.com/* // @grant none // @run-at document-start // @all-frames true // ==/UserScript== // // This is the four standalone scripts (idleon-clicker, idleon-hoops, // idleon-fishing, idleon-darts) merged into one install. The detection, // physics and calibration code is the same code, moved verbatim; what is new // is the shell around it: // // * a Suite panel that switches each helper on and off. A helper that is off // builds no UI, reads no pixels and claims no hotkeys — it costs nothing. // * one animation frame drives every enabled helper, and the downscaled // readback of the game canvas is taken ONCE per frame and shared, instead // of once per helper. // * shared panel chrome: drag, roll-up, hide, the nub that brings a hidden // panel back, and the focus hygiene that keeps game keys out of the panel. // * panel positions are remembered, which matters now that there are five. // // Each helper keeps its own localStorage key (ac_cfg, hoops_cfg, fish_cfg, // darts_cfg), so calibration learned by the standalone scripts carries over // and either version can be run without disturbing the other's settings. // // Uninstall the four standalone scripts before enabling this one, or you get // two of everything. (function () { 'use strict'; // ---------- make the game's backbuffer readable ---------- // OpenFL exports to WebGL, whose drawing buffer is wiped after each compose // unless preserveDrawingBuffer is set, and getContext caches per canvas — so // this has to land before the game creates its context, which is why the // whole script runs at document-start. // // It is patched in EVERY frame (@all-frames) even though the panels are only // built in the top one: the patch must reach whichever document ends up // owning the canvas, and it is free if nothing there ever asks for WebGL. const origGetContext = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function (type, attrs) { if (/webgl/i.test(type)) attrs = Object.assign({}, attrs || {}, { preserveDrawingBuffer: true }); return origGetContext.call(this, type, attrs); }; // The UI is built in the top frame only. The clicker has always run top-only // (it dispatches mouse events into this document, so it has to be where the // canvas is) and it works, which says the game canvas lives here. Building // panels in subframes too would give a second, dead copy of all five. // Flip this if the game ever moves into an iframe. const UI_IN_SUBFRAMES = false; if (!UI_IN_SUBFRAMES && window.top !== window.self) return; // ===================================================================== // Suite core — everything the individual helpers share // ===================================================================== // ---------- which helpers are on ---------- const SUITE_KEY = 'idleon_suite'; const ALL_ON = { clicker: true, hoops: true, fishing: true, darts: true }; // layout: 'free' keeps the dragged-anywhere behaviour every version until now // had, and stays the default so an upgrade moves nobody's panels. 'left' and // 'top' dock them into one column or one row. // solo: opening a helper closes the other helpers. Only meaningful docked, // where they share a column; see the collapse handler. // follow: opt in to letting the active minigame open its own helper. const suite = Object.assign({ collapsed: false, hidden: false, layout: 'free', solo: true, follow: false }, JSON.parse(localStorage.getItem(SUITE_KEY) || '{}')); suite.enabled = Object.assign({}, ALL_ON, suite.enabled); const saveSuite = () => localStorage.setItem(SUITE_KEY, JSON.stringify(suite)); // ---------- the game canvas ---------- // Largest canvas on the page is the game; anything smaller is a UI element. function gameCanvas() { let best = null, area = 0; for (const c of document.querySelectorAll('canvas')) { const a = c.clientWidth * c.clientHeight; if (a > area) { area = a; best = c; } } return area > 160000 ? best : null; // ignore tiny/UI canvases } // ---------- 3x3 solve, used by the hoops and fishing curve fits ---------- function solve3(M, V) { const A = M.map((r, i) => r.concat(V[i])); for (let c = 0; c < 3; c++) { let piv = c; for (let r = c + 1; r < 3; r++) if (Math.abs(A[r][c]) > Math.abs(A[piv][c])) piv = r; if (Math.abs(A[piv][c]) < 1e-12) return null; const tmp = A[c]; A[c] = A[piv]; A[piv] = tmp; for (let r = 0; r < 3; r++) { if (r === c) continue; const f = A[r][c] / A[c][c]; for (let k = c; k < 4; k++) A[r][k] -= f * A[c][k]; } } return [A[0][3] / A[0][0], A[1][3] / A[1][1], A[2][3] / A[2][2]]; } // ---------- shared downscaled readback of the whole frame ---------- // All three minigame helpers want the same thing: the frame, downscaled by // cfg.scale, as raw RGBA. Standalone that was one drawImage + getImageData // each; here the first caller in a frame pays for it and the rest read the // same buffer. They can only differ if their scales differ, so the cache is // keyed on scale as well as on the frame and the canvas. const scratch = document.createElement('canvas'); const sctx = scratch.getContext('2d', { willReadFrequently: true }); let frameId = 0, cache = null; let grabErr = ''; function grabFrame(cv, scale) { if (cache && cache.f === frameId && cache.cv === cv && cache.scale === scale) { grabErr = cache.err; return cache.img; } const sw = Math.max(1, Math.round(cv.width / scale)); const sh = Math.max(1, Math.round(cv.height / scale)); if (scratch.width !== sw || scratch.height !== sh) { scratch.width = sw; scratch.height = sh; } let img = null; try { sctx.clearRect(0, 0, sw, sh); sctx.drawImage(cv, 0, 0, sw, sh); img = { d: sctx.getImageData(0, 0, sw, sh).data, sw, sh }; grabErr = ''; } catch (e) { grabErr = e && e.name === 'SecurityError' ? 'canvas not readable (tainted)' : 'pixel readback failed'; } cache = { f: frameId, cv, scale, img, err: grabErr }; return img; } // ---------- docked layouts ---------- // Five panels is a lot of furniture to arrange by hand every session, and // only one helper is ever useful at a time — you are in exactly one minigame. // Docking stacks them against an edge in a fixed order and takes over their // positions; the saved px/py are left untouched so switching back to 'free' // restores exactly where things were. const docks = []; // { def, ui }, sorted by def.dockOrder const DOCK_EDGE = 10, DOCK_GAP = 8; let relayoutPending = false; function relayout() { if (suite.layout === 'free') { for (const d of docks) d.ui.place(); return; } const vert = suite.layout === 'left'; // ?? not ||: the hub is dockOrder 0, which || would treat as missing and // sort to the bottom of its own dock. const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); // Packed into lanes, not shelved into rows. Shelving — starting every // wrapped panel below the TALLEST one before it — leaves a hole: expanding // the clicker pushed a collapsed Darts panel most of a screen down, past // the empty space under the Suite panel where it plainly belonged. // // So panels run along the dock's edge until the viewport is used up, and // that fixes a set of lanes: columns for a top dock, rows for a left one. // Everything after goes into whichever lane is currently SHALLOWEST, so a // short panel fills the gap beside a short neighbour instead of clearing // the tall one. Lanes are disjoint along the edge, so nothing can overlap // however the depths fall. const lim = vert ? window.innerHeight - DOCK_EDGE : window.innerWidth - DOCK_EDGE; const lanes = []; // { pos, size, edge } along / across / depth used let cursor = DOCK_EDGE; for (const { ui } of list) { if (ui.cfg.hidden) continue; // hidden panels are a nub, not a slot const p = ui.panel; p.style.right = 'auto'; // Measured before placing: style.width pins the width, so the height does // not depend on where it lands, and the lane has to be chosen first. const r = p.getBoundingClientRect(); const along = vert ? r.height : r.width; // extent along the dock edge const deep = vert ? r.width : r.height; // extent away from it let lane; if (cursor + along <= lim || !lanes.length) { // Room for another lane — or this is the first panel, which opens one // even if it is bigger than the viewport, because there is nowhere else. lane = { pos: cursor, size: along, edge: DOCK_EDGE }; lanes.push(lane); cursor += along + DOCK_GAP; } else { // Prefer the shallowest lane this actually FITS in; panels differ by up // to ~30px and one placed in a narrower lane would hang over its // neighbour. Fall back to the shallowest overall if none is wide enough. const fits = lanes.filter(l => l.size >= along); const pool = fits.length ? fits : lanes; lane = pool.reduce((m, l) => (l.edge < m.edge ? l : m), pool[0]); } p.style.left = (vert ? lane.edge : lane.pos) + 'px'; p.style.top = (vert ? lane.pos : lane.edge) + 'px'; lane.edge += deep + DOCK_GAP; } } // The hub owns the layout controls, but a drag out of a dock has to change // the layout from inside makePanel. This is the seam between the two. let onLayoutChange = () => {}; // Solo has to be an invariant, not just something the collapse button does. // Arriving in a dock with four helpers already open gives a column that needs // two of them to fit — which is the exact thing the dock is for avoiding. So // entering a docked layout closes all but the first open helper. function enforceSolo() { if (!suite.solo || suite.layout === 'free') return; let kept = false; const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); for (const d of list) { if (!d.def.helper || d.ui.cfg.collapsed) continue; if (!kept) { kept = true; continue; } // the first open one stays open d.ui.cfg.collapsed = true; d.ui.save(); d.ui.chrome(); } } function syncLayout() { enforceSolo(); relayout(); onLayoutChange(); } // Collapsing a panel changes every panel below it, so the re-stack is // coalesced to one pass per frame rather than run per panel per change. function relayoutSoon() { if (relayoutPending) return; relayoutPending = true; requestAnimationFrame(() => { relayoutPending = false; relayout(); }); } window.addEventListener('resize', relayoutSoon); // ---------- panel chrome ---------- // Every panel is the same furniture around a different body: a title bar // that drags, a roll-up toggle, a hide toggle, and a nub that brings a // hidden panel back. F-keys can be swallowed by the browser (F10 opens the // menu bar), so the nub is the guaranteed way back, not a convenience. const CHROME_CSS = ` * { box-sizing: border-box; font: 12px/1.4 monospace; } canvas { position: fixed; left: 0; top: 0; pointer-events: none; } #p { position: fixed; background: #14171c; color: #cdd3da; border: 1px solid #2a2f37; border-radius: 8px; pointer-events: auto; user-select: none; box-shadow: 0 6px 24px rgba(0,0,0,.5); } #hd { display:flex; align-items:center; justify-content:space-between; padding: 7px 9px; cursor: move; background:#1b1f26; border-radius:8px 8px 0 0; } #hd b { color:#8b95a3; font-weight:600; letter-spacing:.3px; } #dot { width:9px; height:9px; border-radius:50%; background:#4b5563; display:inline-block; } #dot.on { background: var(--dot); box-shadow: 0 0 8px var(--dot); } .body { padding: 9px; display:flex; flex-direction:column; gap:7px; } .row { display:flex; align-items:center; justify-content:space-between; gap:6px; } label { color:#8b95a3; } input[type=number] { width:60px; background:#0c0e12; color: var(--dot); border:1px solid #2a2f37; border-radius:4px; padding:2px 4px; } input[type=checkbox] { accent-color: var(--ac); } .seg { display:flex; border:1px solid #2a2f37; border-radius:5px; overflow:hidden; } .seg button { background:#0c0e12; color:#8b95a3; border:0; padding:3px 8px; cursor:pointer; } .seg button.sel { background: var(--ac); color:#fff; } .btn { width:100%; padding:6px; border:0; border-radius:5px; cursor:pointer; background:#2a2f37; color:#cdd3da; } .btn.go { background:#16a34a; color:#fff; } .btn.stop { background: var(--stop); color:#fff; } .btn.arm { background:#a16207; color:#fff; } .btn.sm { padding:4px; font-size:11px; } #st { color:#6b7280; font-size:11px; white-space:pre-line; min-height:28px; } .hint { color:#4b5563; font-size:11px; text-align:center; } #min { cursor:pointer; color:#6b7280; padding:0 4px; } #nub { position: fixed; width: 13px; height: 13px; border-radius: 50%; background: var(--ac); opacity: .55; cursor: pointer; pointer-events: auto; display: none; } #nub:hover { opacity: 1; } .eye { background:none; border:0; color: var(--ac); cursor:pointer; padding:0 2px; font-size:11px; line-height:1; } .eye.off { color:#374151; } details summary { color:#4b5563; cursor:pointer; font-size:11px; outline:none; } details .body { padding:7px 0 0; gap:6px; } hr { border:0; border-top:1px solid #2a2f37; margin:1px 0; }`; // Every live panel's shadow root, so a game key can be swallowed from // whichever one happens to hold focus. const roots = new Set(); function makePanel(def, cfg) { const host = document.createElement('div'); host.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:' + def.z; const root = host.attachShadow({ mode: 'closed' }); document.documentElement.appendChild(host); roots.add(root); const t = def.theme; root.innerHTML = `` + (def.overlay ? '' : '') + `
${def.name}
${def.bodyHTML}
`; const $ = s => root.querySelector(s); const panel = $('#p'), nub = $('#nub'), minBtn = $('#min'), body = $('#p > .body'); const ov = $('#ov'), octx = ov ? ov.getContext('2d') : null; // Layout: the slot is where the panel sits until it is dragged, after // which its own position is remembered — five panels is too many to // re-arrange every session. // // Everything is clamped to the viewport, saved positions and default slots // alike. A position saved on a wider window, or a default slot on a narrow // one, otherwise puts a panel where it cannot be reached or dragged back — // and the only cure left is clearing localStorage. panel.style.width = def.slot.width + 'px'; nub.style.top = '6px'; nub.style.left = def.slot.nub + 'px'; function place() { const w = def.slot.width, h = 40; if (cfg.px != null && cfg.py != null) { panel.style.right = 'auto'; panel.style.left = Math.max(0, Math.min(cfg.px, window.innerWidth - w)) + 'px'; panel.style.top = Math.max(0, Math.min(cfg.py, window.innerHeight - h)) + 'px'; return; } panel.style.top = Math.max(0, Math.min(def.slot.top, window.innerHeight - h)) + 'px'; if (def.slot.right != null && def.slot.right + w <= window.innerWidth) { panel.style.left = 'auto'; panel.style.right = def.slot.right + 'px'; } else { panel.style.right = 'auto'; panel.style.left = Math.max(0, Math.min(def.slot.left != null ? def.slot.left : window.innerWidth - w - def.slot.right, window.innerWidth - w)) + 'px'; } } place(); // Listeners are tracked so a helper that gets switched off leaves nothing // behind on window or document. const bound = []; const on = (target, type, fn, capture) => { target.addEventListener(type, fn, capture); bound.push([target, type, fn, capture]); }; function chrome() { body.style.display = cfg.collapsed ? 'none' : ''; minBtn.textContent = cfg.collapsed ? '+' : '–'; panel.style.display = cfg.hidden ? 'none' : ''; nub.style.display = cfg.hidden ? '' : 'none'; relayoutSoon(); // heights and occupancy just changed } // drag let dx = 0, dy = 0, drag = false; $('#hd').addEventListener('mousedown', e => { if (e.target.id === 'min') return; // Dragging out of a dock means you want it somewhere else, so the dock // gets out of the way rather than snapping the panel back and looking // broken. "Reset panel layout" puts it back. if (suite.layout !== 'free') { suite.layout = 'free'; saveSuite(); syncLayout(); } drag = true; const r = panel.getBoundingClientRect(); dx = e.clientX - r.left; dy = e.clientY - r.top; panel.style.right = 'auto'; }); on(window, 'mousemove', e => { if (!drag) return; panel.style.left = (e.clientX - dx) + 'px'; panel.style.top = (e.clientY - dy) + 'px'; }); on(window, 'mouseup', () => { if (!drag) return; drag = false; const r = panel.getBoundingClientRect(); cfg.px = Math.round(r.left); cfg.py = Math.round(r.top); ui.save(); }); const ui = { def, cfg, root, $, panel, ov, octx, on, chrome, // The way back from a panel that has been dragged somewhere unreachable, // or left off-screen by a window that has since been made narrower. reset() { cfg.px = null; cfg.py = null; cfg.hidden = false; cfg.collapsed = false; ui.save(); place(); chrome(); }, dot: $('#dot'), runBtn: $('#run'), stEl: $('#st'), nub, minBtn, body, place, // so a dock can hand positions back on the way out save: () => {}, // replaced by the module, which owns its store // Keep every control out of the tab order and drop focus as soon as it // is released, so a Space or Enter aimed at the game can't re-fire // whichever control was touched last. Number inputs keep focus while // they are being typed into; the hotkey handler yields to them. settle() { root.querySelectorAll('button, input[type=checkbox], summary').forEach(el => { el.setAttribute('tabindex', '-1'); el.addEventListener('mouseup', () => el.blur()); }); root.querySelectorAll('input[type=number]').forEach(el => el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === 'Escape') el.blur(); })); }, destroy() { for (const [tg, ty, fn, cap] of bound) tg.removeEventListener(ty, fn, cap); roots.delete(root); host.remove(); const i = docks.findIndex(d => d.ui === ui); if (i >= 0) docks.splice(i, 1); relayoutSoon(); } }; docks.push({ def, ui }); minBtn.addEventListener('click', () => { cfg.collapsed = !cfg.collapsed; ui.save(); // Solo closes the other HELPERS when you open one — not the clicker, // which is useful alongside any of them, and not the suite panel. Only // while docked: in the free layout the panels are wherever you put them // and collapsing one you never touched would just look like a bug. if (!cfg.collapsed && suite.solo && suite.layout !== 'free' && def.helper) { for (const d of docks) { if (d.ui === ui || !d.def.helper || d.ui.cfg.collapsed) continue; d.ui.cfg.collapsed = true; d.ui.save(); d.ui.chrome(); } } chrome(); }); nub.addEventListener('click', () => { cfg.hidden = false; ui.save(); chrome(); }); return ui; } // ---------- one animation frame for the whole suite ---------- const live = new Map(); // id -> running instance function driver() { requestAnimationFrame(driver); frameId++; cache = null; for (const inst of live.values()) { if (!inst.loop) continue; try { inst.loop(); } catch (e) { // A throw used to kill that helper's self-scheduling loop outright and // silently. Now it is contained, reported in the helper's own status // line, and given a few frames to be a transient before it is dropped. inst.errs = (inst.errs || 0) + 1; if (inst.errs === 1) console.error('[IdleOn suite] ' + inst.id + ' loop failed', e); if (inst.errs > 5) { inst.loop = null; if (inst.ui.stEl) inst.ui.stEl.textContent = 'stopped: loop threw\nsee the console'; } } } } // ---------- hotkeys ---------- // One capture-phase listener for the suite. Capture, so it lands before the // browser turns a Space or Enter into a click on whatever control still // holds focus, and before the page sees the key at all. const keymap = new Map(); window.addEventListener('keydown', e => { if (e.key === ' ' || e.key === 'Enter') { for (const r of roots) { const a = r.activeElement; if (a && a.tagName !== 'INPUT') a.blur(); } return; } const fn = keymap.get(e.key); if (!fn) return; e.preventDefault(); // Function keys are never typed into a field, and the game canvas swallows // the mousedown that would otherwise blur one — so a field left focused // used to strand the hotkeys. Blur on the way through, which also commits // a half-typed value. for (const r of roots) if (r.activeElement) r.activeElement.blur(); fn(); }, true); // ---------- starting and stopping a helper ---------- function startModule(def) { if (live.has(def.id)) return; const cfg = def.cfg; const ui = makePanel(def, cfg); ui.save = def.save; const inst = def.init(ui) || {}; inst.id = def.id; inst.ui = ui; live.set(def.id, inst); ui.settle(); ui.chrome(); if (inst.sync) inst.sync(); for (const [key, name] of Object.entries(def.hotkeys)) { if (name === 'hide') keymap.set(key, () => { cfg.hidden = !cfg.hidden; def.save(); ui.chrome(); }); else if (inst[name]) keymap.set(key, inst[name]); } } function stopModule(def) { const inst = live.get(def.id); if (!inst) return; if (inst.destroy) inst.destroy(); inst.loop = null; inst.ui.destroy(); live.delete(def.id); for (const key of Object.keys(def.hotkeys)) keymap.delete(key); } const setEnabled = (def, want) => { suite.enabled[def.id] = !!want; saveSuite(); want ? startModule(def) : stopModule(def); }; // A helper's config store. Each keeps the key the standalone script used, so // hard-won calibration survives the move. function store(key, defaults, migrate) { const cfg = Object.assign({ collapsed: false, hidden: false, px: null, py: null }, defaults, JSON.parse(localStorage.getItem(key) || '{}')); if (migrate) migrate(cfg); let last = 0; const save = () => localStorage.setItem(key, JSON.stringify(cfg)); const saveSoon = () => { const t = performance.now(); if (t - last > 1000) { last = t; save(); } }; return { cfg, save, saveSoon }; } // ===================================================================== // Helper — Clicker // Stealthy autoclicker. The only helper that reads no pixels and needs no // animation frame; it runs on its own randomised setTimeout. // ===================================================================== const clicker = store('ac_cfg', { ivMin: 600, // ms — lower bound of click interval ivMax: 1200, // ms — upper bound; each click picks uniformly in [min, max] jitterPx: 2, // +/- position jitter in px (0 = pixel-perfect) mode: 'cursor', // 'cursor' | 'fixed' fx: 0, fy: 0, // fixed target, viewport px (legacy / no-canvas fallback) fu: null, fv: null,// fixed target as a fraction of the game canvas rect }, cfg => { // migrate old base+jitter config -> min/max range if (cfg.ivMin === undefined && cfg.interval !== undefined) { const j = cfg.jitterMs || 0; cfg.ivMin = Math.max(20, cfg.interval - j); cfg.ivMax = cfg.interval + j; } delete cfg.interval; delete cfg.jitterMs; }); const CLICKER = { id: 'clicker', name: 'IdleOn Clicker', short: 'Clicker', z: 2147483646, theme: { dot: '#4ade80', ac: '#2563eb', stop: '#dc2626' }, slot: { top: 12, right: 12, width: 210, nub: 24 }, dockOrder: 1, overlay: false, hotkeys: { F8: 'toggle', F9: 'panic', F10: 'hide' }, keyHint: 'F8', cfg: clicker.cfg, save: clicker.save, bodyHTML: `
ms
ms
px
F8 toggle · F9 panic-off · F10 hide
`, init(ui) { const cfg = clicker.cfg, save = clicker.save; const $ = ui.$, root = ui.root, runBtn = ui.runBtn, dot = ui.dot; const ivMinEl = $('#ivmin'), ivMaxEl = $('#ivmax'), jpEl = $('#jp'), xyEl = $('#xy'), setBtn = $('#set'); let on = false, timer = null, capturing = false; // lastX/lastY only move while the pointer is over THIS window, so in a // second window they go stale on the way out and are 0,0 before it has // ever arrived. See the standalone clicker for the whole story; ptrIn is // what says whether the coordinates mean anything. let lastX = 0, lastY = 0, ptrIn = false, wasBlind = false; ui.on(document, 'mousemove', e => { lastX = e.clientX; lastY = e.clientY; ptrIn = true; }, true); // A null relatedTarget is the pointer leaving the document altogether; // leaving for a panel names that element instead and does not count. ui.on(document, 'mouseout', e => { if (!e.relatedTarget) ptrIn = false; }, true); function sync() { ivMinEl.value = cfg.ivMin; ivMaxEl.value = cfg.ivMax; jpEl.value = cfg.jitterPx; root.querySelectorAll('.seg button').forEach(b => b.classList.toggle('sel', b.dataset.m === cfg.mode)); xyEl.textContent = cfg.mode !== 'fixed' ? (ptrIn ? '(follows cursor)' : 'cursor is in another window') : hasTarget() ? fixedPoint().map(Math.round).join(', ') : 'not set'; dot.classList.toggle('on', on); runBtn.textContent = on ? 'Stop (F8)' : 'Start (F8)'; runBtn.className = 'btn ' + (on ? 'stop' : 'go'); setBtn.textContent = capturing ? 'Click a spot…' : 'Set Position'; ui.chrome(); } // ---------- target resolution ---------- // Largest canvas on the page is the game; anything smaller is a UI element. function gameCanvas() { let best = null, area = 0; for (const c of document.querySelectorAll('canvas')) { const a = c.clientWidth * c.clientHeight; if (a > area) { area = a; best = c; } } return area > 160000 ? best : null; // ignore tiny/UI canvases } // A fixed target is stored as a fraction of the game canvas, not as viewport // pixels, so it survives a resize, zoom or fullscreen toggle. The canvas is // scaled and letterboxed, so the same screen pixel lands on a different spot // in the world once its size changes — and a click on bare ground is a walk // command, which is how a drifted target sends the character strolling off. const hasTarget = () => cfg.fu != null || cfg.fx || cfg.fy; function fixedPoint() { const cv = gameCanvas(); if (!cv || cfg.fu == null) return [cfg.fx, cfg.fy]; // pre-3.3 config const r = cv.getBoundingClientRect(); return [r.left + cfg.fu * r.width, r.top + cfg.fv * r.height]; } // ---------- clicking ---------- const rand = j => j ? (Math.random() * 2 - 1) * j : 0; function clickAt(x, y) { x = Math.round(x + rand(cfg.jitterPx)); y = Math.round(y + rand(cfg.jitterPx)); const el = document.elementFromPoint(x, y) || gameCanvas(); if (!el) return; for (const type of ['mousemove', 'mousedown', 'mouseup', 'click']) { el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0, buttons: type === 'mousedown' ? 1 : 0 })); } } function tick() { if (!on) return; // Cursor mode with the pointer in another window has nothing to aim at, so // it holds rather than clicking a stale coordinate. The timer keeps running // and it resumes by itself when the pointer comes back. Announced, because // the failure is otherwise invisible: the clicker looks like it is running // and the game just never responds. const blind = cfg.mode !== 'fixed' && !ptrIn; if (blind !== wasBlind) { wasBlind = blind; sync(); } // Resolved every tick: the canvas rect can change under a running clicker. if (!blind && (cfg.mode !== 'fixed' || hasTarget())) { const [tx, ty] = cfg.mode === 'fixed' ? fixedPoint() : [lastX, lastY]; clickAt(tx, ty); } const lo = Math.min(cfg.ivMin, cfg.ivMax), hi = Math.max(cfg.ivMin, cfg.ivMax); timer = setTimeout(tick, Math.max(20, lo + Math.random() * (hi - lo))); } function start() { if (!on) { on = true; sync(); tick(); } } function stop() { on = false; clearTimeout(timer); sync(); } function toggle(){ on ? stop() : start(); } // ---------- position capture ---------- function armCapture() { capturing = true; sync(); const grab = e => { e.preventDefault(); e.stopPropagation(); cfg.fx = e.clientX; cfg.fy = e.clientY; const cv = gameCanvas(), r = cv && cv.getBoundingClientRect(); cfg.fu = r ? (e.clientX - r.left) / r.width : null; cfg.fv = r ? (e.clientY - r.top) / r.height : null; cfg.mode = 'fixed'; capturing = false; save(); sync(); window.removeEventListener('mousedown', grab, true); }; ui.on(window, 'mousedown', grab, true); } // ---------- wiring ---------- runBtn.onclick = toggle; setBtn.onclick = () => capturing ? null : armCapture(); ivMinEl.onchange = e => { cfg.ivMin = Math.max(20, +e.target.value); save(); }; ivMaxEl.onchange = e => { cfg.ivMax = Math.max(20, +e.target.value); save(); }; jpEl.onchange = e => { cfg.jitterPx = Math.max(0, +e.target.value); save(); }; root.querySelectorAll('.seg button').forEach(b => b.onclick = () => { cfg.mode = b.dataset.m; save(); sync(); }); // panic stops the clicker outright; switching the helper off has to as // well, or a torn-down panel leaves a timer clicking with no way to see it. return { sync, toggle, panic: stop, destroy: stop }; } }; // ===================================================================== // Helper — Swishy Hoops // Dotted-line shot preview + live ball arc. // ===================================================================== const hoops = store('hoops_cfg', { on: true, scale: 4, // pixel-readback downscale (bigger = cheaper, blurrier) // Two masks, both measured off the real sprites. // Ball: hue 13-33, but its seam lines drop to v.38 — the threshold has to // stay below them or the ball fragments into pieces too small to detect. // Rim: bright red bar, v.85-.98. The wooden platform is hue 31 v.66, so it // passes the ball mask (and is thrown out by aspect ratio) but can never // reach rimV — which is the only thing keeping it from being read as a hoop. hue: 22, // centre of the ball hue window, degrees hueW: 24, // +/- hue window sMin: 0.45, // min saturation, BALL mask only — exposed in tuning vMin: 0.36, // min value — must stay under the ball's dark seams rimV: 0.80, // rim min brightness (platform is .66 and must fail this) // The rim used to share sMin with the ball. Raising Min sat to chase a // cleaner ball mask therefore ate the rim's lit top edge (s .47) without // saying so — and rim detection is both invisible in a screen recording and // the thing that has broken most often. It gets its own floor. The platform // is still kept out by rimV, not by this. rimS: 0.42, // rim min saturation, independent of the ball tuning hud: 25, // HUD corner height, % of canvas (score pips, reward icon) span: 2500, // how far ahead to draw, ms ghost: true, // preview the shot from where you're standing trail: true, // dots on recent observed positions makes: true, // turn the line green when it predicts a make gate: true, // only draw while the Swishy Hoops screen is up 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: 8, // 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 // crosses platform height going up and coming down. Anchoring to the held // ball instead was ~100px out, because that anchor goes stale while the // character jumps and the platform keeps moving under them. // // "Fixed" is now measured rather than assumed. The README used to record an // open question -- per-shot arc ranging 1.71-3.01, "either the shot // genuinely varies or the single-shot fit is noisy". It is the fit. Fitting // x(t) and y(t) separately across 15 live flights (which needs no release // instant, and cannot degenerate the way y-as-a-function-of-x does) gives a // release velocity of 536-541 px/s horizontally across every well-tracked // flight -- the same shot to half a percent. Nothing about it varies. // // These three are the medians of 8 flights that passed the span and bounce // screens, read live off the running game rather than off a recording: // // shotA 2.233 sd .034 range 2.195..2.288 old seed 2.103, 6% low // shotL -0.119 sd .030 range -.158..-.083 old seed -0.179, 33% off // shotR 0.547 sd .031 range .510.. .588 old seed 0.557, agrees // // shotR is the one the old five-shot seed already had right, and it is also // the one the tracked points actually cover. shotL is the weakest of the // three in both seeds: it is where the arc crossed platform height on the // way UP, which is behind the point tracking starts, so no shot ever // observes it directly and every estimate of it is an extrapolation. Its // own spread across shots is a quarter of its value. Treat a disagreement // there as unsettled rather than as this seed being right. // // 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 // 5 flights -0.86 +0.77 // // Curvature barely moves, which is the tell: shotA is set by g/2vx^2 and // neither of those cares how high the platform is, while shotL and shotR // are where the arc meets platform HEIGHT. // // It looks like the lever and it is not. Modelling shotL as linear in // platform height predicts it 43% better out of sample -- leave-one-out // mean error 51.6px falls to 29.6px -- and shotR barely moves, 40.8 to // 37.7px. But the number that decides a make is the height of the arc where // it passes the RIM, and there the same comparison is: // // constant, as shipped mean 54.7px worst 117.5px // linear in platform height mean 53.0px worst 113.9px // // Three percent. The errors in A, L and R are correlated and largely cancel // by the time the curve reaches the rim, so a correction that plainly // improves two of the three parameters buys almost nothing where it counts. // Measured, not argued, and left unshipped on that basis: an unexplained // empirical correction fitted on 11 flights from one player has to earn // more than 3% before it goes in. // // It is worse than useless, and the way it hides is worth writing down. // Adding a second session's flights and pooling the two made the same // correction look like a 31% win -- 75.2px down to 51.8px -- which is // exactly the kind of number that gets a change shipped. Split by session // it evaporates: // // session A (n=8) 54.7px -> 53.0px -3% // session B (n=7) 61.4px -> 75.1px +23% WORSE // pooled (n=15) 75.2px -> 51.8px -31% // // The tell is that pooling made the CONSTANT model worse than it was in // either session on its own, which can only happen if pooling introduced // variance neither session had. The linear term then soaks that up, and the // leave-one-out score rewards it for absorbing an artefact of pooling // rather than for predicting anything. Per-session the parameters agree to // within .21 of a standard deviation, so there is no real drift to model. // Score a per-shot correction per session, never across pooled sessions. // // And be suspicious of the correlation itself. Measured on three separate // captures the platform-height coupling to curvature came out at -0.13, // -0.46 and -0.82; within the two halves of the third capture, -0.81 and // -0.96. That is not one effect measured three times, it is what a // correlation looks like at n = 6 to 12. Curvature is the one parameter the // anchor cannot touch mathematically -- shotA is a*W straight off the fit // and platform position never enters it -- so a coupling there has to be // either real physics or a bias in the estimator, and the time domain says // it is neither: across those same flights vx holds to 1.3% and neither vx // nor the fitted g tracks platform height (-0.11 and -0.14). The physical // curvature g/2vx^2 is constant. The wobble is sampling noise in medA. // // A slope cap was tried on the back of it -- refit y(x) using only the part // of the arc below some |dy/dx|, on the theory that the steep tail is where // the parameterisation degenerates and how much tail gets tracked depends // on how high the platform was. On the capture it was derived from it looked // excellent, spread 12.3% down to 5.1%. It does not replicate: no effect at // all on a second capture, and on a third the single fit it rests on is // catastrophic, 92% spread, because one whole-segment fit is exactly the // thing the gated median exists to avoid. Not shipped. // // What IS stable: the gated median's own spread runs 4-12% depending on the // session, against 47% before the screens went in. That is the honest state // of it. Anything smaller than that needs more than a dozen flights per // session to see, and every correction derived from a dozen has so far // failed on the next dozen. // // The ~55px of arc height at the rim is therefore the real accuracy ceiling // today, and it is per-shot noise in L and R rather than anything to do // with the anchor. Averaging across shots is what actually removes it, // which is what the commit weighting is for. // // Three explanations have been measured and none survived: // - Stale anchor. flightPlat is sampled a frame or two after release and // the platform is moving (26 of 32 releases, ~135px/s, biased upward). // But recomputing L and R against the platform at release+dt over // -200..+400ms gives no minimum -- the spread falls monotonically and // is still falling at -200ms, which is before the ball left. // - The detector picking a different row of the platform as it moves. // The detected width was 177px in 2565 of 2566 logged frames. // - The ball inheriting the platform's velocity. platV correlates worse // than platY (-0.68 vs -0.86 for L), though on an oscillating platform // the two are confounded and 5 flights cannot separate them. // // What is left is that the shot may simply not be fixed relative to the // platform -- if the character's jump reaches a height that is not purely // 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. // Back to 2.233, the value fitted from 13 tracked flights (sd 0.034, range // 2.195..2.288). v7 replaced it with 2.177, derived as g/2vx^2 on the // 960-wide design canvas, on the argument that the 2.6% gap was a // systematic tracking bias rather than noise. Measuring the offline rip of // the game settles it the other way: its own per-flight fits put curvature // at 2.2205, which sits with the original fit and not with the derivation. // Two independent measurements agreeing against one derivation means the // derivation is what is wrong. shotA: 2.233, // 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, }, cfg => { // Calibration learned before calVer 6 banked a fit from every frame of every // flight that produced a plausible-looking parabola, including flights barely // tracked at all and flights that came off the backboard. Measured over 15 // 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 !== 8) { cfg.calVer = 8; cfg.calSeeded = true; cfg.shotA = 2.233; cfg.shotL = -0.119; cfg.shotR = 0.547; } delete cfg.grav; delete cfg.launch; delete cfg.launchN; delete cfg.gravN; }); const HOOPS = { id: 'hoops', name: 'Hoops Helper', short: 'Hoops', z: 2147483645, theme: { dot: '#f87171', ac: '#dc2626' }, slot: { top: 12, left: 220, width: 228, nub: 42 }, dockOrder: 2, helper: true, overlay: true, hotkeys: { F7: 'toggle', F6: 'hide' }, keyHint: 'F7', cfg: hoops.cfg, save: hoops.save, bodyHTML: `
idle
tuning
ms
°
°
%
F7 arc on/off · F6 hide panel
`, init(ui) { const cfg = hoops.cfg, save = hoops.save; const $ = ui.$, root = ui.root, ov = ui.ov, octx = ui.octx, runBtn = ui.runBtn, dot = ui.dot, stEl = ui.stEl; function sync() { $('#span').value = cfg.span; $('#hue').value = cfg.hue; $('#huew').value = cfg.hueW; $('#smin').value = cfg.sMin; $('#hud').value = cfg.hud; $('#trail').checked = cfg.trail; $('#makes').checked = cfg.makes; $('#debug').checked = cfg.debug; $('#ghost').checked = cfg.ghost; $('#gate').checked = cfg.gate; root.querySelectorAll('.seg button').forEach(b => b.classList.toggle('sel', +b.dataset.s === cfg.scale)); dot.classList.toggle('on', cfg.on); runBtn.textContent = cfg.on ? 'Hide arc (F7)' : 'Show arc (F7)'; runBtn.className = 'btn ' + (cfg.on ? 'stop' : 'go'); ui.chrome(); if (!cfg.on) octx.clearRect(0, 0, ov.width, ov.height); } // ---------- pixel readback ---------- // The full-frame grab comes from the suite, which takes it once per frame // and hands the same buffer to every helper reading at this scale. let readErr = ''; const grab = cv => { const img = grabFrame(cv, cfg.scale); readErr = grabErr; return img; }; // The rim is a 10px-tall bar. Read back at cfg.scale (4x) it is 2.5 rows or // less — and less still if the game's backbuffer is smaller than its CSS box, // which it is. Averaging that sliver against the night sky drags its value // under rimV, so whether the hoop is seen at all comes down to how the bar // happens to land on the sampling grid: in the recording it was missed for // 39 seconds straight, then found, with no change on screen. So the rim gets // its own readback, at 1-2x over the band it can appear in, sized to stay // near the cost of one 4x full-frame grab. const rimScratch = document.createElement('canvas'); const rctx = rimScratch.getContext('2d', { willReadFrequently: true }); const BAND_T = 0.28, BAND_B = 0.99; // fraction of canvas height // Why the last rim scan came up empty, shown in the status line. Two // recordings in a row have reported NO RIM on frames where replaying this // same scan offline finds the bar every time, so the scan has to say which // stage it failed at rather than leaving it to be inferred. let rimWhy = ''; function grabBand(cv, W) { const y0 = Math.round(cv.height * BAND_T); const bh = Math.round(cv.height * (BAND_B - BAND_T)); // Pick the coarsest sampling that still puts four rows through a bar that // is ~10 CSS px thick, whatever resolution the game is rendering at. const perCss = cv.width / Math.max(1, W); const s = Math.max(1, Math.min(3, Math.floor(10 * perCss / 4))); const sw = Math.max(1, Math.round(cv.width / s)), sh = Math.max(1, Math.round(bh / s)); if (rimScratch.width !== sw || rimScratch.height !== sh) { rimScratch.width = sw; rimScratch.height = sh; } // Resizing a canvas resets its context state, so this has to be re-set every // time. Point sampling rather than interpolating: a 10px bar reduced with // smoothing on has its colour diluted by whatever sits above and below it, // and the rim only clears rimV while it stays pure. Nothing to lose here — // there is no detail below the bar's own thickness worth preserving. rctx.imageSmoothingEnabled = false; try { rctx.clearRect(0, 0, sw, sh); rctx.drawImage(cv, 0, y0, cv.width, bh, 0, 0, sw, sh); return { d: rctx.getImageData(0, 0, sw, sh).data, sw, sh, y0, rows: bh / sh, s }; } catch (e) { rimWhy = 'band read failed'; return null; } } // ---------- colour masks ---------- // rim = true selects the bright-red hoop mask instead of the ball mask function isBallPx(r, g, b, rim) { const mx = r > g ? (r > b ? r : b) : (g > b ? g : b); if (mx < (rim ? cfg.rimV : cfg.vMin) * 255) return false; const mn = r < g ? (r < b ? r : b) : (g < b ? g : b); const d = mx - mn; if (d < (rim ? cfg.rimS : cfg.sMin) * mx) return false; let h; if (mx === r) h = 60 * (((g - b) / d) % 6); else if (mx === g) h = 60 * ((b - r) / d + 2); else h = 60 * ((r - g) / d + 4); if (h < 0) h += 360; let dh = Math.abs(h - (rim ? 12 : cfg.hue)); if (dh > 180) dh = 360 - dh; return dh <= (rim ? 22 : cfg.hueW); } // ---------- "am I actually in the minigame?" ---------- // Swishy Hoops renders a full-screen dark navy night sky: measured at 92-93% // of sampled pixels, against 0.3-0.7% anywhere in the overworld. Without this // gate the overworld's orange scenery gets tracked and an arc is drawn over // normal play. Every third pixel is plenty for a 100x margin. function skyFrac(d, w, h) { let navy = 0, tot = 0; for (let y = 0; y < h; y += 3) { for (let x = 0; x < w; x += 3) { const p = (y * w + x) * 4, r = d[p], g2 = d[p + 1], b = d[p + 2]; tot++; const mx = r > g2 ? (r > b ? r : b) : (g2 > b ? g2 : b); if (mx >= 140 || mx === 0) continue; // too bright to be night sky const mn = r < g2 ? (r < b ? r : b) : (g2 < b ? g2 : b); const dd = mx - mn; if (dd < 0.35 * mx) continue; if (mx !== b) continue; // blue must dominate let hu = 60 * ((r - g2) / dd + 4); if (hu < 0) hu += 360; if (hu > 195 && hu < 255) navy++; } } return tot ? navy / tot : 0; } // ---------- connected components ---------- let mask = new Uint8Array(0), stack = new Int32Array(0); // Dead zones: the two HUD corners (score pips top-left, reward icon top-right) // and the bottom strip (the EXIT button is the same red as the rim). A plain // top band can't be used — the ball flies across the top of the screen. function blobs(d, w, h, rim) { const n = w * h; if (mask.length !== n) { mask = new Uint8Array(n); stack = new Int32Array(n); } const hudH = Math.round(h * cfg.hud / 100), hudW = Math.round(w * 0.17); const botY = Math.round(h * 0.94); for (let y = 0, i = 0; y < h; y++) { const inHud = y < hudH, dead = y >= botY; for (let x = 0; x < w; x++, i++) { if (dead || (inHud && (x < hudW || x >= w - hudW))) { mask[i] = 0; continue; } const p = i * 4; mask[i] = isBallPx(d[p], d[p + 1], d[p + 2], rim) ? 1 : 0; } } const out = []; for (let i = 0; i < n && out.length < 200; i++) { if (mask[i] !== 1) continue; let sp = 0; stack[sp++] = i; mask[i] = 2; let minx = w, maxx = 0, miny = h, maxy = 0, cnt = 0, sx = 0, sy = 0, lit = 0; while (sp) { const q = stack[--sp], qx = q % w, qy = (q / w) | 0; cnt++; sx += qx; sy += qy; const p2 = q * 4, m2 = Math.max(d[p2], d[p2 + 1], d[p2 + 2]); if (m2 >= 184) lit++; // v >= .72 if (qx < minx) minx = qx; if (qx > maxx) maxx = qx; if (qy < miny) miny = qy; if (qy > maxy) maxy = qy; if (qx > 0 && mask[q - 1] === 1) { mask[q - 1] = 2; stack[sp++] = q - 1; } if (qx < w - 1 && mask[q + 1] === 1) { mask[q + 1] = 2; stack[sp++] = q + 1; } if (qy > 0 && mask[q - w] === 1) { mask[q - w] = 2; stack[sp++] = q - w; } if (qy < h - 1 && mask[q + w] === 1) { mask[q + w] = 2; stack[sp++] = q + w; } } if (cnt < 4) continue; out.push({ x: sx / cnt, y: sy / cnt, w: maxx - minx + 1, h: maxy - miny + 1, n: cnt, lit: lit / cnt }); } return out; } // Split blobs into hoop rims and ball candidates by shape and size, both // measured as a fraction of canvas width so this survives any window size. // (The ball renders ~3.9% of width; the HUD reward icon ~1.5%; the rim ~8.6%, // while the EXIT button is only ~5.7% and must not out-vote a real rim.) function classify(ballBlobs, k, W) { const minB = W * 0.022, maxB = W * 0.09; const cands = []; for (const b of ballBlobs) { const ar = b.w / b.h, cw = b.w * k; // The wooden platform shares the ball's hue and fragments into square-ish // chunks under the permissive mask, but it is a flat v=.66 brown while the // ball and the player's shirt are lit to v=.78-.98. Without this the // "ball in your hands" locks onto the ledge you are standing on. if (b.lit < 0.3) continue; if (ar >= 0.55 && ar <= 1.8 && cw >= minB && cw <= maxB) cands.push({ x: b.x * k, y: b.y * k, w: cw, h: b.h * k, n: b.n }); } return cands; } // The rim is a long horizontal bar, but it touches the vertical backboard — // as one blob the pair is no longer flat enough to recognise. Scanning for the // single longest horizontal run of rim-coloured pixels finds the bar directly // and ignores the backboard, whose runs are only a few pixels wide. // The band already starts below the "SWISHY HOOPS" title, whose letters are // the same red and would otherwise chain into a long run. The only other // long red run is the EXIT button, which is cut out as a corner rather than // as a full-width strip: the camera sometimes parks the hoop at 90% of the // screen height, and a strip that low was swallowing it. function findRim(img, cvH, W, H) { const { d, sw, sh, y0, rows, s } = img; const kx = W / sw; // band px -> CSS px const yAt = by => (y0 + by * rows) / cvH * H; // band row -> CSS y const exitX = Math.round(sw * 0.88), exitY = (0.90 * cvH - y0) / rows; // The bar spans 9.3% of the canvas width on screen, but the longest run the // live scan managed was 5.8% — so whatever the readback is doing to it, 6% // was above what actually survives. It cannot drop much further than 5%: // the ball is a 3.9%-wide disc that passes the same colour test, and must // never out-run the rim. const minRun = W * 0.05; const y1 = Math.max(0, Math.ceil((0.30 * cvH - y0) / rows)); // hoop never sits higher let best = null, longest = 0; for (let y = y1; y < sh; y++) { const xEnd = y >= exitY ? exitX : sw; // stop short of the EXIT button let run = 0, start = 0, gap = 0; for (let x = 0; x <= xEnd; x++) { const p = (y * sw + x) * 4; const ok = x < xEnd && isBallPx(d[p], d[p + 1], d[p + 2], true); if (ok) { if (!run) start = x; run += gap + 1; gap = 0; } else if (run && gap < 2) gap++; // bridge anti-aliased gaps else { if (run * kx > longest) longest = run * kx; if (run * kx >= minRun) { const cand = { run, y, x0: start, x1: x - gap - 1 }; // Between two long runs prefer the one nearest the last known hoop // rather than the longer one: the backboard post and the rim can // trade places for the longest-run title frame by frame, and the // arc jumping between them is worse than a slightly short bar. if (!best) best = cand; else if (lastRim) { const score = c => Math.abs((c.x0 + c.x1) / 2 * kx - lastRim.x) + Math.abs(yAt(c.y) - lastRim.y); if (score(cand) < score(best)) best = cand; } else if (cand.run > best.run) best = cand; } run = 0; gap = 0; } } } if (!best) { // The longest red bar anywhere in the searched area, against what it had // to beat. "0/80" means nothing matched the colour at all; "62/80" means // the bar is being found but broken up or sampled away. rimWhy = `${Math.round(longest)}/${Math.round(minRun)}@${s || '?'}x${sw}`; return null; } return { x: (best.x0 + best.x1) / 2 * kx, y: yAt(best.y), w: best.run * kx }; } // The platform you stand on: a wide, flat, dull brown bar (hue ~31, v ~.66 — // exactly the thing that used to be mistaken for the rim). It is visible in // every single frame of every recording, which is what makes it the right // anchor: unlike the ball in your hands it can never go stale while the // character winds up and the platform slides out from under them. function findPlatform(d, w, h, k, W) { let best = null; for (let y = Math.round(h * 0.30); y < h; y++) { let run = 0, start = 0, gap = 0; for (let x = 0; x <= w; x++) { let ok = false; if (x < w) { const p = (y * w + x) * 4; const [hu, s, v] = rgbToHsv(d[p], d[p + 1], d[p + 2]); ok = hu > 18 && hu < 46 && s > 0.45 && v > 0.45 && v < 0.80; } if (ok) { if (!run) start = x; run += gap + 1; gap = 0; } else if (run && gap < 3) gap++; else { if (run && (!best || run > best.run)) best = { run, y, x0: start, x1: x - gap - 1 }; run = 0; gap = 0; } } } if (!best || best.run * k < W * 0.06) return null; return { x: (best.x0 + best.x1) / 2 * k, y: best.y * k, w: best.run * k }; } function rgbToHsv(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); const d = mx - mn; let h = 0; if (d) { if (mx === r) h = 60 * (((g - b) / d) % 6); else if (mx === g) h = 60 * ((b - r) / d + 2); else h = 60 * ((r - g) / d + 4); if (h < 0) h += 360; } return [h, mx ? d / mx : 0, mx / 255]; } // ---------- debug probe ---------- // With tuning > Debug on, the measured values behind the drawing are // published on window.__idleon.hoops, refreshed every frame. That is what // tools/replay reads back when replaying a recording, and what to look at in // the console when the overlay is wrong but the status line looks fine — the // status line rounds, and the numbers that decide everything — the rim and platform anchors — never // appear in it at all. Costs nothing while debug is off. const probe = o => { if (!cfg.debug) return; (window.__idleon = window.__idleon || {}).hoops = o; }; // ---------- 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. // The phase is estimated AS A PHASE. The first attempt recovered cos from // |sin| plus a direction-of-travel sign, which is discontinuous exactly where // the platform spends most of its visible time: on a real run it flipped sign // 34 times and jumped over 0.5 in cos 17 times, the worst going +0.946 to // -0.955 across one frame as the platform reversed. The preview leapt between // the strongest and weakest shot, which is worse than no correction. See // 57faab9. // // The period is known exactly, so nothing has to be guessed: G16[0] gains 1.3 // every 20ms and phi = 1.1*G16[0] degrees, giving 71.5 deg/s and a 5.035s // period. With w fixed, // platY(t) = y0 + A*sin(wt) + B*cos(wt) // is linear least squares in (y0, A, B) over a window of observations, and // amp = hypot(A, B) // cos(phi) = (A*cos(wt) - B*sin(wt)) / amp // falls straight out, continuous everywhere and with no sign to choose. const PLAT_W = 2 * Math.PI / 5.035; // rad/s, from the game's own clock // Release lags the input that triggers it by exactly 49 logic ticks // (measured: 49 every time, sd 0, n=12). Over that many ticks the same // oscillator advances G16[0] += 1.3 every 2 ticks, angle = G16[0]*1.1deg, // so the phase at release is 24.5*1.3*1.1 = 35.035deg ahead of the phase // read this frame. shotCurve wants cos of the ROTATED phase; see its // comment for why the rotation, not the raw phase, is what gets applied. const REL_PHASE = 35.035 * Math.PI / 180; const REL_COS = Math.cos(REL_PHASE), REL_SIN = Math.sin(REL_PHASE); let platHist = []; function platCos(H, t) { if (!plat) return null; platHist.push({ t: t / 1000, y: plat.y }); // Just over half a period. Less than that and sin and cos are too alike // across the window to be told apart, which makes A and B swap freely. while (platHist.length > 1 && t / 1000 - platHist[0].t > 3.0) platHist.shift(); const n = platHist.length; if (n < 20 || t / 1000 - platHist[0].t < 2.0) return null; // normal equations for y = c0 + c1*sin(wt) + c2*cos(wt) let Ss = 0, Sc = 0, Sss = 0, Scc = 0, Ssc = 0, Sy = 0, Sys = 0, Syc = 0; for (const q of platHist) { const sn = Math.sin(PLAT_W * q.t), cs = Math.cos(PLAT_W * q.t); Ss += sn; Sc += cs; Sss += sn * sn; Scc += cs * cs; Ssc += sn * cs; Sy += q.y; Sys += q.y * sn; Syc += q.y * cs; } const M = [[n, Ss, Sc], [Ss, Sss, Ssc], [Sc, Ssc, Scc]], V = [Sy, Sys, Syc]; for (let i = 0; i < 3; i++) { let piv = M[i][i]; if (Math.abs(piv) < 1e-9) return null; for (let k = i + 1; k < 3; k++) { const f = M[k][i] / piv; for (let j = i; j < 3; j++) M[k][j] -= f * M[i][j]; V[k] -= f * V[i]; } } if (Math.abs(M[2][2]) < 1e-9) return null; const c2 = V[2] / M[2][2]; const c1 = (V[1] - M[1][2] * c2) / M[1][1]; const c0 = (V[0] - M[0][1] * c1 - M[0][2] * c2) / M[0][0]; const amp = Math.hypot(c1, c2); // The real swing is 110 of 540 on the design canvas. An amplitude far off // that means the fit has latched onto drift or noise rather than the // oscillation, and a wrong phase is worse than no correction at all. const want = (110 / 540) * H; if (amp < want * 0.5 || amp > want * 1.8) return null; // and it has to actually describe the samples let ss = 0; for (const q of platHist) { const pred = c0 + c1 * Math.sin(PLAT_W * q.t) + c2 * Math.cos(PLAT_W * q.t); ss += (q.y - pred) * (q.y - pred); } if (Math.sqrt(ss / n) > amp * 0.25) return null; // Same fit, both quadrature components. cos is exactly what shipped before // (verified against the engine's own phase: error mean 0.0001, sd 0.0055 // over 38 shots); sin falls out of the identical c1/c2/amp with no new // fitting, and is what lets a caller rotate this phase forward to a later // tick -- see the release-phase correction in shotCurve(). const wt = PLAT_W * (t / 1000); return { cos: Math.max(-1, Math.min(1, (c1 * Math.cos(wt) - c2 * Math.sin(wt)) / amp)), sin: Math.max(-1, Math.min(1, (c1 * Math.sin(wt) + c2 * Math.cos(wt)) / amp)), }; } 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 // weight applied 30-40 times in a row that is not a gentle average — a single // shot pulls the numbers all the way onto its own fit, including the early // frames when only three or four points had been seen and the parabola was // still garbage. Hence "arc" wandering 1.71-3.01 across the recording. // One commit per shot, from the median of that shot's fits, instead. function commitCal() { const s = calSamples; calSamples = []; if (s.length < 6) return; // too few frames tracked to trust const med = key => { const v = s.map(o => o[key]).sort((a, b) => a - b); 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; cfg.shotR += (Rn - cfg.shotR) * w; cfg.calSeeded = false; save(); } // 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. // 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; // A prior attempt corrected uL/uR (the parabola's crossings) from cosPhi // measured at press time: 65 offline shots gave L vs cos at -0.0949 (r2 // 0.73) and R vs cos at +0.0253 (r2 0.23), the opposite split from what the // release-point geometry predicted. That correction is gone, not just // disabled -- it was fit against the wrong phase (press, not release; see // below) and never showed up in outcomes (swish/score rate unchanged with // it on vs off). Left removed rather than re-derived, since the slope term // below now carries the oscillator's effect. function shotCurve(px, py, dir, W, cosRel) { const A = cfg.shotA / W; const uL = cfg.shotL * W, uR = cfg.shotR * W; // The oscillator term, evaluated at RELEASE rather than at press. // // The engine sets vy = -2.9 + 0.7*cos(phi) at the instant the ball leaves // the hand, and release is exactly 49 logic ticks after the input that // triggers it (measured: 49 every time, sd 0, n=12) -- not at the phase // the player was aiming with. Over those 49 ticks the same oscillator // that drives the platform keeps advancing: G16[0] += 1.3 every 2 ticks, // angle = G16[0]*1.1 degrees, so 49 ticks is 24.5*1.3*1.1 = 35.035 // degrees of phase. cosRel is that rotation applied by the caller -- // cos(phi)*cos(35.035deg) - sin(phi)*sin(35.035deg) -- before it gets // here; this function just uses it. // // The calibrated parabola (shotA/shotL/shotR) was fit across many shots // at random phases, which averages cos(phi) to zero, so it represents the // cos=0 case and the FULL term applies here, not a difference from it. // In game units vy is px/tick and vx is 3.9 px/tick, so the added slope // in u (screen px along the flight direction) is 0.7*cos(phi_release)/3.9 // -- dimensionless, so it scales with the canvas like everything else. // // Measured against the offline rip: applying this at RELEASE phase took // arc error sd 60.0 -> 30.5px (n=19) and 70.6 -> 32.5px (n=32), a 49% and // 54% reduction. The same correction evaluated at PRESS phase on the same // shots reduced sd by only 18% and 30%. Release phase is what the engine // actually uses, and the data agrees. // // That press-vs-release gap is the load-bearing comparison, because it is // invariant to the flight time: both arms scale with it. An early pass had // the flight time wrong by 4x (it read the ball's position before launch, // which still held the PREVIOUS shot's resting place) and release still // beat press, 9% to -1%. The conclusion survived the bug that hid it. // // Necessary, not sufficient: residual sd is still ~32px against a 25px // scoring radius, and rimDy is separately biased about 25px high and is // not touched by this change. const dSlope = cosRel == null ? 0 : 0.7 * cosRel / 3.9; return { at: x => { const u = (x - px) * dir; return py + A * (u - uL) * (u - uR) + dSlope * u; }, A, uL, uR, dSlope, px, py, dir }; } let lastRim = null, rimT = 0; let frame = 0; // ---------- multi-target tracking ---------- // One track is not enough: the player's orange shirt is the same colour and // size as the ball, the two merge into a single blob while it is held, and // they split at release. Following every candidate separately lets the fast // one be recognised as the shot without the slow one dragging the track off. let tracks = []; // {pts:[{t,x,y}], x, y, vx, vy, last, n} const GATE = 260; // px a track may jump between frames // Also records average horizontal speed: the player's jump is fast but almost // purely vertical, and without that distinction a jump reads as a shot. function trackSpeed(tr) { const p = tr.pts; tr.spx = 0; if (p.length < 2) return 0; const a = p[p.length - 1]; let b = p[0]; for (let i = p.length - 2; i >= 0; i--) { b = p[i]; if (a.t - b.t >= 100) break; } const dt = (a.t - b.t) / 1000; if (dt <= 0.008) return 0; tr.spx = Math.abs(a.x - b.x) / dt; return Math.hypot(a.x - b.x, a.y - b.y) / dt; } function updateTracks(cands, t) { for (const tr of tracks) { const dt = (t - tr.last) / 1000; tr.px = tr.x + tr.vx * dt; tr.py = tr.y + tr.vy * dt; } const taken = new Set(); for (const c of cands) { let best = null, bd = Infinity; for (const tr of tracks) { if (taken.has(tr)) continue; const d = Math.hypot(c.x - tr.px, c.y - tr.py); if (d < bd) { bd = d; best = tr; } } if (best && bd < GATE) { taken.add(best); best.pts.push({ t, x: c.x, y: c.y }); if (best.pts.length > 40) best.pts.shift(); const dt = (t - best.last) / 1000; if (dt > 0.008) { best.vx = (c.x - best.x) / dt; best.vy = (c.y - best.y) / dt; } best.x = c.x; best.y = c.y; best.last = t; best.n = c.n; best.w = c.w; } else { tracks.push({ pts: [{ t, x: c.x, y: c.y }], x: c.x, y: c.y, vx: 0, vy: 0, last: t, n: c.n, w: c.w }); } } tracks = tracks.filter(tr => t - tr.last <= 350 && !tr.gone); if (tracks.length > 12) tracks = tracks.slice(-12); } // ---------- path fitting ---------- // The tracked ball is fitted as a parabola in x-y directly. Fitting against // time needs gravity AND a release instant, and both were shaky: the release // instant depends on detection latency, which varies between recordings and // threw the predicted landing out by ~100px. A curve through the points has // neither problem, and the curve is what gets drawn anyway. function fitXY(pts) { const n = pts.length; if (n < 6) return null; let xs = pts.map(p => p.x); if (Math.max(...xs) - Math.min(...xs) < 40) return null; // needs x spread let sx = 0, sx2 = 0, sx3 = 0, sx4 = 0, sy = 0, sxy = 0, sx2y = 0; for (const p of pts) { const x = p.x, y = p.y, x2 = x * x; sx += x; sx2 += x2; sx3 += x2 * x; sx4 += x2 * x2; sy += y; sxy += x * y; sx2y += x2 * y; } const sol = solve3([[sx4, sx3, sx2], [sx3, sx2, sx], [sx2, sx, n]], [sx2y, sxy, sy]); if (!sol || !(sol[0] > 0)) return null; // must curve downward return { a: sol[0], b: sol[1], c: sol[2] }; } // ---------- drawing ---------- // Walk the path in screen x and stroke it. Taking the curve as a function of // x (rather than stepping through time) means the same routine draws both the // fitted flight and the platform-anchored preview. // The red bar that gets detected is the whole rim assembly, which runs on // past the net into the backboard post — it is wider than the hole and its // centre sits ~5% of its width right of it. Scoring off the raw bar at // +/-0.55 called anything within 68px a swish, which is wider than the hoop. const HOLE_OFF = -0.05, HOLE_HALF = 0.30; // still used by drawRim's debug window, below // The game does NOT score by testing whether the arc's height crosses the // rim bar's y inside a horizontal window (the HOLE_OFF/HOLE_HALF test this // replaces). It scores when the ball's CENTRE comes within 25 game-px of a // fixed scoring point offset from the hoop sprite's top-left corner, seen in // the game's own code as (p95+39, p96+113). The helper's visually-detected // rim sits measurably above that point, not on it. Measured across 82 shots // in two independent sessions, which agree with each other and were // therefore pooled: // rim.y - scorePt.y: session A -24.23px sd 2.84, session B -23.63px sd 2.98 // pooled: true scoring point = detected rim + (-4.4, +23.9) canvas px // Expressed below as fractions of canvas size, never absolute px, because // the game's physics -- and this helper's own calibration -- scales with // the viewport: // true scoring point = detected rim + (-0.00457*W, +0.04431*H) // scoring radius = 0.04630*H (25 game-px on a 540-tall design canvas) // // The old crossing-through-rim.y test was wrong on both axes -- wrong // target (rim.y instead of the scoring point ~24px below it) and wrong // criterion (a line crossing instead of the game's own circle test) -- and // it systematically UNDER-called makes: 18 called vs 27 actual across 43 // shots, 56% agreement. The same 25px-radius circle test applied to the // game's own ball trace (not this helper's prediction) agreed with the real // outcome 40/43 -- that is the standard this replacement is judged against. const SCORE_DX = -0.00457; // * W: detected rim.x -> scoring point x const SCORE_DY = 0.04431; // * H: detected rim.y -> scoring point y const SCORE_R = 0.04630; // * H: scoring radius (25px on a 540-tall canvas) // DEAD END, measured, do not retry without new evidence: widening SCORE_R to // catch bank-ins. Most makes here are banked (20 of 26 in one session), and a // ballistic arc cannot predict a ball that rattles in, so a wider radius // looks like the obvious fix. It is not, because the predicted distance // carries almost no information about the outcome in the first place. // // Measured over 43 shots, ghostMinDist against whether the shot actually // scored: // mean ghostMinDist | made 42.4px // mean ghostMinDist | miss 43.2px // AUC 0.474 (0.5 is no information at all; 1.0 is perfect) // Fitting the best threshold on half the shots and scoring it on the other // half gave 29% and 55%, straddling the 51% always-say-miss baseline. There // is no threshold to find. // // The same circle test fed the game's OWN ball trace instead of this // prediction scores AUC 1.000, so the criterion and the geometry are right -- // the arc simply is not accurate enough at the rim to discriminate. Residual // arc error is sd 29-49px depending on session, against a 25px radius. // Fix the arc, or the rim detection behind it, before touching this radius. // // Note the session dependence, which is the live lead: on identical code, // arc error sd ran 28.7, 35.3 and 49.3 across three sessions, and rim // detection noise (rimDy sd) ran 2.84, 2.98 and 6.95, degrading as the score // climbed and the hoop moved. Score any change to this per session; overall // ghostMade agreement swung 62% -> 44% between two sessions of the SAME // build, so cross-session comparisons of it are not meaningful at n = 43. // Closest point on segment P0->P1 to target T, clamped so it cannot fall // outside the segment (standard clamped projection). Used instead of // point-to-point distance because the arc below is only sampled every // `step` px: checking sampled points alone biases the minimum distance // HIGH between samples and would re-introduce the under-calling above. function closestOnSeg(p0, p1, t) { const dx = p1.x - p0.x, dy = p1.y - p0.y; const len2 = dx * dx + dy * dy; let u = len2 > 0 ? ((t.x - p0.x) * dx + (t.y - p0.y) * dy) / len2 : 0; u = Math.max(0, Math.min(1, u)); const x = p0.x + u * dx, y = p0.y + u * dy; return { x, y, d: Math.hypot(t.x - x, t.y - y) }; } // Set by drawCurve() each call, for the caller to publish on the probe -- // drawCurve itself still returns only the made/missed boolean, matching // every existing call site. let lastMinDist = null, lastScorePt = null; function drawCurve(yAt, xStart, dir, W, H, style) { const pts = []; const rim = lastRim; const scorePt = rim ? { x: rim.x + SCORE_DX * W, y: rim.y + SCORE_DY * H } : null; const radius = SCORE_R * H; let minDist = Infinity, closest = null; const step = Math.max(3, W / 240) * dir; for (let x = xStart, i = 0; i < 900; i++, x += step) { const y = yAt(x); const p = { x, y }; const prev = pts[pts.length - 1]; if (scorePt && prev) { const c = closestOnSeg(prev, p, scorePt); if (c.d < minDist) { minDist = c.d; closest = c; } } pts.push(p); if (y > H + 80 || x < -80 || x > W + 80) break; } lastMinDist = scorePt ? +minDist.toFixed(1) : null; lastScorePt = scorePt ? { x: +scorePt.x.toFixed(1), y: +scorePt.y.toFixed(1) } : null; if (pts.length < 2) return false; const made = !!scorePt && minDist < radius; const green = cfg.makes && made; octx.save(); octx.shadowColor = 'rgba(0,0,0,.7)'; octx.shadowBlur = 3; octx.setLineDash(style === 'ghost' ? [3, 6] : [6, 7]); octx.lineWidth = style === 'ghost' ? 2 : 2.6; octx.strokeStyle = green ? '#4ade80' : (style === 'ghost' ? '#ff7a70' : '#ff3b30'); octx.globalAlpha = style === 'ghost' ? 0.85 : 1; octx.beginPath(); octx.moveTo(pts[0].x, pts[0].y); for (const p of pts) octx.lineTo(p.x, p.y); octx.stroke(); octx.setLineDash([]); const e = pts[pts.length - 1]; octx.beginPath(); octx.arc(e.x, e.y, 4, 0, Math.PI * 2); octx.stroke(); if (made) { octx.lineWidth = 2.5; octx.beginPath(); octx.arc(closest.x, closest.y, 7, 0, Math.PI * 2); octx.stroke(); } octx.restore(); return made; } // Draws the detected bar faintly and the window that actually counts as a // make solidly, so a "not lined up" verdict can be checked against the hoop. function drawRim(r) { const holeX = r.x + r.w * HOLE_OFF, half = r.w * HOLE_HALF; octx.save(); octx.strokeStyle = 'rgba(96,165,250,.4)'; octx.lineWidth = 1; octx.setLineDash([3, 4]); octx.beginPath(); octx.moveTo(r.x - r.w * 0.5, r.y); octx.lineTo(r.x + r.w * 0.5, r.y); octx.stroke(); octx.setLineDash([]); octx.strokeStyle = '#60a5fa'; octx.lineWidth = 2; octx.beginPath(); octx.moveTo(holeX - half, r.y); octx.lineTo(holeX + half, r.y); octx.moveTo(holeX - half, r.y - 5); octx.lineTo(holeX - half, r.y + 5); octx.moveTo(holeX + half, r.y - 5); octx.lineTo(holeX + half, r.y + 5); octx.stroke(); octx.restore(); } // ---------- main loop ---------- function loop() { frame++; if (!cfg.on) return; const cv = gameCanvas(); if (!cv) { if (frame % 30 === 0) stEl.textContent = 'no game canvas found'; probe({ frame, idle: 'no game canvas' }); return; } const rect = cv.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const W = rect.width, H = rect.height; if (ov.width !== Math.round(W * dpr) || ov.height !== Math.round(H * dpr)) { ov.width = Math.round(W * dpr); ov.height = Math.round(H * dpr); ov.style.width = W + 'px'; ov.style.height = H + 'px'; } ov.style.left = rect.left + 'px'; ov.style.top = rect.top + 'px'; octx.setTransform(dpr, 0, 0, dpr, 0, 0); octx.clearRect(0, 0, W, H); const img = grab(cv); if (!img) { stEl.textContent = readErr; probe({ frame, idle: readErr }); return; } const k = W / img.sw; // downscaled px -> CSS px // Only draw inside Swishy Hoops — otherwise the overworld's orange scenery // gets tracked and an arc appears over normal play. if (cfg.gate && skyFrac(img.d, img.sw, img.sh) < 0.55) { tracks = []; lastRim = null; plat = null; flightPlat = null; holdT = -1e9; calSamples = []; if (frame % 15 === 0) stEl.textContent = 'idle\nnot in Swishy Hoops'; probe({ frame, idle: 'gated out: sky < 55%' }); return; } const ballBlobs = blobs(img.d, img.sw, img.sh, false); const t = performance.now(); const cands = classify(ballBlobs, k, W); // The hoop only drifts as the camera pans, so its own (finer, pricier) // readback runs at a third of the frame rate once it has been found. const rim = (!lastRim || frame % 3 === 0) ? (() => { const band = grabBand(cv, W); const r = band ? findRim(band, cv.height, W, H) : null; if (r) return r; const bandWhy = rimWhy; // Fall back to the coarse grab that has already been read for the blobs. // The same scan, just sampled the way v1.0 sampled it — so whatever goes // wrong with the fine band read, this cannot end up seeing less than the // build before it did. const flat = findRim({ d: img.d, sw: img.sw, sh: img.sh, y0: 0, rows: cv.height / img.sh, s: cfg.scale }, cv.height, W, H); // Report both stages. Letting the fallback overwrite the band's reason // hid which of the two was actually failing for a whole round of testing. if (!flat) rimWhy = `band ${bandWhy} / flat ${rimWhy}`; return flat; })() : null; 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 ph = platCos(H, t); const cosPhi = ph ? ph.cos : null; // Rotate the phase read THIS frame forward to where it will be at // release, 49 ticks (35.035deg) later -- cos(phi+d) = cos(phi)cos(d) - // sin(phi)sin(d). This is what shotCurve's oscillator term needs; see // its comment for the rest of the derivation. const cosRel = ph ? ph.cos * REL_COS - ph.sin * REL_SIN : null; if (cfg.debug) { octx.lineWidth = 1; octx.strokeStyle = 'rgba(140,140,140,.45)'; for (const b of ballBlobs) octx.strokeRect(b.x * k - b.w * k / 2, b.y * k - b.h * k / 2, b.w * k, b.h * k); octx.strokeStyle = 'rgba(255,60,60,.9)'; for (const c of cands) octx.strokeRect(c.x - c.w / 2, c.y - c.h / 2, c.w, c.h); } if (rim) { lastRim = rim; rimT = t; } else if (t - rimT > 4000) lastRim = null; if (lastRim) drawRim(lastRim); // ---- track every candidate, then decide which one is the shot ---- updateTracks(cands, t); let fly = null; // A ball resting on the platform means you are holding the next shot. This // is tracked separately from the flight, because after a miss the game hands // you a new ball while the previous one is still falling off-screen. let holding = null; for (const tr of tracks) { const sp = trackSpeed(tr); tr.sp = sp; if (t - tr.last > 120) continue; // stale: ball already gone // Once a ball has left the play area the shot is over — keeping it would // leave the old arc on screen while you line the next one up. if (tr.y > H * 0.94 || tr.x > W * 0.97 || tr.x < W * 0.02) { tr.gone = true; continue; } if (tr.gone) continue; if (sp > H * 0.35 && tr.spx > W * 0.08 && tr.pts.length >= 3) { if (!fly || sp > fly.sp) fly = tr; } // Judged on HORIZONTAL speed only: a held ball rides the platform up and // down, so its total speed regularly exceeds any "stationary" threshold // and the preview blinked out every time the platform picked up pace. else if (plat && tr.spx < W * 0.06 && Math.abs(tr.x - plat.x) < W * 0.08 && tr.y < plat.y && tr.y > plat.y - H * 0.30) { if (!holding || tr.n > holding.n) holding = tr; } } const flying = !!fly; // Brief detection dropouts shouldn't flicker the preview off. if (holding) holdT = t; const ready = holding || (t - holdT < 300); if (fly && !fly.flew) { // First frame this track counts as a shot. Its history still holds the // stationary held phase and the wind-up, which are not projectile motion // and would flatten both the curvature and the velocity fit. fly.flew = true; fly.pts = fly.pts.slice(-3); // 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(); flightCos = null; } // ---- live arc for a ball in the air ---- let made = null; if (fly) { const f = fitXY(fly.pts); if (f) { if (cfg.trail) { octx.fillStyle = 'rgba(255,59,48,.55)'; for (const p of fly.pts) { octx.beginPath(); octx.arc(p.x, p.y, 2, 0, Math.PI * 2); octx.fill(); } } const p0 = fly.pts[0], pN = fly.pts[fly.pts.length - 1]; const dir = Math.sign(pN.x - p0.x) || 1; made = drawCurve(x => f.a * x * x + f.b * x + f.c, fly.x, dir, W, H, 'live'); // Has this shot come off the backboard or the far lip of the rim? Both // are ordinary ways to score, and both send the ball back over x it has // already crossed. A projectile's x is monotonic, so any retreat from // the furthest point reached is a bounce and nothing else. The margin // is a whole 1% of the width because the tracker's own x jitter measured // 3-7px rms, and one noisy frame must not read as a bounce. if (!fly.dir0 && Math.abs(pN.x - p0.x) > 4) fly.dir0 = Math.sign(pN.x - p0.x); if (fly.dir0) { const reach = pN.x * fly.dir0; if (fly.reach === undefined || reach > fly.reach) fly.reach = reach; else if (fly.reach - reach > W * 0.01) fly.bounced = true; } // Learn the shot in platform-relative terms: curvature, plus where the // path crosses platform height going up and coming down. Those three // are the same for every shot regardless of when tracking began. if (flightPlat) { const A = f.a, py = flightPlat.y; const disc = f.b * f.b - 4 * A * (f.c - py); if (disc > 0) { const r1 = (-f.b - Math.sqrt(disc)) / (2 * A), r2 = (-f.b + Math.sqrt(disc)) / (2 * A); const uL = (Math.min(r1, r2) - flightPlat.x) * dir; const uR = (Math.max(r1, r2) - flightPlat.x) * dir; const An = A * W, Ln = uL / W, Rn = uR / W; const xs = fly.pts.map(q => q.x); const span = Math.max(...xs) - Math.min(...xs); // Two screens on top of the plausibility window, both measured off // 15 live flights read out of the running game over the DevTools // protocol. The window alone is not enough: it asks whether the // fitted parabola looks sane, never whether the points under it // were a single projectile, and the worst offenders sail through. // // SPAN. fitXY needs only 40px of x to return a curve, and it will, // but curvature error goes as 1/spread^2, so a fit over a short arc // is a guess wearing a number. The three wildest calibrations in // the sample -- A of 2.941, 1.865, 2.716 against a true 2.23 -- came // from the three shortest tracks, spans of 200, 143 and 140px. 40px // stays as fitXY's floor because the live arc should still draw // early in a flight; it is only LEARNING that waits for real spread. // // BOUNCE. y is fitted as a function of x, so a ball returning over // its own x is not a hard fit, it is an impossible one -- two y for // one x. That is where A reached 55. // // Sweeping the span gate over those flights (commits kept, spread // of the committed A): // // none (shipped) 15 commits 47.4% 1.865-2.941 // 0.10 W 11 commits 19.3% // 0.15 W 10 commits 8.2% + bounce cut 4.3% // 0.20 W 10 commits 7.7% + bounce cut 4.2% // 0.25 W 10 commits 7.2% + bounce cut 4.0% // 0.30 W 10 commits 5.1% // 0.35 W 7 commits 2.0% // 0.40 W 2 commits 0.9% // // Commits hold flat from .15 to .30 and fall off a cliff above it, // so .20 sits in the middle of the plateau rather than on an edge: // tightening or loosening it by a quarter changes nothing much. // Together they take the spread from 47.4% to 4.2% for two commits // out of ten -- and a commit is cheap, the calibration averages. if (!fly.bounced && span >= W * 0.20 && An > 1.5 && An < 3.2 && Rn > 0.40 && Rn < 0.75 && Ln > -0.45 && Ln < 0.05) calSamples.push({ A: An, L: Ln, R: Rn }); } } } } // ---- shot preview, anchored to the platform ---- let ghostMade = null, ghostRimY = null, ghostMinDist = null, scorePt = null; // Drawn whenever a ball is in your hands — NOT gated on "no shot in flight". // After a miss both are true at once, and suppressing the preview then is // 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; // RE-ENABLED, on the release-phase slope in shotCurve() rather than the // uL/uR correction this comment used to explain away. That one was // fit and applied at PRESS phase, which is not the phase the engine // actually launches on -- release is 49 ticks (35.035deg) later, see // REL_PHASE above and the derivation in shotCurve(). Applying the // correction at the right phase is what changed the outcome: // // arc error sd, n=19: 60.0px -> 30.5px at release phase // (only 18% of that gain if applied at press) // arc error sd, n=32: 70.6px -> 32.5px at release phase // (only 30% of that gain if applied at press) // // Necessary, not sufficient: residual sd is still ~32px against a 25px // scoring radius (the old 54.7px mean / 117.5px worst-case baseline // this comment used to cite), and rimDy is separately biased about // 25px high and is not touched by this change. const curve = shotCurve(plat.x, plat.y, dir, W, cosRel); // 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. ghostMade = drawCurve(curve.at, plat.x, dir, W, H, 'ghost'); // Where the predicted arc crosses the rim's x. This is kept as a // secondary diagnostic (against the ball's true height there it gives a // signed error with a direction and a size); it is no longer what // decides a make -- see SCORE_DX/DY/R and ghostMinDist below for that. if (lastRim) ghostRimY = +curve.at(lastRim.x).toFixed(1); // The actual decision variable now: closest approach of the predicted // arc to the game's own scoring point, set by drawCurve() above. A make // is ghostMinDist < SCORE_R*H -- publishing the distance itself (rather // than just the boolean) is what lets that threshold be checked // externally against real outcomes. ghostMinDist = lastMinDist; scorePt = lastScorePt; octx.save(); const topY = curve.at(plat.x); octx.strokeStyle = 'rgba(255,122,112,.35)'; // tie the arc to the platform octx.setLineDash([2, 4]); octx.lineWidth = 1; octx.beginPath(); octx.moveTo(plat.x, plat.y); octx.lineTo(plat.x, topY); octx.stroke(); octx.setLineDash([]); octx.strokeStyle = 'rgba(255,122,112,.6)'; octx.lineWidth = 2; octx.beginPath(); octx.moveTo(plat.x - 10, plat.y); octx.lineTo(plat.x + 10, plat.y); octx.stroke(); octx.restore(); } if (frame % 8 === 0) { const cal = `range ${(cfg.shotR * 100).toFixed(0)}% · arc ${cfg.shotA.toFixed(2)}` + (cfg.calSeeded ? ' (default)' : ''); // Both lines get reported when both are on screen. Holding the next ball // while a shot is still falling is the normal state after a miss, and // "ready" used to win outright — so the panel would read "not lined up" // about the preview while a green live arc dropped through the hoop right // next to it, or claim SWISH off the preview while the shot in the air was // visibly missing. Each label now says which line it is talking about. const parts = []; if (flying) parts.push(made === null ? 'shot tracking' : (made ? 'shot SWISH' : 'shot misses')); if (ready) parts.push(ghostMade ? 'aim SWISH' : 'aim off'); if (!parts.length) parts.push(`no ball (${cands.length} blobs)`); const what = parts.join(' · '); // Rim and platform state are always shown: without them a missing or // wrong arc gives no clue which half of the picture failed. const rimSt = lastRim ? 'rim' : `NO RIM ${rimWhy}`; stEl.textContent = `${cal}\n${what} · ${rimSt} · ${plat ? 'platform' : 'NO PLATFORM'}`; } probe({ frame, plat, rim: lastRim, rimWhy, blobs: cands.length, tracks: tracks.length, flying, made, ready, ghostMade, ghostRimY, ghostMinDist, scorePt, 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), // cosPhi rotated forward to the release phase, 35.035deg later -- the // value actually fed to shotCurve() for the ghost preview cosRel: cosRel == null ? null : +cosRel.toFixed(3), platY: plat ? +plat.y.toFixed(1) : null, fit: lastFit }); } // ---------- wiring ---------- // A button that has been clicked keeps keyboard focus, and the minigame is // played with the keyboard — so a Space or Enter aimed at the game re-fires // whichever control was touched last. Nothing has been observed going wrong // this way; it is guarded because "Reset calibration" is one stray keypress // from throwing away a session's worth of learning, silently. const tap = (el, fn) => el.addEventListener('click', e => { if (!e.detail) return; // detail 0 => Space/Enter, not a click el.blur(); fn(e); }); const toggle = () => { cfg.on = !cfg.on; if (!cfg.on) tracks = []; save(); sync(); }; tap(runBtn, toggle); tap($('#cal'), () => { cfg.shotA = 2.233; cfg.shotL = -0.119; cfg.shotR = 0.547; cfg.calSeeded = true; calSamples = []; save(); }); $('#span').onchange = e => { cfg.span = Math.max(200, +e.target.value); save(); }; $('#hue').onchange = e => { cfg.hue = (+e.target.value % 360 + 360) % 360; save(); }; $('#huew').onchange = e => { cfg.hueW = Math.min(90, Math.max(1, +e.target.value)); save(); }; $('#smin').onchange = e => { cfg.sMin = Math.min(1, Math.max(0, +e.target.value)); save(); }; $('#hud').onchange = e => { cfg.hud = Math.min(40, Math.max(0, +e.target.value)); save(); }; $('#ghost').onchange = e => { cfg.ghost = e.target.checked; save(); }; $('#trail').onchange = e => { cfg.trail = e.target.checked; save(); }; $('#makes').onchange = e => { cfg.makes = e.target.checked; save(); }; $('#debug').onchange = e => { cfg.debug = e.target.checked; save(); }; $('#gate').onchange = e => { cfg.gate = e.target.checked; save(); }; root.querySelectorAll('.seg button').forEach(b => tap(b, () => { cfg.scale = +b.dataset.s; tracks = []; save(); sync(); })); // For the suite's auto-open: the platform is found every frame the court is up. // Reusing the loop's own state rather than testing the screen again -- // a second detector here would be one more thing to drift. return { loop, sync, toggle, active: () => plat != null }; } }; // ===================================================================== // Helper — Fishing // Landing prediction for a cast, plus fish and hazard markers. // ===================================================================== const fishing = store('fish_cfg', { on: true, scale: 4, marks: true, // ring the fish and the hazards aim: true, // live landing marker while the power bar charges arc: true, // dotted arc for a bobber already in the air ruler: true, // numbered 0-8 graduations on the gauge and lane debug: false, // landing = aim2*p^2 + aim1*p + aim0, p the gauge fill, result a fraction // along the lane. // // v6: the mapping is a CURVE, not a line. Every version up to v5 fitted a // straight line, and a line through this data has residuals that are // positive at both ends and negative in the middle — the signature of // fitting a curve with a ruler. It went unnoticed because the recording it // was measured on only ever used 0.23-0.68 of the gauge, where a line is a // fine approximation. A second recording covering 0.09-1.00 showed the // ends pulling away: the v5 line under-predicted every long cast by 5-8% // of the lane, all in the same direction. That is the "I have to release // before the mark to hit anything far out" complaint, exactly. // // 19 casts across two fishing spots, powers 0.09 to 1.00, each pairing the // locked gauge fill with where the bobber came to rest: // // line mean 2.2% of the lane, worst 3.9%, residuals still curved // parabola mean 1.1% of the lane, worst 2.2%, no pattern left // // Both spots fall on the SAME curve, so this is the game's law and not a // per-spot quirk — which also means the seed is worth trusting before any // self-calibration has happened. calVer: 6, // bump to discard samples gathered under an older gauge aim2: 0.3095, aim1: 0.5631, aim0: 0.0420, samples: [], // [powerFraction, landingFraction] pairs, newest last }, cfg => { // Samples are (power, landing) pairs and would survive a change of model — // but not a change of what "power" meant. Everything learned before v6 was // paired with a gauge reading that could collapse. Everything learned before // v7 was paired with a gauge read through the 4x downscale, where one row of // the ~21-row gauge was ~5% of it and the reading could not resolve the // game's own step at all — so those pairs carry the readback error in the // power axis, and refitting on them fits the error. They go too. if (cfg.calVer !== 7) { cfg.calVer = 7; cfg.samples = []; cfg.aim2 = 0.3095; cfg.aim1 = 0.5631; cfg.aim0 = 0.0420; delete cfg.aimA; delete cfg.aimB; } // A zero curvature can only have come from the straight-line fallback that // refitAim used to drop to below eight samples — the seed has never been a // line under calVer 6. Put the seed curve back; the samples themselves are // still good, and the first cast landed from here refits them properly. if (!cfg.aim2) { cfg.aim2 = 0.3095; cfg.aim1 = 0.5631; cfg.aim0 = 0.0420; } }); const FISHING = { id: 'fishing', name: 'Fishing Helper', short: 'Fishing', z: 2147483644, theme: { dot: '#38bdf8', ac: '#0284c7' }, slot: { top: 12, left: 460, width: 214, nub: 60 }, dockOrder: 3, helper: true, overlay: true, hotkeys: { F4: 'toggle', F3: 'hide' }, keyHint: 'F4', cfg: fishing.cfg, save: fishing.save, bodyHTML: `
idle
tuning
F4 on/off · F3 hide panel
`, init(ui) { const cfg = fishing.cfg, save = fishing.save, saveSoon = fishing.saveSoon; const $ = ui.$, root = ui.root, ov = ui.ov, octx = ui.octx, runBtn = ui.runBtn, dot = ui.dot, stEl = ui.stEl; function sync() { $('#aim').checked = cfg.aim; $('#marks').checked = cfg.marks; $('#arcx').checked = cfg.arc; $('#ruler').checked = cfg.ruler; $('#debug').checked = cfg.debug; dot.classList.toggle('on', cfg.on); runBtn.textContent = cfg.on ? 'Hide helper (F4)' : 'Show helper (F4)'; runBtn.className = 'btn ' + (cfg.on ? 'stop' : 'go'); ui.chrome(); if (!cfg.on) octx.clearRect(0, 0, ov.width, ov.height); } // ---------- pixel readback ---------- // The full frame comes from the suite's shared grab, taken once per frame // whatever else is running. let readErr = ''; const grab = cv => { const img = grabFrame(cv, cfg.scale); readErr = grabErr; return img; }; // The whole frame is read downscaled (cheap, enough to find the lane), but the // sprites sitting on the lane are small and spiky — at 4x the urchin breaks // into fragments too small to trust. The lane is only a thin strip, so it is // re-read at native resolution, which costs about as much as the whole // downscaled frame and makes the sprites solid. const strip = document.createElement('canvas'); const stctx = strip.getContext('2d', { willReadFrequently: true }); function grabStrip(cv, laneYcss, Hcss) { const half = Math.max(12, Math.round(cv.height * 0.045)); const cy = Math.round(laneYcss / Hcss * cv.height); const sy = Math.max(0, cy - half); const hh = Math.min(cv.height - sy, half * 2); if (hh < 4) return null; if (strip.width !== cv.width || strip.height !== hh) { strip.width = cv.width; strip.height = hh; } try { stctx.clearRect(0, 0, cv.width, hh); stctx.drawImage(cv, 0, sy, cv.width, hh, 0, 0, cv.width, hh); return { d: stctx.getImageData(0, 0, cv.width, hh).data, w: cv.width, h: hh, sy, cvH: cv.height }; } catch (e) { return null; } } // The gauge is read at NATIVE resolution, in its own narrow grab. Everything // else works off the 4x-downscaled frame, which is fine for finding a lane // 20% of the screen wide and hopeless for a gauge ~64px tall: see the power // meter section for what that cost. The band is ~10% of the width by 30% of // the height, so this reads about a fortieth of the frame — cheaper than the // downscaled grab it corrects, and it only runs once a lane has been found. const gauge = document.createElement('canvas'); const gctx = gauge.getContext('2d', { willReadFrequently: true }); function grabGauge(cv, lane, sw, sh) { // lane is in downscaled coordinates; nx/ny carry back to native ones. const nx = cv.width / sw, ny = cv.height / sh; const sx = Math.max(0, Math.floor((lane.x0 - sw * 0.10) * nx)); const ex = Math.min(cv.width, Math.ceil((lane.x0 - sw * 0.005) * nx)); const sy = Math.max(0, Math.floor((lane.y - sh * 0.22) * ny)); const ey = Math.min(cv.height, Math.ceil((lane.y + sh * 0.08) * ny)); const w = ex - sx, h = ey - sy; if (w < 4 || h < 16) return null; if (gauge.width !== w || gauge.height !== h) { gauge.width = w; gauge.height = h; } try { gctx.clearRect(0, 0, w, h); gctx.drawImage(cv, sx, sy, w, h, 0, 0, w, h); return { d: gctx.getImageData(0, 0, w, h).data, w, h, sx, sy, nx, ny, cvW: cv.width, cvH: cv.height }; } catch (e) { return null; } } // ---------- colour helpers (measured off the real sprites) ---------- function hsvAt(d, i) { const p = i * 4, r = d[p], g = d[p + 1], b = d[p + 2]; const mx = r > g ? (r > b ? r : b) : (g > b ? g : b); const mn = r < g ? (r < b ? r : b) : (g < b ? g : b); const dd = mx - mn; let h = 0; if (dd) { if (mx === r) h = 60 * (((g - b) / dd) % 6); else if (mx === g) h = 60 * ((b - r) / dd + 2); else h = 60 * ((r - g) / dd + 4); if (h < 0) h += 360; } return [h, mx ? dd / mx : 0, mx / 255]; } const inH = (h, lo, hi) => lo < hi ? (h >= lo && h <= hi) : (h >= lo || h <= hi); const isLane = (h, s, v) => h > 193 && h < 216 && s > 0.35 && v > 0.48 && v < 0.76; const isBobber = (h, s, v) => inH(h, 345, 15) && s > 0.55 && v > 0.55; const isFish = (h, s, v) => h > 118 && h < 175 && s > 0.22 && v > 0.55; // Hazard hue and brightness vary by fishing spot: the first one measured sat // at hue 320-340 / v.46-.54, another at hue ~350 / v up to .85. Too narrow a // window and the AVOID rings simply never appear. // // The window also has to WRAP, and it never did. Written as 315..360 it // stops dead at the top of the circle, and hsvAt returns hue 0 — not 360 — // for pure red. A colour census of the Blunder Hills lane read off the live // canvas found the urchin's two strongest tones sitting at exactly hue 0: // rgb(240,66,66) s.73 v.94, 96px of outline, and rgb(125,93,93) s.26 v.49, // 96px of body. Both were outside 315..360, so not one of the sprite's 456 // pixels could ever match and the AVOID ring never appeared at that spot at // all. inH wraps the same way isBobber's 345..15 does; +5 is enough to cover // hue 0 without letting the window drift towards the sand's orange. const isHazard = (h, s, v) => inH(h, 315, 5) && s > 0.28 && v > 0.3; // Higher-tier catches unlocked by landing streaks (eel +2, squid +3, whale +5), // measured off the game's legend sprites. The squid's purple sits at 255-315, // just clear of the hazard window, which starts at 315. Only the whale's // dark-blue body is matched — its pale belly is the same desaturated blue as // the sky behind the lane, and its mid-blues would vanish into the lane. // The lane's own shadow edge reaches hue ~225 at s .6-.7, which a window // starting at 216 rang as a whale; the whale body is hue ~230 at s ~.4, so // both the hue floor and a saturation ceiling keep the shadow out. // // The squid's saturation floor came off that legend icon and was far too high // for the sprite swimming in the lane. Read off the live canvas, the sprite is // three flat tones and only ONE of them cleared s > .22: // // rgb(237,204,240) h295 s.150 v.94 138px body — the bulk of it // rgb(147,89,161) h288 s.447 v.63 63px mid shading // rgb(49,24,60) h282 s.600 v.24 77px outline (below the v floor) // // Keeping only the 63 shading pixels left them scattered as single dots // through the body they outline, so nothing connected: no component reached // even a 21px floor, let alone the 63px the count screen wanted, and the // squid was never ringed once. At s > .12 the sprite comes back as a single // 173px, 22x24 blob. The floor is set .03 below the body's exact .15 rather // than snug against it because that .15 is a palette entry, and a sprite // drawn at another scale blends its edges. A whole-canvas sweep at s > .10 // turned up no new component anywhere, so there is room below to spend. const isEel = (h, s, v) => h > 30 && h < 55 && s > 0.35 && v > 0.55; const isSquid = (h, s, v) => h > 255 && h <= 315 && s > 0.12 && v > 0.35; const isWhale = (h, s, v) => h > 228 && h < 258 && s > 0.22 && s < 0.6 && v > 0.3; // How close the bobber has to land, per species, as a fraction of the lane. // The game's catch test is // |fishX - bobberX| < 6 + SIZE[type] // with SIZE = [6,6,9,10,12,13,17,17] in lane units and the 6 being the // bobber's own half-width. Points identify the type: 1pt is type 2, 2pt is // type 3, 3pt is type 4 and 5pt is type 6, so the tolerances come out at // 15, 16, 18 and 23 lane units. The pufferfish is type 5, size 13, so 19. // // The lane is about 299.5 of those units across, and two independent routes // agree on it: inverting the measured aim curve puts the lane ends at game x // 11 and 311, and the game seeds fish between 40 and 295 with the bobber // landing between 24 and 285 — all inside that span. Dividing by it turns a // tolerance into a fraction of whatever the lane measures on screen, so this // survives any window size, which raw pixels would not. const LANE_UNITS = 299.5; const tol = u => u / LANE_UNITS; const SPECIES = [ { name: 'FISH', pts: 1, color: '#4ade80', test: isFish, catchN: tol(15) }, { name: 'EEL', pts: 2, color: '#facc15', test: isEel, catchN: tol(16) }, { name: 'SQUID', pts: 3, color: '#e879f9', test: isSquid, catchN: tol(18) }, { name: 'WHALE', pts: 5, color: '#60a5fa', test: isWhale, catchN: tol(23) }, ]; const HAZARD_N = tol(19); // pufferfish, type 5, size 13 // ---------- the lane ---------- // The fishing lane is a long flat blue bar. Its longest horizontal run is both // the geometry everything else is measured against and the "is this minigame // even open?" test — nothing in the overworld produces a run this long in this // narrow colour band. function findLane(d, w, h) { // Fish, hazards and the bobber sit ON the lane and break the colour run into // fragments; taking the longest fragment made the measured ends swing by // ~50px as things slid along. Bridging generously spans an obstacle, since // nothing else nearby shares this colour. const maxGap = Math.max(2, Math.round(w * 0.06)); const edge = Math.max(2, Math.round(w * 0.015)); let best = null; for (let y = Math.round(h * 0.25); y < Math.round(h * 0.70); y++) { let run = 0, start = 0, gap = 0; for (let x = 0; x <= w; x++) { const ok = x < w && isLane(...hsvAt(d, y * w + x)); if (ok) { if (!run) start = x; run += gap + 1; gap = 0; } else if (run && gap < maxGap) gap++; else { // The open ocean is the same blue and spans the full width, so with // generous bridging it outruns the lane. It always reaches the screen // edges; the lane is a free-floating bar that never does. const x1 = x - gap - 1; if (run && start >= edge && x1 <= w - 1 - edge && (!best || run > best.run)) best = { run, y, x0: start, x1 }; run = 0; gap = 0; } } } if (!best || best.run <= w * 0.20) return null; // A long blue run alone is not enough: the Swishy Hoops night sky lands in // the same colour band and spans the whole screen. The lane is a thin bar // (~1.6% of height), so measuring how far the colour extends vertically // tells the two apart outright. const mid = (best.x0 + best.x1) >> 1; let up = 0, dn = 0; while (up < h && best.y - up - 1 >= 0 && isLane(...hsvAt(d, (best.y - up - 1) * w + mid))) up++; while (dn < h && best.y + dn + 1 < h && isLane(...hsvAt(d, (best.y + dn + 1) * w + mid))) dn++; return (up + dn + 1) <= Math.max(3, h * 0.08) ? best : null; } // The Swishy Hoops night sky is a dithered gradient, and at some canvas sizes // one of its bands is both long and thin enough to pass for a lane. That scene // is overwhelmingly dark navy (~93% of pixels) while the fishing spot is bright // open water and sky, so rejecting dark scenes outright settles it. function tooDark(d, w, h) { let navy = 0, tot = 0; for (let y = 0; y < h; y += 3) { for (let x = 0; x < w; x += 3) { const p = (y * w + x) * 4, r = d[p], g = d[p + 1], b = d[p + 2]; tot++; const mx = r > g ? (r > b ? r : b) : (g > b ? g : b); if (mx >= 140 || mx === 0 || mx !== b) continue; const mn = r < g ? (r < b ? r : b) : (g < b ? g : b); const dd = mx - mn; if (dd < 0.35 * mx) continue; let hu = 60 * ((r - g) / dd + 4); if (hu < 0) hu += 360; if (hu > 195 && hu < 255) navy++; } } return tot ? navy / tot > 0.30 : false; } // Obstacles can only ever make the detected lane look SHORTER than it is, so // holding the widest extent seen over a short window recovers the true ends, // and taking the median row kills the one-frame flyers. Everything downstream // (the aim marker especially) is measured against these, so any wobble here // shows up directly as a jittering line. let laneHist = []; function stableLane(raw, t, h) { if (raw) { const med = laneHist.length ? laneHist[laneHist.length >> 1].y : raw.y; // A row far from the settled one is a different object (usually the ocean). if (!laneHist.length || Math.abs(raw.y - med) <= h * 0.06) { laneHist.push({ t, x0: raw.x0, x1: raw.x1, y: raw.y }); } } laneHist = laneHist.filter(o => t - o.t < 1500); if (laneHist.length < 2) return raw ? { x0: raw.x0, x1: raw.x1, y: raw.y } : null; const ys = laneHist.map(o => o.y).sort((a, b) => a - b); return { x0: Math.min(...laneHist.map(o => o.x0)), x1: Math.max(...laneHist.map(o => o.x1)), y: ys[ys.length >> 1] }; } // ---------- the power meter ---------- // A short vertical gauge just left of the lane that fills from the bottom as // you hold. Its fill fraction is what the aim marker is derived from. // // The gauge is read at NATIVE resolution, out of its own grab, while the lane // and everything else come off the 4x-downscaled frame. That split is the // whole point of this section, so it is worth writing down why. // // The game sets the fill sprite's vertical scale to round(64 * power) — read // out of N.js, the shipped bundle, where _event_Minigames1 does // // theta += 1 (starts at 90, one step per tick) // power = 1 - |sin(theta degrees)| // AdjustImgInst("height", fillSprite, 100 * round(64 * power)) // // and AdjustImgInst("height", img, e) is set_scaleY(SCALE * e / 100). So the // fill takes exactly 65 heights and one step is the game's SCALE in pixels. // // Read through the 4x downscale the whole gauge came to ~21 rows, so a step // was under a third of a row: the reading could not resolve the game's own // quantum, and a one-row error in either the fill or the track was ~5% of the // gauge. The aim curve's slope near full charge is ~1.18 lane-fractions per // unit of power, so that single row arrived as ~6% of the lane — four times // the error of the curve it was feeding. That is why long casts were far off // while short ones looked fine: the same row is worth ~6% at full charge and // very little near zero, because the charge law is flattest at the bottom. const RUNGS = 64; function readMeter(G, lane) { const { d, w, h, sx, sy, nx, ny, cvW, cvH } = G; const isCase = (hu, s, v) => v < 0.55 && inH(hu, 5, 60) && s > 0.25; const part = (hu, s, v) => isBobber(hu, s, v) || isCase(hu, s, v); // Scanning the whole band and taking the topmost red row put a floor of // ~43% on every reading, because the striped beach umbrella beside the // meter is red too — so low-power casts could never be predicted. The // gauge is a tall thin column and the umbrella is squat, so the column // with the longest vertical run picks out the real meter. // // The run BRIDGES small gaps, as findLane does. It did not have to when // this ran on the downscaled frame: the 4x box filter blurred the pole's // own texture, the fill/track seam and the green marker line into pixels // that passed, so an unbroken run was easy to come by. At native // resolution those gaps are real, and a strict run measured 16-32 rows // against the 38 this test demands — every frame above about a third // charge failed outright and the meter read nothing at all. Bridging is // also what keeps the test meaningful rather than merely looser: what // separates the gauge from the umbrella is that the gauge is LONG, and a // run broken into thirds cannot show that. const bridge = Math.max(2, Math.round(cvH * 0.006)); let bestX = -1, bestRun = 0; for (let x = 0; x < w; x++) { let run = 0, gap = 0, longest = 0; for (let y = 0; y < h; y++) { const [hu, s, v] = hsvAt(d, y * w + x); if (part(hu, s, v)) { run += gap + 1; gap = 0; if (run > longest) longest = run; } else if (run && gap < bridge) gap++; else { run = 0; gap = 0; } } if (longest > bestRun) { bestRun = longest; bestX = x; } } if (bestX < 0 || bestRun < cvH * 0.05) return null; const pad = Math.max(1, Math.round(cvW * 0.006)); const cx0 = Math.max(0, bestX - pad), cx1 = Math.min(w, bestX + pad + 1); // Per row: how many of the band's columns are pole, and how many are fill. const rowN = new Uint8Array(h), rowRed = new Uint8Array(h); for (let y = 0; y < h; y++) { let c = 0, r = 0; for (let x = cx0; x < cx1; x++) { const [hu, s, v] = hsvAt(d, y * w + x); if (isBobber(hu, s, v)) { r++; c++; } else if (isCase(hu, s, v)) c++; } rowN[y] = c; rowRed[y] = r; } // One matching pixel in the row is enough. There WAS a width test here — // "at least half as many columns as the widest row" — to keep single-pixel // foliage specks from standing in for the top of the gauge. It was wrong, // and it took a second fishing spot to show why: it measured the fill's // apparent width against a peak taken from the TRACK, and the two masks // are not comparable. isCase is loose (v<.55, s>.25) so a blended edge // pixel still counts as track, while isBobber wants s>.55 and a blended // edge pixel does not count as fill. The track therefore always looks // wider than the fill, by about a column. // // On the spot it was tuned against, the fill was 2 columns and the test // demanded 2 — it passed with nothing to spare. On a spot where the camera // sits closer the gauge is narrower, the fill downscales to a SINGLE // column, and every fill row failed: the gauge collapsed from 22 rows to 6 // and the power read 1.00 for a bar that was three-quarters full. // // What actually separates a gauge from a speck is not width, it is that a // gauge is a long unbroken run and a speck is one or two isolated rows. // The walk below tests exactly that, and it was already doing the work. const on = i => i >= 0 && i < h && rowN[i] > 0; // Both ends are walked out from inside the pole rather than taken as the // first and last matching row. Two things break the run and have to be // stepped over: the row where the fill meets the track blends to a colour // that matches neither mask, and the game draws a green marker line across // the gauge. The gap to the foliage above is far longer than either, so // bridging a couple of rows separates them cleanly. const gapMax = Math.max(2, Math.round(cvH * 0.02)); const walk = (from, dir) => { let cur = from; for (;;) { let next = -1; for (let g = 1; g <= gapMax; g++) { const y = cur + dir * g; if (y < 0 || y >= h) break; if (on(y)) { next = y; break; } } if (next < 0) return cur; cur = next; } }; // The base is sought from the lane row down, not up: the dark PTS banner // sits lower in the same columns at some layouts. let bot = Math.min(Math.round(lane.y * ny) - sy, h - 1); if (bot < 0) return null; while (bot > 0 && !on(bot)) bot--; if (!on(bot)) return null; bot = walk(bot, 1); const top = walk(bot, -1); // Four rows of the OLD downscaled gauge, which is 4*ny native rows now. if (bot - top < 4 * ny) return null; let fillTop = null; for (let y = top; y <= bot; y++) if (rowRed[y] > 0) { fillTop = y; break; } // Ends come back in native canvas pixels, which is the space the geometry // is held in — the band offset sy moves with the lane row and must not // leak into a value that is supposed to be fixed furniture. return { x: (sx + bestX) / nx, topAbs: sy + top, botAbs: sy + bot, fillTopAbs: fillTop === null ? null : sy + fillTop, nx, ny }; } // The gauge is fixed furniture — it cannot move between frames, and the game // never resizes it — so its two ends are settled ONCE and then held, instead // of being re-derived every frame. A splash or a floating "+1 FISH" can cover // part of the pole for a frame or two, and a gauge measured short reads the // same red bar as far more power than it is. The previous version took a // rolling median over 1500ms, which removed the outliers (worst case 6 rows // for a 21-row gauge, i.e. triple the true power, on 1% of frames) but still // let the denominator drift with whatever the last 1.5s happened to contain. // A denominator that drifts is not noise, it is a slow scale error on every // prediction, and the aim marker cannot tell the two apart. let meterHist = [], meterGeom = null; function resetMeter() { meterHist = []; meterGeom = null; } function stableMeter(m, t) { if (!m) return null; if (!meterGeom) { meterHist.push({ t, top: m.topAbs, bot: m.botAbs }); meterHist = meterHist.filter(o => t - o.t < 1500); if (meterHist.length >= 10) { const med = k => { const a = meterHist.map(o => o[k]).sort((x, y) => x - y); return a[a.length >> 1]; }; meterGeom = { top: med('top'), bot: med('bot') }; } } const top = meterGeom ? meterGeom.top : m.topAbs; const bot = meterGeom ? meterGeom.bot : m.botAbs; const totalPx = bot - top + 1; if (totalPx < 4) return null; const fillPx = m.fillTopAbs === null ? 0 : Math.max(0, bot - m.fillTopAbs + 1); const rawFrac = Math.max(0, Math.min(1, fillPx / totalPx)); // The game's power is always exactly k/64, so snapping the reading to that // ladder ought to remove the sub-step noise for free. It is computed, and // reported, but deliberately NOT what the helper uses. That reads backwards // until you know where the rungs land in pixels, so: // // The ladder is real. The minigame attaches the gauge's two sprites from // one anchor: the track at anchor.y-87, the fill at anchor.y-23 with its // origin moved to its own bottom edge so it grows upward. 87-23 = 64, the // same 64 the fill's scale is quantised to — a full fill reaches exactly // the track's top edge, one rung is exactly one game unit, and fill/track // really is the power. // // And on the live canvas the rung is exactly one PIXEL. Watched through // tools/chrome over a session of real casts, totalPx reads 64 and never // anything else, because the backing store is the game's own resolution — // so fillPx/64 IS k/64 by construction. Fifteen distinct locked charges // came back 0.063, 0.094, 0.141, 0.156 ... 0.813, every one of them a whole // rung, the largest departure being the 0.05% that three decimal places of // printout can account for on its own. rawFrac needs no snapping: it is // already exact. // // Which also explains the recordings, where 585 readings sat no closer to // the rungs than random, at 32, 64 or 128 alike. Those captured the canvas // at its CSS size, 750 tall against the game's 540, so a unit spanned // 750/540 = 1.389px and the gauge measured the ~89px we saw. A rung that // is 1.4px wide, through H.264, against a fill edge the colour masks // resolve to about half a pixel, is a rung that does not survive being // measured. The ladder was there; the capture destroyed it. // // So the snap stays off, and the two measurements say why better than // either does alone: it is an identity at 64px, exactly where it would be // safe, and unreliable at 89px, exactly where it would have to earn its // place. There is no canvas size at which it is worth having. `snapped` // stays in the probe as the check — if it ever diverges from rawFrac on a // live canvas, the gauge is being read at a scale nobody has thought about. const snapped = Math.round(rawFrac * RUNGS) / RUNGS; const frac = rawFrac; return { x: m.x, top: top / m.ny, bot: bot / m.ny, fillTop: m.fillTopAbs === null ? null : m.fillTopAbs / m.ny, total: totalPx / m.ny, totalPx, fillPx, stepPx: totalPx / RUNGS, rawFrac, frac, snapped, settled: !!meterGeom }; } // ---------- blobs of a given colour on/near the lane ---------- let mask = new Uint8Array(0), stack = new Int32Array(0); function blobs(d, w, h, test, y0, y1, x0, x1) { const n = w * h; if (mask.length !== n) { mask = new Uint8Array(n); stack = new Int32Array(n); } mask.fill(0); for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { const i = y * w + x; if (test(...hsvAt(d, i))) mask[i] = 1; } const out = []; for (let y = y0; y < y1 && out.length < 60; y++) for (let x = x0; x < x1; x++) { const i = y * w + x; if (mask[i] !== 1) continue; let sp = 0; stack[sp++] = i; mask[i] = 2; let cnt = 0, sx = 0, sy = 0, ax = w, bx = 0, ay = h, by = 0; while (sp) { const q = stack[--sp], qx = q % w, qy = (q / w) | 0; cnt++; sx += qx; sy += qy; if (qx < ax) ax = qx; if (qx > bx) bx = qx; if (qy < ay) ay = qy; if (qy > by) by = qy; if (qx > x0 && mask[q - 1] === 1) { mask[q - 1] = 2; stack[sp++] = q - 1; } if (qx < x1 - 1 && mask[q + 1] === 1) { mask[q + 1] = 2; stack[sp++] = q + 1; } if (qy > y0 && mask[q - w] === 1) { mask[q - w] = 2; stack[sp++] = q - w; } if (qy < y1 - 1 && mask[q + w] === 1) { mask[q + w] = 2; stack[sp++] = q + w; } } if (cnt >= 3) out.push({ x: sx / cnt, y: sy / cnt, w: bx - ax + 1, h: by - ay + 1, n: cnt }); } return out; } // A single sprite often breaks into a few blobs (the urchin's spikes especially), // which would draw a pile of overlapping rings. Merge anything close together. function merge(list, gap) { const out = []; for (const o of list.sort((a, b) => a.x - b.x)) { const last = out[out.length - 1]; if (last && o.x - last.x < gap) { const n = last.n + o.n; last.x = (last.x * last.n + o.x * o.n) / n; last.y = (last.y * last.n + o.y * o.n) / n; last.n = n; } else out.push({ x: o.x, y: o.y, n: o.n }); } return out; } // ---------- aim calibration ---------- // A parabola needs its samples spread out to be worth fitting: six casts all // at half power pin the middle and let the ends fly anywhere, which is a // worse predictor than the seed they replaced. So the quadratic is only // accepted with enough samples over a wide enough range of the gauge. // // Below that the fit used to drop to a straight line, on the reasoning that a // line is "still better than nothing and cannot bend the wrong way". That was // true when the seed was itself a line, and became wrong the moment v6 made // the seed a curve measured over 19 casts and two spots: the fallback was no // longer replacing nothing, it was replacing the best number in the file. In // practice it fired almost immediately — three casts is enough — and a live // config caught in the act held aim2 = 0, aim1 = .8456 from six samples that // spanned only p .25 to .625. Against the seed that line reads +2.2% of the // lane at half power and -6.9% at full, so the further the target the more // power it demands, and you have to release early to land anything. That is // exactly the complaint v6 was supposed to have fixed. // // So the fallback keeps the curvature and fits only what the samples can // honestly see: the slope and the offset. Both spots measured for v6 fell on // the same curve, which makes SEED_C2 the game's law rather than one lane's // quirk, while slope and offset absorb the things that do move — chiefly how // wide findLane measured this particular lane. Two free parameters need far // fewer samples than three, and the result cannot bend the wrong way either. const SEED_C2 = 0.3095; // must move with cfg.aim2's default and migration function refitAim() { const S = cfg.samples; if (S.length < 3) return; const ps = S.map(s => s[0]); const span = Math.max(...ps) - Math.min(...ps); let c2 = 0, c1, c0; if (S.length >= 8 && span > 0.35) { let s0 = S.length, s1 = 0, s2 = 0, s3 = 0, s4 = 0, y0 = 0, y1 = 0, y2 = 0; for (const [p, l] of S) { const p2 = p * p; s1 += p; s2 += p2; s3 += p2 * p; s4 += p2 * p2; y0 += l; y1 += p * l; y2 += p2 * l; } const sol = solve3([[s4, s3, s2], [s3, s2, s1], [s2, s1, s0]], [y2, y1, y0]); if (sol) [c2, c1, c0] = sol; } if (!c2) { // Curvature pinned, slope and offset least-squared over l - SEED_C2*p^2. c2 = SEED_C2; let n = 0, sx = 0, sy = 0, sxx = 0, sxy = 0; for (const [p, l] of S) { const y = l - c2 * p * p; n++; sx += p; sy += y; sxx += p * p; sxy += p * y; } const den = n * sxx - sx * sx; if (Math.abs(den) < 1e-6) return; c1 = (n * sxy - sx * sy) / den; c0 = (sy - c1 * sx) / n; } // Reject anything that is not a sane cast curve: it has to rise all the way // across the gauge and stay on the lane. A fit that dips in the middle, or // sends full power off the end, is overfitted noise — keep what we had. const at = p => c2 * p * p + c1 * p + c0; const slope = p => 2 * c2 * p + c1; if (slope(0) <= 0 || slope(1) <= 0) return; if (at(0) < -0.15 || at(0) > 0.35 || at(1) < 0.5 || at(1) > 1.3) return; cfg.aim2 = c2; cfg.aim1 = c1; cfg.aim0 = c0; } const aimFrac = p => Math.max(0, Math.min(1, cfg.aim2 * p * p + cfg.aim1 * p + cfg.aim0)); // Inverse of the mapping: what power lands ON a given lane fraction. Only as // good as the current calibration, same as the aim marker. The curve rises // across the whole gauge (refitAim will not accept one that does not), so the // root wanted is always the one from the positive branch. const invAim = f => { const a = cfg.aim2, b = cfg.aim1, c = cfg.aim0 - f; if (Math.abs(a) < 1e-6) return Math.abs(b) > 0.05 ? Math.max(0, Math.min(1, -c / b)) : null; const disc = b * b - 4 * a * c; if (disc < 0) return null; return Math.max(0, Math.min(1, (-b + Math.sqrt(disc)) / (2 * a))); }; // ---------- debug probe ---------- // With tuning > Debug on, the measured values behind the drawing are // published on window.__idleon.fishing, refreshed every frame. That is what // tools/replay reads back when replaying a recording, and what to look at in // the console when the overlay is wrong but the status line looks fine — the // status line rounds, and the numbers that decide everything — the gauge's two ends — never // appear in it at all. Costs nothing while debug is off. const probe = o => { if (!cfg.debug) return; (window.__idleon = window.__idleon || {}).fishing = o; }; // ---------- state ---------- let frame = 0, lane = null, laneT = 0; let bob = null, bobHist = [], lastBobT = 0; let charge = 0, chargeSeen = 0, hold = null; function drawLaneMark(x, y, color, label, sub) { octx.save(); octx.shadowColor = 'rgba(0,0,0,.6)'; octx.shadowBlur = 3; octx.strokeStyle = color; octx.lineWidth = 2.5; octx.beginPath(); octx.arc(x, y, 11, 0, Math.PI * 2); octx.stroke(); octx.fillStyle = color; octx.font = 'bold 11px monospace'; if (label) { octx.textAlign = 'center'; octx.fillText(label, x, y - 15); } if (sub) { octx.textAlign = 'right'; octx.fillText(sub, x - 16, y + 4); } octx.restore(); } function loop() { frame++; if (!cfg.on) return; const cv = gameCanvas(); if (!cv) { if (frame % 30 === 0) stEl.textContent = 'no game canvas'; probe({ frame, idle: 'no game canvas' }); return; } const rect = cv.getBoundingClientRect(), dpr = window.devicePixelRatio || 1; const W = rect.width, H = rect.height; if (ov.width !== Math.round(W * dpr) || ov.height !== Math.round(H * dpr)) { ov.width = Math.round(W * dpr); ov.height = Math.round(H * dpr); ov.style.width = W + 'px'; ov.style.height = H + 'px'; } ov.style.left = rect.left + 'px'; ov.style.top = rect.top + 'px'; octx.setTransform(dpr, 0, 0, dpr, 0, 0); octx.clearRect(0, 0, W, H); const img = grab(cv); if (!img) { stEl.textContent = readErr; probe({ frame, idle: readErr }); return; } const { d, sw, sh } = img; const kx = W / sw, ky = H / sh; const t = performance.now(); const raw = tooDark(d, sw, sh) ? null : findLane(d, sw, sh); if (raw) laneT = t; const L = (t - laneT > 700) ? (laneHist = [], null) : stableLane(raw, t, sh); lane = L; if (!lane) { bobHist = []; hold = null; resetMeter(); if (frame % 15 === 0) stEl.textContent = 'idle\nnot at the fishing spot'; probe({ frame, idle: 'no lane' }); return; } const laneX0 = lane.x0 * kx, laneX1 = lane.x1 * kx, laneY = lane.y * ky; const laneW = laneX1 - laneX0; // lane outline octx.save(); octx.strokeStyle = 'rgba(56,189,248,.45)'; octx.lineWidth = 1.5; octx.setLineDash([4, 5]); octx.beginPath(); octx.moveTo(laneX0, laneY); octx.lineTo(laneX1, laneY); octx.stroke(); octx.restore(); // ---- fish and hazards sitting on the lane ---- // On-lane sprites are found in the native-resolution strip, then mapped back // into CSS space. Thresholds are deliberately strict: a real fish measures // ~30x30px solid, while the lane's own highlight edge produces long 3px-tall // slivers. Better to ring nothing than to ring the wrong thing. const S = grabStrip(cv, laneY, H); let fish = [], haz = [], landed = null; if (S) { const toCss = o => ({ x: o.x / S.w * W, y: (S.sy + o.y) / S.cvH * H, w: o.w / S.w * W, h: o.h / S.cvH * H, n: o.n }); // Search a little PAST both ends of the lane. A catch drifting to the end // of the water keeps swimming until its centre is level with the last // pixel of the bar, and half its sprite then hangs over the edge — but // the search box used to stop exactly at laneX1, so the blob was sliced // down the middle as it went. Watched live at Blunder Hills, a squid held // 173px and a solid ring out to 96% of the lane, then fell to 84px and // lost the ring at 99% purely to the slicing: the count floor was never // the problem, the width screen was, because three surviving columns are // not 1.2% of the canvas wide. Half a sprite is the margin that fixes it // — that squid measured 22px against a 296px lane, so 5% is half a sprite // with a little room. Nothing downstream gets looser: startX still throws // out the sand mound off the left end, and onLane still throws out the // surfboard rack off the right. const pad = Math.round(laneW * 0.05 / W * S.w); const sx0 = Math.round(laneX0 / W * S.w) - pad, sx1 = Math.round(laneX1 / W * S.w) + pad; const px = S.w / W; // native px per CSS px // The strip is tall enough that scenery pokes into it: the surfboard // rack at the spot's right edge matches both the hazard pink and the // bobber red, and wore a permanent AVOID ring. Everything the minigame // owns sits on the lane row, so blobs vertically off it are scenery. const onLane = o => Math.abs(o.y - laneY) < H * 0.03; const raw = (test, minN) => blobs(S.d, S.w, S.h, test, 0, S.h, Math.max(0, sx0), Math.min(S.w, sx1)) .filter(o => o.n >= minN).map(toCss).filter(onLane); const clump = W * 0.025; // One pass per species, with two geometry screens: // - Sprites are solid and roughly square, while the lane's shading breaks // into runs only a few px tall — a height floor kills those slivers in // whatever colour band they fall, and lets the pixel-count floor sit // lower than the old 200 (a real green fish measured ~190 matching px // at a 713px-wide window, which is how it lost its ring). // - The sand mound shares the eel's gold and its centroid can clear a // start margin, but it is anchored AT the lane's left end — so anything // whose left edge touches the start is scenery, not a catch. const startX = laneX0 + laneW * 0.02; const minH = H * 0.02; fish = []; for (const sp of SPECIES) for (const o of raw(sp.test, 120 * px * px)) if (o.w > W * 0.012 && o.h > minH && o.x - o.w / 2 > startX) fish.push({ ...o, name: sp.name, pts: sp.pts, color: sp.color }); const bobs2 = raw(isBobber, 60 * px * px); landed = bobs2.sort((a, b) => b.n - a.n)[0] || null; // The bobber is red too, so it lands in the hazard mask. Drop clusters // that coincide with it, and require the rest to be urchin-sized. // // Nothing is screened on size BEFORE the merge, and that is the point. // merge() exists because "a single sprite often breaks into a few blobs // (the urchin's spikes especially)" — but a 30px floor ran first and threw // the spikes away before merge could put them back, so the merge could // only ever reassemble a sprite that did not need reassembling. Measured // live: an urchin holding 150 matching pixels shattered into fragments // whose LARGEST was 7px, every one of them under the floor, and the lane // reported zero hazards with an urchin sitting on it. Handing merge the // raw blobs instead rebuilds it at 85px, clear of the 110*px*px screen // that still runs after. blobs() already refuses anything under 3px, and // that is the only pre-merge screen worth having. // // The trailing lane-start screen is the hazard pass's version of the // species pass's startX: the beach umbrella at the left end is red and // white, merges to 67px of its own, and clears the same 110*px*px floor. // Today the padded search box happens to cut it off — its centre sits at // -0.07 of the lane against a box reaching -0.05 — but two hundredths of // a lane is not a margin, it is a coincidence. haz = merge(raw(isHazard, 3), clump) .filter(o => o.n >= 110 * px * px && o.x > laneX0) .filter(o => !landed || Math.abs(o.x - landed.x) > W * 0.02); } if (cfg.marks) { // Left of each catch, the power that would land the cast on it — the // number to release the gauge at. Recomputed every frame, so once the // fish start moving (later in a run) the label tracks them. // The catch WINDOW, not just the spot: a bar as wide as the tolerance the // game actually allows, so a near miss is visibly near rather than a // mystery. A whale is half again as forgiving as a fish, which is not // something the sprite sizes make obvious. octx.save(); octx.lineWidth = 3; octx.globalAlpha = 0.45; octx.shadowColor = 'rgba(0,0,0,.6)'; octx.shadowBlur = 2; for (const f of fish) { const r = (f.catchN || 0) * laneW; if (r <= 0) continue; octx.strokeStyle = f.color; octx.beginPath(); octx.moveTo(f.x - r, f.y); octx.lineTo(f.x + r, f.y); octx.stroke(); } octx.restore(); for (const f of fish) { const p = invAim((f.x - laneX0) / laneW); drawLaneMark(f.x, f.y, f.color, `${f.name} +${f.pts}`, p !== null ? ((p * 100) | 0) + '%' : null); } // A hazard with a catch sitting on it is not a hazard. Land there and the // catch is what you get — which is why the aim marker below already lets // the catch colour outrank the hazard colour. Ringing it AVOID as well // put a red ring and a species ring on the same pixel, arguing with each // other over a spot you actually want to hit. Hazards only cost you when // you land on a bare one, or miss everything; same W*0.02 as the marker. for (const z of haz) if (!fish.some(f => Math.abs(f.x - z.x) < W * 0.02)) { // Same treatment for the pufferfish: its window is how far away you // have to stay, and at 19 lane units it is wider than every catch // except the whale. const r = HAZARD_N * laneW; octx.save(); octx.strokeStyle = '#f87171'; octx.lineWidth = 3; octx.globalAlpha = 0.45; octx.beginPath(); octx.moveTo(z.x - r, z.y); octx.lineTo(z.x + r, z.y); octx.stroke(); octx.restore(); drawLaneMark(z.x, z.y, '#f87171', 'AVOID'); } } // ---- power meter ---- // Its own native-resolution grab, not the downscaled frame — see the // power meter section for the ~6%-of-lane error that cost. const Gg = grabGauge(cv, lane, sw, sh); const m = stableMeter(Gg ? readMeter(Gg, lane) : null, t); if (m) { charge = m.frac; if (charge > 0.02) chargeSeen = t; } // Tick on the gauge at each catch's target power, in the species colour: // release when the fill reaches the mark. if (cfg.marks && m && fish.length) { octx.save(); octx.lineWidth = 2; octx.shadowColor = 'rgba(0,0,0,.6)'; octx.shadowBlur = 3; for (const f of fish) { const p = invAim((f.x - laneX0) / laneW); if (p === null) continue; const tx = m.x * kx, ty = (m.bot - p * (m.bot - m.top)) * ky; // A BAND, not a tick: the ends of the catch window mapped back through // the aim curve give the range of gauge fills that still land on this // fish. That is the release slack, and it is what you are actually // aiming at — a tick says where perfect is and nothing about how much // room there is around it. The curve is not linear, so the band is not // symmetric about the tick, and it tightens the further out the fish is. const r = (f.catchN || 0) * laneW; const pLo = invAim((f.x - r - laneX0) / laneW); const pHi = invAim((f.x + r - laneX0) / laneW); octx.strokeStyle = f.color; if (pLo !== null && pHi !== null) { const yLo = (m.bot - pLo * (m.bot - m.top)) * ky; const yHi = (m.bot - pHi * (m.bot - m.top)) * ky; octx.save(); octx.globalAlpha = 0.35; octx.lineWidth = 6; octx.beginPath(); octx.moveTo(tx - 2, yLo); octx.lineTo(tx - 2, yHi); octx.stroke(); octx.restore(); } octx.beginPath(); octx.moveTo(tx - 12, ty); octx.lineTo(tx + 8, ty); octx.stroke(); } octx.restore(); } // ---- ruler: numbered graduations tying the gauge to the lane ---- // Same idea as se7enek's IdleonHelper static overlay (gauge mark N lands // at lane mark N), but generated from the learned mapping instead of a // stretched image, so the numbers stay honest as calibration refits. if (cfg.ruler) { octx.save(); octx.font = 'bold 10px monospace'; octx.shadowColor = 'rgba(0,0,0,.7)'; octx.shadowBlur = 3; octx.strokeStyle = 'rgba(255,255,255,.65)'; octx.fillStyle = 'rgba(255,255,255,.85)'; octx.lineWidth = 1.5; for (let k = 0; k <= 8; k++) { const lx = laneX0 + aimFrac(k / 8) * laneW; octx.textAlign = 'center'; octx.beginPath(); octx.moveTo(lx, laneY + 4); octx.lineTo(lx, laneY + 11); octx.stroke(); octx.fillText(k, lx, laneY + 22); if (m) { const gx = m.x * kx, gy = (m.bot - (k / 8) * (m.bot - m.top)) * ky; octx.beginPath(); octx.moveTo(gx - 6, gy); octx.lineTo(gx + 6, gy); octx.stroke(); octx.textAlign = 'right'; octx.fillText(k, gx - 9, gy + 3); } } octx.restore(); } // ---- bobber ---- const above0 = Math.max(0, lane.y - Math.round(sh * 0.30)); const bobs = blobs(d, sw, sh, isBobber, above0, Math.max(above0 + 1, lane.y - 2), Math.round(lane.x0 - sw * 0.003), lane.x1) .filter(o => o.n >= 3 && o.w <= Math.round(sw * 0.05) && o.h <= Math.round(sh * 0.09)); bob = bobs.sort((a, b) => b.n - a.n)[0] || null; if (cfg.debug && bob) { octx.strokeStyle = 'rgba(255,255,255,.8)'; octx.lineWidth = 1; octx.strokeRect(bob.x * kx - bob.w * kx / 2, bob.y * ky - bob.h * ky / 2, bob.w * kx, bob.h * ky); } if (bob) { const p = { t, x: bob.x * kx, y: bob.y * ky }; if (bobHist.length && (t - lastBobT > 220 || Math.abs(p.x - bobHist[bobHist.length - 1].x) > W * 0.25)) bobHist = []; bobHist.push(p); if (bobHist.length > 30) bobHist.shift(); lastBobT = t; } else if (t - lastBobT > 300) bobHist = []; // ---- arc + landing prediction for a bobber in the air ---- let landX = null; if (bobHist.length >= 4) { const pts = bobHist.filter(q => bobHist[bobHist.length - 1].t - q.t <= 400); if (pts.length >= 4) { const t0 = pts[0].t, n = pts.length; let st = 0, s2 = 0, s3 = 0, s4 = 0, sx = 0, stx = 0, sy = 0, sty = 0, stty = 0; for (const q of pts) { const tt = (q.t - t0) / 1000, t2 = tt * tt; st += tt; s2 += t2; s3 += t2 * tt; s4 += t2 * t2; sx += q.x; stx += tt * q.x; sy += q.y; sty += tt * q.y; stty += t2 * q.y; } const den = n * s2 - st * st; if (Math.abs(den) > 1e-9) { const vx = (n * stx - st * sx) / den, x0 = (sx - vx * st) / n; const sol = solve3([[s4, s3, s2], [s3, s2, st], [s2, st, n]], [stty, sty, sy]); if (sol && sol[0] > 50) { const a = sol[0], b = sol[1], c = sol[2]; // solve a t^2 + b t + c = laneY for the landing time const disc = b * b - 4 * a * (c - laneY); if (disc >= 0) { const tl = (-b + Math.sqrt(disc)) / (2 * a); const now = (t - t0) / 1000; if (tl > now - 0.1 && tl < now + 3) { landX = x0 + vx * tl; if (cfg.arc) { octx.save(); octx.setLineDash([4, 5]); octx.lineWidth = 2; octx.strokeStyle = '#ffd166'; octx.shadowColor = 'rgba(0,0,0,.6)'; octx.shadowBlur = 3; octx.beginPath(); for (let tt = now; tt <= tl; tt += 0.016) { const px = x0 + vx * tt, py = a * tt * tt + b * tt + c; tt === now ? octx.moveTo(px, py) : octx.lineTo(px, py); } octx.lineTo(landX, laneY); octx.stroke(); octx.restore(); } } } } } } } // ---- learn power -> landing ---- // The gauge sweeps up and back down while you hold; releasing LOCKS it at // the chosen value, where it stays until the bobber is reeled back in. That // plateau is the power actually used — sampling the peak instead paired the // wrong power with the wrong cast, which made the mapping look random. if (charge > 0.05) { if (!hold || Math.abs(charge - hold.power) > 0.03) hold = { power: charge, t, xs: [] }; else if (landed && t - hold.t > 300) hold.xs.push(landed.x); } else if (hold) { if (hold.xs.length >= 6) { const xs = hold.xs.slice().sort((a, b) => a - b); const mid = xs[xs.length >> 1]; const spread = xs[xs.length - 1] - xs[0]; const landFrac = (mid - laneX0) / laneW; if (spread < W * 0.02 && landFrac > -0.05 && landFrac < 1.05) { cfg.samples.push([hold.power, landFrac]); if (cfg.samples.length > 20) cfg.samples.shift(); refitAim(); saveSoon(); } } hold = null; } // ---- live aim marker while charging ---- let aimX = null; if (cfg.aim && charge > 0.02 && !bob) { aimX = laneX0 + aimFrac(charge) * laneW; const near = fish.some(f => Math.abs(f.x - aimX) < W * 0.02); const bad = haz.some(z => Math.abs(z.x - aimX) < W * 0.02); octx.save(); // Landing on a fish counts as the fish even with a mine directly under // it, so the catch colour outranks the hazard colour. octx.strokeStyle = near ? '#4ade80' : (bad ? '#f87171' : '#ffd166'); octx.lineWidth = 3; octx.beginPath(); octx.moveTo(aimX, laneY - 26); octx.lineTo(aimX, laneY + 12); octx.stroke(); octx.beginPath(); octx.moveTo(aimX - 6, laneY - 26); octx.lineTo(aimX + 6, laneY - 26); octx.lineTo(aimX, laneY - 16); octx.closePath(); octx.fillStyle = octx.strokeStyle; octx.fill(); octx.restore(); } if (landX !== null) { octx.save(); octx.strokeStyle = '#ffd166'; octx.lineWidth = 2.5; octx.beginPath(); octx.arc(landX, laneY, 8, 0, Math.PI * 2); octx.stroke(); octx.restore(); } if (frame % 8 === 0) { const cal = `lane ${laneW | 0}px · ${cfg.samples.length} casts learned`; const line2 = bob ? (landX !== null ? `cast lands at ${((landX - laneX0) / laneW * 100) | 0}% of lane` : 'tracking cast') : charge > 0.02 ? `power ${(charge * 100) | 0}% → ${(aimFrac(charge) * 100) | 0}% of lane` : `${fish.length} fish · ${haz.length} hazards`; stEl.textContent = cal + '\n' + line2; } probe({ frame, lane, meter: m, charge, aimAt: aimX === null ? null : (aimX - laneX0) / laneW, landAt: landX === null ? null : (landX - laneX0) / laneW, // Where the bobber actually IS, as a fraction of the lane. The one // number that says whether the mapping is right: park a cast, read this, // compare with the aimAt that was showing when it was released. bobAt: landed ? (landed.x - laneX0) / laneW : null, fish: fish.length, haz: haz.length, cal: { c2: cfg.aim2, c1: cfg.aim1, c0: cfg.aim0, n: cfg.samples.length } }); } // ---------- wiring ---------- const toggle = () => { cfg.on = !cfg.on; if (!cfg.on) bobHist = []; save(); sync(); }; runBtn.onclick = toggle; $('#aim').onchange = e => { cfg.aim = e.target.checked; save(); }; $('#marks').onchange = e => { cfg.marks = e.target.checked; save(); }; $('#arcx').onchange = e => { cfg.arc = e.target.checked; save(); }; $('#ruler').onchange = e => { cfg.ruler = e.target.checked; save(); }; $('#debug').onchange = e => { cfg.debug = e.target.checked; save(); }; $('#cal').onclick = () => { cfg.samples = []; cfg.aim2 = 0.3095; cfg.aim1 = 0.5631; cfg.aim0 = 0.0420; save(); }; // For the suite's auto-open: the lane goes null the moment the fishing spot is off screen. // Reusing the loop's own state rather than testing the screen again -- // a second detector here would be one more thing to drift. return { loop, sync, toggle, active: () => lane != null }; } }; // ===================================================================== // Helper — Throwy Darts // Predicted dart path and the band it lands in, wind included. // ===================================================================== const darts = store('darts_cfg', { on: true, scale: 4, path: true, // dotted flight path band: true, // name the band you would hit live: true, // track a dart already in the air debug: false, 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) -> // 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 // throws at 6mph blowing up-right and six at 9mph blowing down-right, same // session, same aim style. The vertical acceleration difference between // 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). // 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 // 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. // // 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 // v6: magenta is NO LONGER gated, and v7 added red. 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 every wind under 10 mph, magenta 10-17, red 18 and up. Red was // not matched at all until v7 and read as 'none'; see windPx. 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 !== 6) { cfg.calVer = 6; cfg.vN = 0.548; cfg.gN = 0.612; cfg.landN = 0; cfg.windK = 0.01389; } }); const DARTS = { id: 'darts', name: 'Darts Helper', short: 'Darts', z: 2147483643, theme: { dot: '#fbbf24', ac: '#d97706' }, slot: { top: 12, left: 686, width: 216, nub: 78 }, dockOrder: 4, helper: true, overlay: true, hotkeys: { F2: 'toggle', F1: 'hide' }, keyHint: 'F2', cfg: darts.cfg, save: darts.save, bodyHTML: `
idle
tuning
F2 on/off · F1 hide panel
`, init(ui) { const cfg = darts.cfg, save = darts.save, saveSoon = darts.saveSoon; const $ = ui.$, root = ui.root, ov = ui.ov, octx = ui.octx, runBtn = ui.runBtn, dot = ui.dot, stEl = ui.stEl; function sync() { $('#path').checked = cfg.path; $('#band').checked = cfg.band; $('#live').checked = cfg.live; $('#debug').checked = cfg.debug; dot.classList.toggle('on', cfg.on); runBtn.textContent = cfg.on ? 'Hide path (F2)' : 'Show path (F2)'; runBtn.className = 'btn ' + (cfg.on ? 'stop' : 'go'); ui.chrome(); if (!cfg.on) octx.clearRect(0, 0, ov.width, ov.height); } // ---------- readback ---------- // Full frame from the suite's shared grab; darts wants it as {d,w,h}. let readErr = ''; const grab = cv => { const img = grabFrame(cv, cfg.scale); readErr = grabErr; return img && { d: img.d, w: img.sw, h: img.sh }; }; // The dart is a ~4px-wide sprite; at 4x it is a smear. The area around the // player is re-read at native resolution so the aim can be measured. const aimC = document.createElement('canvas'); const actx = aimC.getContext('2d', { willReadFrequently: true }); function grabBox(cv, cx, cy, half, W, H) { const sx = Math.max(0, Math.round((cx - half) / W * cv.width)); const sy = Math.max(0, Math.round((cy - half) / H * cv.height)); const sw = Math.min(cv.width - sx, Math.round(half * 2 / W * cv.width)); const sh = Math.min(cv.height - sy, Math.round(half * 2 / H * cv.height)); if (sw < 8 || sh < 8) return null; if (aimC.width !== sw || aimC.height !== sh) { aimC.width = sw; aimC.height = sh; } try { actx.clearRect(0, 0, sw, sh); actx.drawImage(cv, sx, sy, sw, sh, 0, 0, sw, sh); return { d: actx.getImageData(0, 0, sw, sh).data, w: sw, h: sh, sx, sy, cvW: cv.width, cvH: cv.height }; } catch (e) { return null; } } // A tall narrow native-resolution slice through the board, for reading bands. const bandC = document.createElement('canvas'); const bctx = bandC.getContext('2d', { willReadFrequently: true }); function grabBoard(cv, xCss, W) { const cx = Math.round(xCss / W * cv.width); const half = Math.max(4, Math.round(cv.width * 0.012)); const sx = Math.max(0, cx - half); const sw = Math.min(cv.width - sx, half * 2); if (sw < 3) return null; if (bandC.width !== sw || bandC.height !== cv.height) { bandC.width = sw; bandC.height = cv.height; } try { bctx.clearRect(0, 0, sw, cv.height); bctx.drawImage(cv, sx, 0, sw, cv.height, 0, 0, sw, cv.height); return { d: bctx.getImageData(0, 0, sw, cv.height).data, w: sw, h: cv.height, sy: 0, cvH: cv.height }; } catch (e) { return null; } } // Native-resolution crop of the "N mph" text, for the digit reader. const mphC = document.createElement('canvas'); const mctx = mphC.getContext('2d', { willReadFrequently: true }); function grabMph(cv) { const sx = Math.round(cv.width * 0.489), sw = Math.round(cv.width * 0.106); const sy = Math.round(cv.height * 0.037), sh = Math.round(cv.height * 0.067); if (sw < 8 || sh < 8) return null; if (mphC.width !== sw || mphC.height !== sh) { mphC.width = sw; mphC.height = sh; } try { mctx.clearRect(0, 0, sw, sh); mctx.drawImage(cv, sx, sy, sw, sh, 0, 0, sw, sh); return { d: mctx.getImageData(0, 0, sw, sh).data, w: sw, h: sh }; } 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); const d = mx - mn; let h = 0; if (d) { if (mx === r) h = 60 * (((g - b) / d) % 6); else if (mx === g) h = 60 * ((b - r) / d + 2); else h = 60 * ((r - g) / d + 4); if (h < 0) h += 360; } return [h, mx ? d / mx : 0, mx / 255]; } const px = (I, x, y) => { const p = (y * I.w + x) * 4; return hsv(I.d[p], I.d[p + 1], I.d[p + 2]); }; const isGold = (h, s, v) => h > 38 && h < 62 && s > 0.5 && v > 0.7; // ---------- is the darts screen up? ---------- // The whole backdrop is a dark red-brown plank wall. Measured at ~70% of // sampled pixels here and essentially absent elsewhere. function wallFrac(I) { let n = 0, tot = 0; for (let y = 0; y < I.h; y += 3) for (let x = 0; x < I.w; x += 3) { const [h, s, v] = px(I, x, y); tot++; if (h >= 0 && h < 32 && s > 0.30 && s < 0.75 && v > 0.20 && v < 0.72) n++; } return tot ? n / tot : 0; } // ---------- the target board ---------- // A tall column of saturated bands on the right. Found as the column with the // most strongly-coloured pixels; its bands then give the score for a hit. function findBoard(I, W, H) { const kx = W / I.w, ky = H / I.h; const x0 = Math.round(I.w * 0.80); let bestX = -1, bestN = 0; for (let x = x0; x < I.w; x++) { let n = 0; for (let y = Math.round(I.h * 0.12); y < Math.round(I.h * 0.95); y++) { const [h, s, v] = px(I, x, y); if (s > 0.35 && v > 0.35 && !(h < 32 && s < 0.75)) n++; } if (n > bestN) { bestN = n; bestX = x; } } if (bestX < 0 || bestN < I.h * 0.35) return null; let top = null, bot = null; for (let y = 0; y < I.h; y++) { const [h, s, v] = px(I, bestX, y); if (s > 0.35 && v > 0.35) { if (top === null) top = y; bot = y; } } if (top === null || bot - top < I.h * 0.3) return null; return { x: bestX * kx, top: top * ky, bot: bot * ky, col: bestX }; } // Read the band at NATIVE resolution. The board is a narrow strip, so at 4x it // blends with the reddish wall behind it and the blend reads as red — which // reported "+5" while the dart was actually heading for the purple band at the // bottom. Measured band colours: purple hue 220-236 at only s=0.19-0.30, tan // 48-54, green 113-127, red 352-358. Purple's low saturation is why the old // s>0.25 cutoff also threw it away. function bandAt(S, yCss, H) { if (!S) return null; const y = Math.round(yCss / H * S.cvH) - S.sy; if (y < 1 || y >= S.h - 1) return null; const votes = []; for (let dy = -1; dy <= 1; dy++) for (let x = 0; x < S.w; x++) { const [h, s, v] = px(S, x, y + dy); if (v < 0.35) continue; if (h >= 100 && h < 175 && s > 0.55) votes.push('+3'); else if ((h > 335 || h < 12) && s > 0.55 && v > 0.55) votes.push('+5'); else if (h >= 30 && h < 75 && s > 0.25 && v > 0.65) votes.push('+2'); else if (h >= 195 && h < 275 && s > 0.12) votes.push('+1'); } if (votes.length < 4) return null; const tally = {}; for (const v of votes) tally[v] = (tally[v] || 0) + 1; const best = Object.entries(tally).sort((a, b) => b[1] - a[1])[0]; if (best[1] < votes.length * 0.4) return null; const col = { '+5': '#ef4444', '+2': '#e5c07b', '+3': '#4ade80', '+1': '#93a4d4' }[best[0]]; return { name: best[0], col }; } // ---------- wind ---------- // 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. 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. // The three arrow sprites, and the one that used to be invisible. // // DartWind0 cyan hue 185-209 v .91-1.00 under 10 mph // DartWind1 magenta hue 275-293 v 1.00 10-17 mph // DartWind2 red hue 3- 36 v 1.00 18 mph and up // // Only the first two were ever matched, so an 18+ mph wind read as 'none' and // was modelled as still air -- the strongest winds in the game, treated as no // wind at all. Exactly the same shape of bug as the magenta gate. // // Red needs care the other two do not. It shares the HUD's own colours: the // brown panel behind it is hue 0-32 saturation .30-.75, and the amber text and // trim beside it run hue 33-44 -- so the arrow overlaps its background in BOTH // hue and saturation. Hue cannot separate them at all: the arrow's hue is // quantised, 73.5% of it below 36.3 and the remainder exactly at 36.3, right // inside the amber. // // Brightness helps -- the arrow is v=1.00 throughout and the brown never gets // past .72 -- but it is not enough on its own, because the amber reaches .96. // What actually separates an arrow from HUD text is that an arrow is a solid // blob; see the density gate in readWind. // // One asymmetry to know about: every darts recording reports 'none', which // makes them a free test that red is not seen where it should not be. None of // them contains an 18+ mph wind, so that red IS seen when it should be stays // unverified until one turns up. const windPx = (h, s, v) => s > 0.35 && v > 0.6 && ( (h > 165 && h < 215) || // cyan (h > 270 && h < 335) || // magenta (h < 45 && v > 0.85) // red, 18 mph and up ); 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 (windPx(h, s, v)) pts.push({ x, y, h }); } // An arrow is a BLOB, not a scattering. Requiring merely 8 pixels was // enough while only cyan and magenta were matched -- neither colour appears // in the HUD -- but red shares the HUD's own palette, and a handful of // amber text pixels would otherwise be read as a wind. // // Density is what separates them, and it does not care about colour at all: // the arrow sprites fill 4-8% of this window (480, 518 and 258 px of a // window that is 0.12W x 0.10H), while the amber scatter that was being // picked up ran 22-32 px, under half a percent. 2% sits in the gap with // room on both sides. // // This replaces a v threshold that was being tuned against whichever frame // was last looked at -- .85 let 70 false frames through, .97 still let 22 // through -- which is fitting a constant to noise rather than measuring. if (pts.length < 0.02 * S.w * S.h) 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; } mx /= n; my /= n; let sxx = 0, syy = 0, sxy = 0; for (const q of pts) { const a = q.x - mx, b = q.y - my; sxx += a * a; syy += b * b; sxy += a * b; } const th = 0.5 * Math.atan2(2 * sxy, sxx - syy); let ux = Math.cos(th), uy = Math.sin(th); if (ux < 0) { ux = -ux; uy = -uy; } const hue = pts.reduce((p, c) => p + c.h, 0) / n; // Staged, not a single split: red sits at ~20, which a `hue < 240` test // would have called cyan. predict() no longer cares which name it gets -- // every detected wind is trusted since v6 -- but the status line says it // and the probe records it, so it should be the truth. const key = hue < 45 ? 'red' : hue < 240 ? 'cyan' : 'magenta'; return { key, deg: Math.atan2(-uy, ux) * 180 / Math.PI }; } // ---------- reading the wind speed ---------- // Colour only gives a band (4mph and 9mph are both cyan), so the number is // read directly. Digit shapes were harvested from lossless screenshots; the // mph readout and the HUD score use the SAME font, which was verified glyph // by glyph, so templates from either work. Each digit is described by ink // density over a 3x5 grid plus aspect ratio — tolerant of the odd edge pixel, // unlike exact bitmap matching. const DIGITS = {"0":[{"z":[0.0732,0.0488,0.0854,0.0366,0.0732,0,0.0244,0.0488,0.0732,0,0.0244,0.0488,0.0732,0,0.0244,0.0488,0.0732,0,0.0244,0.0488,0.0366,0.0488,0.0732,0.0122],"ar":0.769}],"1":[{"z":[0,0.08,0.12,0,0.04,0.04,0.08,0,0,0.04,0.08,0,0,0.04,0.08,0,0,0.04,0.08,0,0.04,0.08,0.08,0.08],"ar":0.615},{"z":[0.0465,0.0465,0.0233,0,0,0.093,0.093,0,0,0.0465,0.0465,0,0,0.093,0.0465,0,0.0698,0.093,0.0698,0.0465,0.0465,0.0465,0.0465,0.0465],"ar":0.9},{"z":[0.0732,0.0488,0,0,0,0.0976,0.0488,0,0,0.0488,0.0244,0,0,0.0976,0.0488,0,0.0732,0.0976,0.0732,0.0488,0.0732,0.0488,0.0488,0.0488],"ar":0.9}],"2":[{"z":[0.0833,0.0556,0.0972,0.0417,0.0417,0,0.0417,0.0556,0,0.0139,0.0833,0.0139,0.0139,0.0556,0.0417,0,0.0694,0.0417,0,0,0.0833,0.0556,0.0833,0.0278],"ar":0.769},{"z":[0.08,0.0533,0.0933,0.0667,0.04,0,0.04,0.0533,0,0.0133,0.08,0.0133,0.0133,0.0533,0.04,0,0.0667,0.0533,0,0,0.0667,0.0533,0.08,0.04],"ar":0.769},{"z":[0.08,0.0533,0.0933,0.0667,0.04,0,0.04,0.0533,0,0.0133,0.08,0.0133,0.0133,0.0533,0.04,0,0.0667,0.0533,0,0,0.0667,0.0533,0.08,0.04],"ar":0.769}],"3":[{"z":[0.0811,0.0541,0.0946,0.0676,0.0405,0,0.027,0.0541,0,0,0.0811,0.0405,0.027,0,0.027,0.0541,0.0811,0,0.027,0.0541,0.0405,0.0541,0.0811,0.0135],"ar":0.769},{"z":[0.0882,0.0588,0.1029,0.0441,0.0441,0,0.0294,0.0588,0,0.0147,0.0588,0.0294,0,0,0.0294,0.0588,0.0882,0,0.0294,0.0588,0.0441,0.0588,0.0882,0.0147],"ar":0.769},{"z":[0.0946,0.0541,0.0676,0.0676,0.0676,0,0,0.0541,0,0.0135,0.0405,0.0541,0.027,0.0135,0.0405,0.0541,0.0811,0,0,0.0541,0.0676,0.0541,0.0541,0.0405],"ar":0.692}],"4":[{"z":[0.1311,0,0.1311,0,0.0984,0,0.0984,0,0.0984,0.0328,0.0984,0.0328,0,0,0.0984,0,0,0,0.0984,0,0,0,0.082,0],"ar":0.769}],"5":[{"z":[0.1034,0.0345,0.0345,0.0345,0.069,0,0,0,0.1034,0.069,0.069,0.0517,0.0345,0,0,0.069,0.069,0,0,0.069,0.069,0.0345,0.0345,0.0517],"ar":0.75},{"z":[0.1034,0.0345,0.0345,0.0345,0.069,0,0,0,0.1034,0.069,0.069,0.0517,0.0345,0,0,0.069,0.069,0,0,0.069,0.069,0.0345,0.0345,0.0517],"ar":0.75},{"z":[0.0615,0.0769,0.0462,0.0308,0.0923,0.0308,0,0,0.0462,0.0923,0.0923,0.0462,0.0154,0.0154,0,0.0615,0.0308,0.0308,0,0.0615,0.0154,0.0615,0.0462,0.0462],"ar":0.917}],"6":[{"z":[0.0698,0.0465,0.0814,0.0349,0.0698,0.0233,0.0349,0.0233,0.0698,0.0233,0.0465,0.0349,0.0698,0,0.0233,0.0465,0.0698,0,0.0233,0.0465,0.0349,0.0465,0.0698,0.0116],"ar":0.769}],"7":[{"z":[0.12,0.08,0.14,0.1,0,0,0.06,0.06,0,0,0.12,0,0,0.04,0.08,0,0,0.06,0.06,0,0,0.08,0,0],"ar":0.769},{"z":[0.1176,0.0784,0.098,0.1176,0,0,0.0196,0.0784,0,0,0.0784,0.0588,0,0.0392,0.0784,0.0196,0,0.0588,0.0588,0,0,0.0784,0.0196,0],"ar":0.692}],"8":[{"z":[0.0741,0.0494,0.0864,0.037,0.0741,0,0.0247,0.0494,0.0617,0.0247,0.0494,0.0123,0.0741,0,0.0247,0.037,0.0741,0,0.0247,0.0494,0.037,0.0494,0.0741,0.0123],"ar":0.769},{"z":[0.0805,0.046,0.0575,0.0575,0.069,0,0,0.046,0.069,0.023,0.0345,0.046,0.069,0.023,0.0345,0.046,0.069,0,0,0.046,0.0575,0.046,0.046,0.0345],"ar":0.692}],"9":[{"z":[0.0698,0.0465,0.0814,0.0349,0.0698,0,0.0233,0.0465,0.0698,0,0.0233,0.0465,0.0465,0.0465,0.0698,0.0465,0.0465,0,0.0233,0.0465,0.0349,0.0465,0.0698,0.0116],"ar":0.769},{"z":[0.0814,0.0465,0.0581,0.0581,0.0698,0,0,0.0465,0.0698,0,0,0.0465,0.0698,0.0465,0.0465,0.0465,0.0698,0,0.0116,0.0465,0.0581,0.0465,0.0465,0.0349],"ar":0.692}]}; // All ten digits are covered: 0 and 1 came from a "Score: 103" screenshot, // after an earlier guess at which glyph in "+1 Life" was the digit turned out // to be wrong — which silently broke every two-digit reading (10/11/12). function glyphSig(g) { // 4x6 zoning. A 3x5 grid could not tell '3' from '8' — both have a top and // bottom bowl, and only a finer grid sees that a '3' is open on the left. const z = new Float64Array(24); let tot = 0; for (let y = 0; y < g.h; y++) for (let x = 0; x < g.w; x++) if (g.g[y * g.w + x]) { z[Math.min(5, (y / g.h * 6) | 0) * 4 + Math.min(3, (x / g.w * 4) | 0)]++; tot++; } for (let i = 0; i < 24; i++) z[i] /= tot || 1; return { z, ar: g.w / g.h }; } function sigDist(a, b) { let s = 0; for (let i = 0; i < 24; i++) { const d = a.z[i] - b.z[i]; s += d * d; } return Math.sqrt(s) + Math.abs(a.ar - b.ar) * 0.5; } function readMph(S) { if (!S) return null; const ink = (x, y) => { const p = (y * S.w + x) * 4, r = S.d[p], g = S.d[p + 1], b = S.d[p + 2]; 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; if (seen[i] || !ink(x, y)) continue; st.length = 0; st.push(i); seen[i] = 1; let n = 0, x0 = S.w, x1 = 0, y0 = S.h, y1 = 0; const cells = []; while (st.length) { const q = st.pop(), qx = q % S.w, qy = (q / S.w) | 0; n++; cells.push([qx, qy]); if (qx < x0) x0 = qx; if (qx > x1) x1 = qx; if (qy < y0) y0 = qy; if (qy > y1) y1 = qy; for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]) { const nx = qx + dx, ny = qy + dy; if (nx < 0 || ny < 0 || nx >= S.w || ny >= S.h) continue; const nb = ny * S.w + nx; if (!seen[nb] && ink(nx, ny)) { seen[nb] = 1; st.push(nb); } } } const w = x1 - x0 + 1, h = y1 - y0 + 1; 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 }); } glyphs.sort((a, b) => a.x0 - b.x0); 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 > G.gap) break; // gap before "mph" digits.push(glyphs[i]); } if (!digits.length || digits.length > 2) return null; let out = ''; for (const d of digits) { const s = glyphSig(d); let best = 9e9, bch = null; for (const ch in DIGITS) for (const t of DIGITS[ch]) { const dd = sigDist(s, t); if (dd < best) { best = dd; bch = ch; } } if (bch === null || best > 0.22) { if (out === '1') { out += '0'; continue; } return null; } out += bch; } const v = parseInt(out, 10); return (v >= 1 && v <= 40) ? v : null; } // ---------- aim ---------- // The dart's own colours are useless: the character's body is white and so is // the shaft (s=.02 vs s=.03). What separates them is shape — the dart is a // long thin protrusion ahead of the hand. So march outward from the gold // 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. // 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; 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++) { 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) 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); if (v < 0.25) return true; // the dart's dark outline return !(s > 0.28 && h >= 0 && h < 38); // wall, skin and hair are reddish }; const scale = B.cvW / W; // native px per css px const R0 = Math.round(18 * scale), R1 = Math.round(100 * scale); const ext = []; let best = null; // 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. // -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; for (let r = R0; r <= R1; r++) { if (notWall(Math.round(gx + ux * r), Math.round(gy + uy * r))) { reach = r; gap = 0; } else if (++gap > 4) break; } ext.push({ deg, reach }); if (!best || reach > best.reach) best = { deg, reach }; } // 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 // 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; for (const e of near) { const w = e.reach - (best.reach - 5 * scale); sw += w; sd += w * e.deg; } // 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 ---------- // With tuning > Debug on, the measured values behind the drawing are // published on window.__idleon.darts, refreshed every frame. That is what // tools/replay reads back when replaying a recording, and what to look at in // the console when the overlay is wrong but the status line looks fine — the // status line rounds, and the numbers that decide everything — the board and the wind — never // appear in it at all. Costs nothing while debug is off. const probe = o => { if (!cfg.debug) return; (window.__idleon = window.__idleon || {}).darts = o; }; // ---------- state ---------- 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, 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 // 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) { const v = cfg.vN * W, g = cfg.gN * H; // Wind pushes ALONG the arrow, so it has a vertical component too — the old // model only pushed sideways. Strength scales with the speed the game // states, not with the colour band. The vertical component is the part // that matters for the board and is where cfg.windK is actually measured // (see its comment); the horizontal push is the same constant applied to // the arrow's x-component, which the per-throw x-fits are too noisy to // confirm (+-300px/s^2 scatter) but too small to matter (~1/4 of a band). // Magenta stays suppressed: its arrow glyph is a third the size of cyan's, // 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. // 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 // both axes with it. Read out of N.js, the shipped bundle: the minigame // builds the wind as 30*cos(phi) and 30*sin(phi) into two slots, then each // flight tick adds the horizontal slot over 600 and the vertical slot over // 750. Same vector, different divisors — so the horizontal acceleration is // 750/600 = 1.25x the vertical one, and a model using a single coefficient // for both is wrong on the horizontal axis by exactly that factor. // // Which axis is the correct one is settled by how windK was measured: it // was solved from the vertical acceleration difference between two wind // clusters (see its comment), so 0.0158 is the /750 term and it stays. The // horizontal is the one that was never independently confirmed — the // per-throw x-fits scattered +-300px/s^2 — and it is the one that moves. // // This should also account for the residual recorded against landN: "the // unexplained leftover splits +-20px WITH the wind sign". A horizontal // wind error does exactly that. It changes how long the dart takes to // reach the board, so it lands at the wrong point on an otherwise correct // vertical curve, and the error flips sign when the wind does. landN was // fitted with the horizontal term 20% light and is therefore carrying some // of it; it wants re-measuring on throws recorded after this change. const HV = 1.25; const ax = A * HV * Math.cos(wr), ay = -A * Math.sin(wr); const th = deg * Math.PI / 180; const vx = v * Math.cos(th), vy = -v * Math.sin(th); // The residual is eased in over the flight so the line still starts at the // dart rather than jumping away from it. const off = cfg.landN * H; return t => { const x = x0 + vx * t + 0.5 * ax * t * t; const frac = Math.min(1, Math.max(0, (x - x0) / Math.max(1, W * 0.55))); return { x, y: y0 + vy * t + 0.5 * (g + ay) * t * t + off * frac }; }; } function loop() { frame++; if (!cfg.on) return; const cv = gameCanvas(); if (!cv) { if (frame % 30 === 0) stEl.textContent = 'no game canvas'; probe({ frame, idle: 'no game canvas' }); return; } const rect = cv.getBoundingClientRect(), dpr = window.devicePixelRatio || 1; const W = rect.width, H = rect.height; if (ov.width !== Math.round(W * dpr) || ov.height !== Math.round(H * dpr)) { ov.width = Math.round(W * dpr); ov.height = Math.round(H * dpr); ov.style.width = W + 'px'; ov.style.height = H + 'px'; } ov.style.left = rect.left + 'px'; ov.style.top = rect.top + 'px'; octx.setTransform(dpr, 0, 0, dpr, 0, 0); octx.clearRect(0, 0, W, H); const I = grab(cv); if (!I) { stEl.textContent = readErr; probe({ frame, idle: readErr }); return; } if (wallFrac(I) < 0.35) { 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; } const b = findBoard(I, W, H); if (b) { board = b; boardT = performance.now(); } else if (performance.now() - boardT > 900) board = null; wind = readWind(grabWind(cv)); if (wind.key !== 'none') wind.mph = readMph(grabMph(cv)); const t = performance.now(); const kx = W / I.w, ky = H / I.h; // The dart's gold fletching — found as a BLOB, not as an average of every // 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. // // 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 // board is far right. Cutting there stops a dart already in flight from // 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 = []; 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; stack.length = 0; stack.push(i); seen[i] = 1; let n = 0, sx = 0, sy = 0, minx = I.w; while (stack.length) { const q = stack.pop(), qx = q % I.w, qy = (q / I.w) | 0; n++; sx += qx; sy += qy; if (qx < minx) minx = qx; 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 < y0 || ny >= y1 || nx < 0 || nx >= x1 || seen[nb]) continue; if (isGold(...px(I, nx, ny))) { seen[nb] = 1; stack.push(nb); } } } if (n < 4) continue; 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; })(); // ---- aim, measured in a native-resolution box around the player ---- 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, hand.x, hand.y); } if (aim) { // The sweep is smooth at roughly 3 deg per frame; anything wilder is the // detector latching onto scenery. Without this, occasional readings came // out 40 deg wrong and would have drawn a confident, wrong line. const df = frame - lastAimF; if (lastAim === null || df > 6 || Math.abs(aim.deg - lastAim) <= 12 * df) { aimDeg = aim.deg; aimT = t; lastAim = aim.deg; lastAimF = frame; } else aim = null; } // ---- predicted path from the current aim ---- let hitY = null, hitBand = null; if (cfg.path && aim && board && t - aimT < 400) { const f = predict(aim.x, aim.y, aimDeg, W, H, wind); const pts = []; for (let tt = 0; tt <= 3; tt += 0.012) { const p = f(tt); pts.push(p); if (p.x >= board.x) { hitY = p.y; break; } if (p.y > H + 40 || p.x > W + 40) break; } if (pts.length > 1) { octx.save(); octx.setLineDash([4, 6]); octx.lineWidth = 2.2; hitBand = hitY !== null ? bandAt(grabBoard(cv, board.x, W), hitY, H) : null; octx.strokeStyle = hitBand ? hitBand.col : '#fbbf24'; octx.shadowColor = 'rgba(0,0,0,.7)'; octx.shadowBlur = 3; octx.beginPath(); octx.moveTo(pts[0].x, pts[0].y); for (const p of pts) octx.lineTo(p.x, p.y); octx.stroke(); octx.setLineDash([]); if (hitY !== null) { octx.beginPath(); octx.arc(board.x, hitY, 8, 0, Math.PI * 2); octx.stroke(); if (cfg.band && hitBand) { octx.fillStyle = hitBand.col; octx.font = 'bold 15px monospace'; octx.textAlign = 'right'; octx.fillText(hitBand.name, board.x - 14, hitY - 12); } } octx.restore(); } } // ---- a dart already in the air ---- // 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, // 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) })) }; } 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; 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; } } 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' : `wind ${wind.mph ? wind.mph + 'mph' : wind.key} ${wind.deg.toFixed(0)}°`; stEl.textContent = `${w} · ${board ? 'board ok' : 'NO BOARD'}\n` + (aimDeg !== null && t - aimT < 400 ? `aim ${aimDeg.toFixed(0)}°${hitBand ? ` → ${hitBand.name}` : ''}` : 'no dart in hand'); } 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, // 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 } }); } // ---------- wiring ---------- const toggle = () => { cfg.on = !cfg.on; save(); sync(); }; runBtn.onclick = toggle; $('#path').onchange = e => { cfg.path = e.target.checked; save(); }; $('#band').onchange = e => { cfg.band = e.target.checked; save(); }; $('#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; cfg.windK = 0.01389; save(); }; // For the suite's auto-open: the board is nulled by the wall gate and after 900ms stale. // Reusing the loop's own state rather than testing the screen again -- // a second detector here would be one more thing to drift. return { loop, sync, toggle, active: () => board != null }; } }; // ===================================================================== // Suite panel — the switchboard // ===================================================================== const MODULES = [CLICKER, HOOPS, FISHING, DARTS]; const HUB = { id: 'suite', name: 'IdleOn Suite', z: 2147483647, theme: { dot: '#a78bfa', ac: '#7c3aed' }, slot: { top: 12, left: 12, width: 196, nub: 6 }, dockOrder: 0, overlay: false, bodyHTML: // Each row: the helper's name, its toggle hotkey, an eye that shows or // hides that panel, and the tickbox that runs it at all. The eye is here // because a hidden panel leaves only a 13px nub on screen to click, and // nothing says which nub is which — so "where did my darts panel go" had // no answer you could find by looking. MODULES.map(m => `
` + `${m.keyHint} ` + ` ` + `
` ).join('\n ') + `

unticking a helper stops it:
no panel, no readback, no hotkey
` }; function boot() { const hub = makePanel(HUB, suite); hub.save = saveSuite; hub.dot.classList.add('on'); // "all hidden" drives the button's label, so it reads as the thing it is // about to do rather than as the state it is in. const anyShown = () => MODULES.some(m => live.has(m.id) && !m.cfg.hidden); const anyOpen = () => MODULES.some(m => live.has(m.id) && !m.cfg.collapsed); function syncHub() { for (const m of MODULES) { hub.$('#en-' + m.id).checked = !!suite.enabled[m.id]; const eye = hub.$('#eye-' + m.id), off = !live.has(m.id); eye.textContent = m.cfg.hidden ? '\u25cb' : '\u25cf'; eye.title = m.cfg.hidden ? 'Show the ' + m.short + ' panel' : 'Hide the ' + m.short + ' panel'; eye.className = 'eye' + (off ? ' off' : ''); } hub.$('#panels').textContent = anyShown() ? 'Hide all panels' : 'Show all panels'; hub.$('#rollup').textContent = anyOpen() ? 'Minimise all' : 'Expand all'; for (const l of ['free', 'left', 'top']) hub.$('#lay-' + l).classList.toggle('sel', suite.layout === l); hub.$('#solo').checked = !!suite.solo; hub.$('#follow').checked = !!suite.follow; // Both only bite in a dock; saying so beats leaving them looking broken. hub.$('#solo').disabled = hub.$('#follow').disabled = suite.layout === 'free'; hub.chrome(); } for (const m of MODULES) { hub.$('#en-' + m.id).onchange = e => { setEnabled(m, e.target.checked); syncHub(); }; hub.$('#eye-' + m.id).onclick = () => { m.cfg.hidden = !m.cfg.hidden; m.save(); const inst = live.get(m.id); if (inst) inst.ui.chrome(); syncHub(); }; } for (const l of ['free', 'left', 'top']) hub.$('#lay-' + l).onclick = () => { suite.layout = l; saveSuite(); syncLayout(); }; hub.$('#solo').onchange = e => { suite.solo = e.target.checked; saveSuite(); }; hub.$('#follow').onchange = e => { suite.follow = e.target.checked; saveSuite(); }; onLayoutChange = syncHub; // Rolls every helper up to its title bar without hiding it — the panels // stay on screen and stay clickable, which is the difference from "Hide // all panels". In a dock that is also how you get back to one short column // after several have been opened. hub.$('#rollup').onclick = () => { const roll = anyOpen(); for (const m of MODULES) { m.cfg.collapsed = roll; m.save(); const inst = live.get(m.id); if (inst) inst.ui.chrome(); } syncHub(); }; hub.$('#panels').onclick = () => { const hide = anyShown(); for (const m of MODULES) { m.cfg.hidden = hide; m.save(); const inst = live.get(m.id); if (inst) inst.ui.chrome(); } syncHub(); }; // Puts every panel back in its default slot, unhidden and unrolled — // including the ones that are switched off, whose stored position would // otherwise still be off-screen next time they are switched back on. hub.$('#reset').onclick = () => { for (const m of MODULES) { const inst = live.get(m.id); if (inst) inst.ui.reset(); else { m.cfg.px = null; m.cfg.py = null; m.cfg.hidden = false; m.cfg.collapsed = false; m.save(); } } hub.reset(); suite.layout = 'free'; saveSuite(); syncLayout(); syncHub(); }; // Opt-in: the helper whose minigame is on screen opens itself and the other // helpers close. Driven off each helper's own detection -- the variable it // already keeps for "I can see my minigame" -- so there is no second copy // of any detector here to drift out of step. // // Only acts on a CHANGE of which helper is active, so a manual collapse is // not immediately undone; and it does nothing until a helper has been // active for a moment, because the detectors flicker while a screen loads // and a layout that flickers with them is worse than one that lags. let followWas = null, followSince = 0, followCand = null; function followTick() { if (!suite.follow || suite.layout === 'free') { followWas = null; return; } let now = null; for (const m of MODULES) { const inst = live.get(m.id); if (m.helper && inst && inst.active && inst.active()) { now = m.id; break; } } const t = performance.now(); if (now !== followCand) { followCand = now; followSince = t; return; } if (t - followSince < 600 || now === followWas) return; followWas = now; for (const m of MODULES) { if (!m.helper) continue; const inst = live.get(m.id); if (!inst) continue; const want = m.id === now; if (m.cfg.collapsed !== !want) { m.cfg.collapsed = !want; m.save(); inst.ui.chrome(); } } } for (const m of MODULES) if (suite.enabled[m.id]) startModule(m); hub.settle(); syncHub(); syncLayout(); setInterval(followTick, 250); requestAnimationFrame(driver); } if (document.documentElement) boot(); else document.addEventListener('readystatechange', function once() { if (document.documentElement) { document.removeEventListener('readystatechange', once); boot(); } }); })();