chars.js refresh from mod meta report + slot-aware slice + TODO rebuild#1
Open
chars.js refresh from mod meta report + slot-aware slice + TODO rebuild#1
Conversation
Card title already says "In your roster", so the green pill was noise. Only the "Not unlocked" state needs explicit badging. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A rounded Diamond with a set icon can smooth mask-only into Circle:0.97 stronglyRound=true with observed dCorner=0.92 — indistinguishable from the Grievous-Circle case on those signals alone. The `outer` contour candidate is the tiebreaker: a real Circle's outer trace scores Circle >= 0.6 with circularity >= 0.70, while a Diamond's outer trace sees the corners and scores Circle <= 0.35 with circularity <= 0.55. New high-priority rescue fires before the winnerStronglyRound-gated rules so over-smoothed Diamonds aren't blocked. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…oster re-ranking Overlay character list now shows explicit per-character status: - "Not unlocked" when not in roster (free + premium) - "Owned" when in roster but mod data unavailable - "X/6 · Empty slot" when mod slots are incomplete - "6/6 · Upgrade (N↑)" when fully modded with upgrade candidates - "6/6 · Maxed" otherwise Ownership lookup now fires on any loaded roster (free tier sees owned/not-unlocked); mod-data badges still premium-gated since only premium performs the roster sync. Your Roster card in Slicer now numbers matches 1..N based on how many roster characters matched, instead of inheriting the global rank from Best Characters (e.g. #2 in Best becomes #1 in Your Roster). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tapping the ally code input or Load button while roster lookup is locked now opens an Alert explaining the feature requires a rewarded ad or Premium, with a Watch Ad shortcut. Prevents the load from running without an unlock. Also unifies App.js mod-status gating: any loaded roster (ad-unlock or Premium) gets ownership + mod badges. Truly free users don't have a roster loaded and see no badges. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Popup title is now "Unlock Premium Features" with a body that lists what gets unlocked (roster lookup, ownership badges, mod-status recommendations), so users understand the value before tapping Watch Ad. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Scaffolds premium GAC team-recommender feature end-to-end on the
infra side (no UI yet).
Worker (tools/roster-worker/worker.js):
* New gacProbe=1 diagnostic that hits every candidate swgoh.gg GAC
meta endpoint and reports shape/status, so we can pick whichever
upstream actually serves squad win-rates.
* New gac=3v3|5v5 route that returns the first candidate endpoint
yielding usable data.
Client (src/services/gacMetaService.js):
* fetchGacMeta(bracket) with 12h AsyncStorage cache.
* Defensive normalizer accepts multiple possible upstream shapes so
the worker can swap endpoints without client changes.
* recommendSquads(payload, ownedBaseIds) ranks by
(winRate * 0.7 + coverage * 0.3) and splits into offense / defense
buckets, filtering out squads with <60% roster coverage.
* probeGacEndpoints(bracket) passthrough for dev.
State (src/services/gacMetaState.js):
* Pub/sub wrapper per bracket, matching rosterState pattern.
premiumState.FEATURES adds GAC_META key for future gating.
Next steps:
* wrangler deploy, hit /?gacProbe=1&bracket=5v5 to confirm live shape.
* Build GacScreen UI with 3v3/5v5 toggle + offense/defense split.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The worker now parses /gac/squads/ and /gac/who-to-attack/ HTML tables (swgoh.gg has no JSON API for GAC meta) and walks back a few season_ids until it finds a page matching the requested 3v3 or 5v5 bracket. Output feeds gacMetaService.normalizeGacData and recommendSquads unchanged. GacScreen adds a new tab gated behind premium / GAC_META rewarded unlock. Renders top defense or offense squads for the chosen bracket, marks missing roster members in red, and ranks by win-rate * coverage when a roster is linked. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Locked users now see only a feature-list gate card ("what you unlock")
instead of the bracket/role toggles and an empty squad area. Upstream
fetch is skipped entirely until unlocked.
Also drops the "Source: swgoh.gg" footer and the swgoh.gg mention from
the subtitle — the data provider is an implementation detail.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Your Roster and Best Fit were showing the same characters when a roster was linked — Best Fit was rendering the full matchedCharacters list, of which the top rows are almost always owned, so the two cards were visually identical. Best Fit now filters to non-owned matches so the two cards carry disjoint, complementary information. Mod status badges now key on the scanned shape instead of aggregating all six slots. modSummary(roster, baseId, shape) returns slotMod / slotEmpty / slotUpgradeable, which the overlay and Slice cards use to render "Empty Circle" / "Upgrade Circle" / "Circle maxed" instead of the generic "4/6 · Empty slot". Scanning a Circle now tells you about the circle slot, not the whole loadout. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Characters owned but below mod level (or otherwise never modded) came back with mods=[], which the slot-aware path demoted to "· Owned" — unhelpful, since any scanned mod is by definition an upgrade over nothing. Now a zero-mod unit reports slotEmpty=true for the scanned shape, so the overlay + Slice screens render "Empty Circle slot" and rank them correctly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…o 20 Free users who haven't linked a roster now see a premium pitch on the overlay characters panel instead of a misleading top-6 list (we can't score "upgrade over empty circle" without their roster). Also bumped the cap from 6 to 20 — with ~330 characters in SWGOH, 6 is too tight to surface real alternates. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace flat 'Speed › Offense% › Crit Chance%' line on each matched character with colored chips that mark which priority slots the scanned mod actually hit — green ✓ for aligned secondaries, purple ★ for a primary-stat match, muted for unhit slots. Makes it obvious why the engine ranked each character (which combines primary, set, and secondary weights already). Engine now returns alignedPriorityIndices + primaryPriorityIndex on each match so the UI doesn't have to re-run the matching logic. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tanks like Gamorrean Guard and Vandor Chewbacca have two builds: a main (Speed-first) and an alt/tank build (Health% > Defense% > ... > Speed). The engine picks whichever variant fits the scanned shell better — correct behavior — but compact match rows hid the variant label, making a legitimate alt-build pick look like wrong data. Show 'Main build' / 'Alt build' on every row, and tag overlay entries with '(alt)' when applicable. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Alt builds (buSecs) are now derived at runtime from SEC_FOCUS usage research instead of hand-curated strings. Strategy: keep positions #1 and #2 from the main build, then append stats #5 and #6 from the full usage-sorted list. Speed is locked — if the main build has Speed at position N, alt keeps Speed at N. Characters whose research shows Speed outside the top-6 (naturally slow) are respected; Speed is not force-injected. Falls back to manual buSecs if SEC_FOCUS is missing. Also adds a -12 finalScore penalty (with reason line) when the mod has 3+ revealed secondaries and none of them is Speed. Almost every character wants Speed first; a speed-less mod should rank last even if the shell otherwise matches. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds Recent-changes sections for the three April 2026 slice/overlay updates, plus two new follow-up items (scanner dismiss on resume, stat-comparative slot badges). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Priority-list matching now promotes flat stats to their % variant via a new normalizePriorityName helper. A character priority of Health (flat) is satisfied by a scanned Health% secondary, and vice versa — % is strictly better than flat and should always count as aligned. Derived alt builds coalesce flat + % usage counts per character before picking positions 5/6, so alt priorities consistently emit the % variant instead of leaking flat entries from SEC_FOCUS. scoreEnteredSecondaries already applies FLAT_TIEBREAKER_MULTIPLIER (0.25) when a scanned flat stat supports a % plan, so the inverse case (flat scanned vs % priority) remains down-weighted as a 'shitty match'. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Premium users with a linked roster now see a single full-width Your Roster card instead of the Your Roster + Best Fit split. Best Fit (non-owned matches) added noise for users who specifically opted into roster-filtered slice advice. Full-width also lets the match rows drop out of compact mode so variant label + full priority-chip list render. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Rewrite src/data/chars.js against swgoh.gg/stats/mod-meta-report/ (277 entries updated, 47 already correct, Cobb Vanth left as-is — not yet in the meta table). - Add tools/verify-chars-vs-swgoh.js: fetches the meta report through our Cloudflare Worker, parses set icons + per-shape primary columns, diffs vs chars.js; supports multi-primary tolerance lists. - Extend worker scrape allow-list to include /stats/. - Slice tab: replace score-based Upgrade/Sidegrade/Downgrade with a count-based Better/Same/Worse fit that compares priority-aligned secondaries on scanned vs equipped mod. New countAlignedForMatch() export in sliceEngine.js. - Move the priority-star indicator into dedicated Primary-stat-match (purple) + Set-match (yellow, substring-matched) badges under the character name. Priority chips now uniformly green when aligned. - Filter character recommendations by the scanned mod's set (substring). - Bump rosterService cache key v2 → v3 to invalidate caches predating mod primary/secondaries normalization. - OverlayCaptureService tweaks + .gitignore for .cache/ and *.bak.
Flip three existing OPEN rows to DONE based on what shipped this session: - Roster / premium: slot-aware empty/upgrade badges - Slice screen layout: Your Roster card alongside Best Characters - Slice screen scoring: secondaries weighted via aligned-priority count Add five new DONE rows for the session-specific work: chars.js refresh from swgoh.gg mod meta report, Primary stat + Set match badges, Better/Same/Worse fit verdict, set-filtered recommendations, roster cache-key bump.
Restores launch-prep items, Comlink-related context, branch hygiene, tier-ladder slicing, and additional DONE entries that the prior single-session edit had dropped. Adds build-todo-doc.js driver so the docx is regenerable. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
compareScannedVsEquipped() already scores scanned vs equipped mod against match priorities and emits Upgrade/Sidegrade/Downgrade + scoreDelta + per-stat deltas. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
buildLadderPlan() walks the scanned mod's current tier through 6E using rolled secondaries as signal. Emits one of four verdicts: USABLE — worth slicing to 6E (mat cost justified) CAP_AT_5A — finish the cheap 5A levels, skip 6-dot mats SELLABLE — bail now, mats would be wasted NOT_SLICEABLE — already 6E, no tier, or no build uses this shell Pre-5A steps each burn mats, so no-priority + no-Speed or minimal priority quality triggers an early bail. The 5A→6E step also burns mats; we require Speed evidence (3 rolls or speed arrow) or a high-SLICE_GAIN priority stat rolled ≥65% quality to justify continuing. SliceScreen renders a new ladder verdict card above the Decision card. TODO row flipped to DONE. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously a 5B/5C mod with decent stats but no 6-dot catalyst fell
into Cap at 5A, which misleadingly suggested finishing the climb —
but 5B→5A still burns tier mats. Fixed:
- Cap at 5A now only fires when tier === '5A' (money-only levels).
- New FILLER verdict for pre-5A mods with decent fit: equip as-is,
skip the tier-slice mats, replace when better lands.
- Sellable reserved for genuinely weak rolls.
User-reported 5B case (Off%1r, Ten%2r, Prot%3r, Speed1r) now returns
Filler instead of the wrong Cap at 5A.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Defense% weight 5.0 -> 4.0 (community consensus ~3-4, was biasing tanks) - strongUpside SLICE_GAIN cutoff 0.5 -> 0.3 (brings Protection% into the 6E-catalyst bucket so Prot%-primary mods with strong Prot% rolls can earn a Usable verdict) - hasDecentFit floor finalScore 40 -> 50 (Grandivory HOLD is 60; tighter floor avoids keeping mediocre mods as Filler too eagerly) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- READMEBEFOREEDITING: new sections covering the 5-state tier-ladder verdict (Usable / Cap at 5A / Filler / Sellable / Not sliceable) and the community-guidance tuning pass (Defense% 5.0 -> 4.0, strongUpside SLICE_GAIN cutoff 0.5 -> 0.3, hasDecentFit finalScore floor 40 -> 50). - TODO: flip the tier-progression row to reflect the 5-state verdict, add a new DONE row for the community-tuning audit. - Rebuild docs/TODO.docx from tools/build-todo-doc.js. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related fixes for the slice verdict flow. 1. New SLICE_NEXT verdict (cyan) for pre-5A mods with catalyst potential. Instead of projecting an end-state verdict from current rolls, the engine recommends "slice one tier, re-check" — which matches how slicing actually works. Each tier slice adds one random roll across the 4 revealed secondaries, so a 5C mod with Speed at 1 roll shouldn't be pre-judged against the 5A end-state. Gate: Speed secondary with rolls<5 OR a priority stat with SLICE_GAIN >= 0.3. Firing order puts SLICE_NEXT after the definitive-Usable checks so clear-cut "worth 6-dot" mods still return USABLE. 2. Tier letter OCR. extractModTier() in modCaptureParser.js reads E/D/C/B/A from the mod-card OCR (patterns: "Tier C", "LVL 15 · C", "Level 15 A", plus a standalone-letter fallback requiring a nearby LVL/LEVEL token). Threaded through parsed.modTier -> App.js slicePrefill.tier -> SliceScreen so the tier pill auto-selects from the scan. When OCR misses, SliceScreen now clears tier to '' on prefill instead of leaving the previous '5A' default — the ladder plan falls through to "Not sliceable — No tier selected" and the user picks manually. Prior behaviour silently showed wrong 5A verdicts on every scan. 6E (6-dot) isn't auto-detected yet (needs native pip-count detection) — user taps 6E manually on 6-dot mods. Flagged as OPEN follow-up in TODO. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Each SWGOH tier slice adds one random roll to one secondary, so the physical max rolls per secondary is 5E=1, 5D=2, 5C=3, 5B=4, 5A=5. The previous estimateRolls() used a hard cap of 5 and ignored the scanned tier, which produced impossible outputs like "Health% at 5 rolls" on a 5C scan. - maxRollsForTier() export in overlayRecommendation.js. - estimateRolls(stat, value, dotLevel, tier) respects the tier cap and also rejects outright when the value exceeds tier*max*1.02 (OCR-misread guard: e.g. Health% with value 30 can't fit any roll count, so null is preferable to a confidently wrong estimate). - App.js prefill now clamps explicit OCR rolls to tier ceiling and passes the scanned tier into estimateRolls. - ModOverlayCaptureService now dumps the full OCR text to <externalFilesDir>/ocr-debug-last.txt on each capture so scan-miss diagnostics (tier letter missed, stat misread) can be retrieved via adb pull without waiting for another misfire. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two OPEN items queued for future work: (1) scans miss the tier letter on some cards despite the existing extractModTier patterns -- need fresh per-tier OCR dumps to audit which patterns are failing; (2) investigate whether the ladder should nudge toward B->A slicing when speed has been missed on consecutive prior slices. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two more OPEN items: (1) some scanned secondaries don't make it to the Slice tab even when the tier is picked up correctly -- need a trace on a failing ocr-debug-last.txt to find where rows are being dropped; (2) add a Mine/All toggle on the GAC tab so users can see the full meta regardless of roster coverage, not just what they can field. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a third toggle row (shown when a roster is linked) that switches between the existing coverage-filtered ranking (>=60% members owned) and a full-meta view sorted by raw win-rate. All-meta mode still computes per-squad ownedCount + coverage so missing-member red chips keep working. No-roster case collapses into the same code path via showAll = !hasRoster || view === 'all'. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- modCaptureParser: detect standalone tier letter (D/C/B/A) above PRIMARY STAT so 5D arrow dumps mark tier correctly. - modCaptureParser: pre-clean OCR "0" -> "O" on stat name boundary so "0ffense" parses as Offense. - modCaptureParser: dedup secondaries by post-promotion canonical stat name via willPromoteToPercent() so flat Health (N) and Health% can coexist instead of the second entry getting dropped. - overlayRecommendation: use ladderPlan verdict/desc as source of truth, matching Slice tab. Fixes overlay saying FILLER ONLY while Slice tab says Slice to 5C on the same mod. - charBaseIds: Crosshair (Scarred) maps to CROSSHAIRS3 (swgoh.gg's real base id) so GAC meta list renders the name instead of the id. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- SlicerWhyPanel: per-secondary rows now include numeric value and Good/Great/Max thresholds so the separate Stat Quality card can go away. One card instead of two for the same data. - SliceScreen: drop standalone Stat Quality card and hide the Analysis (reasonLines) card; the headline reason already renders on the verdict card and the full list was making the page too long. - sliceEngine.getNextHitNarrative: worst-case next hit now uses the same targetWeight * upsidePct formula as best-case. Prior version picked by lowest targetWeight alone, which surfaced Defense% as worst even when flat Health / flat Offense were clearly lower- ceiling re-roll outcomes. - modCaptureParser.extractModTier: widen the standalone tier-letter scan window to everything before "SECONDARY STATS" (was stopping at "PRIMARY STAT") so layouts where a primary stat renders above the tier letter still pick up the tier. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- SlicerWhyPanel: Per-Secondary section is now collapsed by default with a chevron toggle, so the page isn't dominated by the expanded per-stat rows. - sliceEngine.getNextHitNarrative: worst-case next hit now prefers flat stats that don't match the character's build (isFlatTieBreaker) when any are present. A flat re-roll is always a worse outcome than a %-stat re-roll, regardless of pure targetWeight/upside math. - overlayRecommendation: pass parsed.modTier to every evaluateSliceMod call. Without it, the overlay's ladder plan fell through to "No tier selected" even when the Slice tab was correctly showing the ladder verdict for the same mod. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The icon classifier occasionally misreads Diamond as Circle (and similar pairs). When that happens the parser has an independent signal — the primary stat — that the Circle call is impossible: Circle only allows Health%/Protection%, so a Defense%-primary mod can't be a Circle. chooseShape() now walks ranked classifier candidates and picks the best one that's compatible with the OCR'd primary, falling back to the primary's only-allowed shape (Diamond for Defense%, Square for Offense%) when no candidate is compatible. Also lower the Defense% → Diamond inference threshold from >=15% to >=8% so 5-dot Diamond Defense% primaries (11.75%) trigger the Diamond inference instead of falling through. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Primary-value-by-slot is brittle data to bake in, and Arrow/Triangle/Cross can all roll Defense% primary. Keep the primary-shape constraint (Circle/ Square still rejected when Defense% is the primary) and rely on the icon classifier's ranked candidates to pick between Arrow/Triangle/Cross/Diamond. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SWGOH primary stat values are deterministic per slot × dot-tier. Some values uniquely identify a shape (11.75% Defense = 5-dot Diamond, 15% Defense = 6-dot Arrow, 20% Defense = 6-dot Diamond). When the OCR'd value matches one of these, trust the inference over the icon classifier — the value table is authoritative, the classifier is probabilistic. Ambiguous values like 7.5% Defense (5-dot Arrow or 6-dot Triangle/Cross) fall through to the classifier + primary-shape compat filter. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…c not slot Primary stat values in SWGOH are determined by mod dot-tier (1→6 dots), not by slot. 11.75% Defense is just the 5-dot primary value and can appear on Arrow/Triangle/Cross/Diamond alike; 20% is the 6-dot value across the same set. Value-based shape inference was wrong — relying on the primary-shape compat filter + icon classifier ranking only. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The icon classifier evaluates four shape variants (outer/inner/unguided/
mask-only) but only the winner's top-N rankings reach JS. When the winning
variant's top pick is rejected by the primary-shape filter (e.g. a Diamond
with a rounded outer rim wins as Circle, then Circle gets rejected because
Circle can't have Defense%), falling back to the winner's #2 often picks
Cross — another wrong answer — because the outer contour is biased toward
Cross for rounded-rim Diamonds.
Now expose all variant top-N rankings to JS as JSON. When the primary filter
rejects the winner, the JS consensusShape() picks via:
1. If mask-only has a clear top-1 (margin >= 0.08 over its #2) among
primary-compatible shapes → trust it. mask-only reads the inner icon
shape directly, so it nails the Diamond-with-rounded-rim case.
2. Otherwise sum all variant scores for compatible shapes, return highest.
For the reported Diamond/Potency/Defense scan: mask-only picks Diamond 0.61
with Cross 0.49 as #2 — margin 0.12, so Diamond wins.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Subscribe to premiumState and return null when isPremium. Placeholder and live BannerAd both bypass; no surrounding wrapper to adjust. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Their thin silhouettes read smaller than the filled shapes at the same bounding-box size. Keep the parent wrapper at the requested size so layout is unchanged; scale only the inner Image. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Permissions row now reflects MediaProjection grant post-install (user verified). Missed-speed streak nudge decided against — slicing a low-Speed mod on streak logic wastes mats; the high-Speed partial case is already covered by SLICE_NEXT. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dropped the speedBacked path (2 rolls + 55% quality -> Usable) and added a Speed value floor of 14 to speedHitHard. Mods with low-value Speed on few rolls now fall to SLICE_NEXT instead of auto-committing 6-dot mats. Aligns with the community "Speed >= 15 before 6-dot" rule. Also unified overlayRecommendation with SliceScreen: both now evaluate against the full DECODED_CHARS pool so roster filtering can't produce conflicting verdicts (overlay "don't slice" vs Slice tab "->6E"). Ownership stays visible via the per-character status badges. GuideModal Slicer page, READMEBEFOREEDITING.md, and TODO.docx updated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The in-app Guide Me was still showing 4 tabs. App now has 5 (Lookup, Finder, Slice, Scanner, GAC). Added a GAC Meta page covering bracket toggle, Mine vs All view, and the premium/rewarded-ad gate. Also switched the Scanner eyebrow icon from the old camera glyph to the same one the tab bar renders so the two stay in sync. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously all 140 matchable characters rendered in Best Characters / Your Roster. matchedCharacters is already sorted desc by matchScore, so slicing to 25 surfaces the strongest fits without reshuffling. Card headers show "Top 25 of N" when truncated, plain "N" otherwise. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Classifier debug PNGs were landing at native resolution (~100KB+), which broke Claude's Read tool on replay. Added a System.Drawing bicubic downscale pass inside Pull-SafePng that caps the longest edge at 400px. Typical crops drop to ~20-40KB while retaining enough detail to eyeball shape/mask decisions. -Full switch bypasses when native res is needed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Overlay + MediaProjection rationale dialogs already fired before their system prompts; the copy now hits every Play-reviewer checklist item explicitly. Overlay dialog calls out user-initiated only, no foreign app content access, no input simulation. Capture dialog calls out user-initiated only, on-device parsing, immediate image discard, no cloud or ad upload. Bulleted layout makes the "no automation" and "no tracking" claims easy to verify in a review pass. Closed the matching TODO rows: rationale UX and FOREGROUND_SERVICE manifest declarations were already wired but the doc lagged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Delete archive/ (53 legacy files) and src/data/chars.js.bak. Pre-cleanup
backup pushed to backup/pre-cleanup-2026-04-24 and chars.js.bak copied to
C:/Users/Chad/my-app/backups/ before deletion.
- Extract duplicated DECODED_CHARS + ENGINE_SLICE_REF to src/data/charDecoding.js;
overlayRecommendation.js and SliceScreen.js both import from it.
- Replace duplicated decisionDefinition() helpers with single
getDecisionDescription() export on sliceEngine.js so the verdict wording
matches across the Slice tab and overlay.
- Add tools/README.md cataloguing all 22 dev scripts.
- Generate light-mode shape variants. Each {shape}.png has a metallic
frame plus dark inner cavity + dark outer aura that read as black blobs
on a light background. New tools/gen-shape-lightmode.py rewrites pixels
with max(R,G,B) < 55 to white, preserving alpha. Outputs
assets/shapes/{shape}-light.png. ModShapeIcon.js reads isDark from the
theme and switches sources accordingly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…fault useState(true) keeps dark as the cold-start default for fresh installs. On mount we hydrate @modforge/themeIsDark from AsyncStorage; if a persisted value exists, swap to it (brief dark flash on launch is acceptable, matches premiumState/rosterState pattern). toggleTheme writes the new value to storage so a user who picks light keeps light on next launch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Refactor appTheme to expose hydrateTheme() with module-level cache, then await it in App.js's warm-up sequence before dismissing LoadingScreen. A returning light-mode user no longer sees a one-frame flash of dark before the swap — the persisted value is already in cachedIsDark by the time the provider mounts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Android occasionally remounts the React tree inside the same JS process (activity recreation from config changes / overlay scanner attaching), which fires AppThemeProvider's useEffect a second time. The .then was calling setIsDark with the value the hydrate promise originally resolved to — i.e. the AsyncStorage value at app launch — which clobbered any toggle the user made afterwards. Switch the .then to read from the live cachedIsDark module variable (toggleTheme writes to it synchronously) so remounts re-sync to the current state instead of the launch-time state. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Slows reverse-engineering of the Kotlin overlay/IAP modules and strips
debug symbols on release. JS bundle was already protected by Hermes
bytecode (hermesEnabled=true).
- android.enableMinifyInReleaseBuilds=true
- android.enableShrinkResourcesInReleaseBuilds=true
ProGuard keep rules pin the surfaces R8 can't see through reflection:
- com.hungrr13.modhelper.overlay.** + com.hungrr13.modhelper.iap.**
so the JS bridge can find @ReactMethod methods by string lookup
- @ReactMethod methods on ReactContextBaseJavaModule subclasses
- java.security.** + javax.crypto.** for the IAP signature verifier
(R8 would otherwise strip these on the assumption nothing reflects
into the security provider)
R8 was failing the release build on missing classes referenced by the
Google Mobile Ads SDK that target API 35+ (current compileSdkVersion
is 34). Suppressed with -dontwarn:
- android.media.LoudnessCodecController
- android.media.LoudnessCodecController$OnLoudnessCodecUpdateListener
These are guarded at runtime by SDK_INT checks inside AdMob, so the
suppression is safe. Add new entries here as R8 reports more.
Verified on R5CX10W4LJY (SM-S928U): release build installs cleanly,
JS bundle loads ("Running main"), no ClassNotFoundException /
NoSuchMethodError / UnsatisfiedLinkError in PID-scoped logcat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a Kotlin native module that verifies Google Play purchase
signatures against the Play License public key. RSA-SHA1 is Google
Play Billing's documented signing algorithm, so we use
Signature.getInstance("SHA1withRSA") + X509EncodedKeySpec on the
Base64-decoded SubjectPublicKeyInfo bytes; the receipt JSON is
signed as raw UTF-8. Fail-closed on malformed Base64.
JS wrapper at src/services/iapVerifier.js handles both
react-native-iap field shapes (dataAndroid/originalJson +
signatureAndroid/signature) and exposes verifyPurchase(purchase)
returning {ok, verified} or {ok:false, reason:...}.
Public key slot lives at src/config/playLicense.js, currently empty.
While empty, isConfigured() returns false and callers fall open --
acceptable for dev/sideload, NOT for prod. Before producing the
Play release build, paste the Base64 RSA public key from
Play Console -> Monetize -> Monetization setup.
Threat model: this raises the bar against Frida/Xposed hooks that
intercept react-native-iap and emit synthetic purchase events. It
does NOT make client-side gating cryptographically secure --
attackers with root can still patch the JS bundle or call
premiumState.setPremium(true) directly. Defense-in-depth, not
a single perimeter.
Native module is registered in MainApplication.kt alongside
ModOverlayCapturePackage; @ReactMethod surface is preserved by
the keep rules already in proguard-rules.pro.
NOTE: react-native-iap is not currently installed (lazy require
in iap.js fails open), so this verifier is inert until the
dependency is added. See TODO doc.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds reconcileWithPlay({timeoutMs}) to src/services/iap.js. On every
cold start, App.js warm-up queries getAvailablePurchases through
react-native-iap, filters to PREMIUM_SKU, and overwrites
premiumState to match Play's source of truth -- correcting:
- manual AsyncStorage flag flips on rooted devices
- sideloaded patched APKs that pre-populate the cache
- emulator runs that bypass the original purchase
- Google-side refunds and chargebacks (premiumState gets
revoked on next launch)
Behaviour:
- Play says owned -> setPremium(true)
- Play says not owned -> setPremium(false), revoking cache
- IAP unavailable / Play
offline / query timeout -> leave cache alone (don't punish
offline users on flaky connections)
3-second timeout via Promise.race so warm-up never stalls.
Also wires signature verification into the purchaseUpdatedListener
and restorePurchases paths via a new findVerifiedPremium() helper.
When iapVerifier.isConfigured() (Play License key populated), every
PREMIUM_SKU candidate must verify against the key before unlocking.
Invalid signatures drop the event WITHOUT finishTransaction so Play
retries -- if it's genuine, the next cold-start reconcile picks it
up and re-verifies. When the key is empty (current shipped state),
the verifier falls open so dev/sideload builds keep working.
Verified on R5CX10W4LJY (SM-S928U): cold start with Premium owned
keeps Premium unlocked; cold start with Premium not owned keeps it
locked. No FATAL, no Unhandled Promise Rejection in PID-scoped
logcat. (react-native-iap is not yet installed, so reconcile
returns iap-unavailable immediately and the verifier is unreached
-- the wiring is forward-compatible for when the dep is added.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
READMEBEFOREEDITING.md:
- New "Premium hardening" section under Recent changes covering
cold-start reconcile, native RSA-SHA1 verifier, Play license
key slot, R8 + ProGuard enablement, R8 dontwarn for AdMob
missing classes, and the threat model
- New "Theme persistence" section documenting the hydrateTheme()
gate, the live-cache useEffect that survives Android Activity
recreation remounts, and the related commits 5e90623 / 248b270
/ 44d5451
- Follow-ups list updated: populate PLAY_LICENSE_PUBLIC_KEY before
Play release; remaining blocker (R8 missing classes) marked as
resolved by the dontwarn entries
tools/build-todo-doc.js + docs/TODO.docx (regenerated):
- DONE: Theme persistence (hydration gate + live cache)
- DONE: Premium cold-start reconcile
- DONE: Premium signature verification
- DONE: Premium R8 + ProGuard enablement
- DONE: Repo hygiene -- mod-source-html/_files/ already removed
from working tree and not tracked on either branch (user kept
external backup)
- OPEN: react-native-iap dependency installation -- without it
the IAP hardening pipeline is inert; iap.isAvailable() returns
false because the lazy require fails. Plan: npx expo install
react-native-iap (v12+ for the requestPurchase shape iap.js
already uses), wire BILLING permission, validate with a real
Play Console internal-test purchase
- OPEN: Populate PLAY_LICENSE_PUBLIC_KEY in src/config/playLicense.js
before Play release; consider a Gradle assertion that fails the
release build if the constant is empty
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… live
react-native-iap was missing from the dep graph, so the lazy require()
pattern in src/services/iap.js was always falling open: iap.isAvailable()
returned false, reconcileWithPlay() short-circuited with iap-unavailable,
and ModForgeIapVerifier was unreachable. The hardening pipeline was inert.
Wire it up:
- Pin react-native-iap@^12.16.4 (matches the requestPurchase({sku, skus:[]})
/ getProducts shape iap.js was already written for; v15 ships Nitro
Modules with renamed APIs and would mean rewriting iap.js + flipping
newArchEnabled, much bigger change).
- AndroidManifest.xml: add com.android.vending.BILLING. The library does
NOT auto-add it via manifest merge.
- android/app/build.gradle: pin missingDimensionStrategy 'store', 'play'.
react-native-iap ships dual play/amazon flavors and Gradle errors out
on ambiguity without an explicit pick.
Patch RN 0.81 currentActivity break:
- ReactContextBaseJavaModule was converted Java->Kotlin in RN 0.81. The
Java-getter-as-Kotlin-property syntax (currentActivity) stopped
resolving for downstream Kotlin modules; only getCurrentActivity()
works now.
- Both react-native-iap and react-native-google-mobile-ads tripped on
this. Patches via patch-package@^8.0.1 + postinstall hook so they
survive npm install. ~7 sites total across RNIapModule.kt,
ReactNativeGoogleMobileAdsModule.kt, FullScreenAdModule.kt.
Bump Gradle daemon JVM:
- -Xmx4g / -XX:MaxMetaspaceSize=1024m (up from 2g/512m). Adding iap to
the classpath pushed Kotlin-compile + R8 metadata over the old 512m
ceiling. Symptom was ugly: daemon stayed alive at full CPU running
endless GC cycles, no APK output, only visible by tailing
~/.gradle/daemon/8.14.3/daemon-<pid>.out.log.
Verified on R5CX10W4LJY (SM-S928U) 2026-04-25:
- BUILD SUCCESSFUL in 2m 28s, installRelease landed clean.
- Cold-start logcat shows three RNIapModule: responseCode: 0 lines
within 3s of bundle load (initConnection, getProducts,
getAvailablePurchases = reconcileWithPlay).
- No FATAL EXCEPTION, no stripped-class errors, no unhandled rejections.
PLAY_LICENSE_PUBLIC_KEY remains empty pending Play Console identity
verification; signature verifier still fails open in the meantime.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…TODO README: - Premium hardening section now reflects the live state: react-native-iap is installed and reconciling against Play (responseCode: 0 in logcat), R8 builds cleanly with the LoudnessCodecController -dontwarn pair, Gradle daemon JVM bumped to 4g/1g to survive Kotlin+R8 with the expanded classpath. - Drop the "known build blocker" bullet (resolved). - Follow-ups: drop the R8 entry, soften the license-key entry to note Play Console identity verification is the gating step. TODO.docx: - Premium / react-native-iap dependency: OPEN -> DONE with the install details (flavor pin, BILLING perm, currentActivity patches, JVM bump, on-device verification). - Build / R8 missing classes: BLOCKED -> DONE (-dontwarn entries). - New row: Build / Gradle JVM heap + metaspace (records the 2g/512m -> 4g/1g bump and the silent-OOM symptom for next time). - Premium / Play License key: still OPEN, with a note that Play Console identity verification is currently gating it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
src/data/chars.jsfrom swgoh.gg's/stats/mod-meta-report/(277 of 325 entries updated). Rewrotetools/verify-chars-vs-swgoh.jsto parse the single-page meta report (explicit set icons + multi-primary tolerance) instead of per-character pages.countAlignedForMatch, set filtering). Cache key bumped v2 -> v3.docs/TODO.docx(35 rows) from the authoritative paste, restoring launch-prep, Comlink follow-up, branch hygiene, and tier-ladder items. Addedtools/build-todo-doc.jsso the docx is regenerable./stats/mod-meta-report/. Added.cache/and*.bakto.gitignore.Test plan
node tools/verify-chars-vs-swgoh.jsreports 0 mismatchesdocs/TODO.docxopens in Word/Google Docs with all 35 rows and status colorsGenerated with Claude Code