Making a GUI-only GPU poker solver scriptable — and what solver-priced regret reveals about heuristic bots
I made TexasSolverGPU (a closed-source, GUI-only, WebView2-packaged CFR solver) fully scriptable without patching the binary: an exe-scoped registry policy exposes a Chrome DevTools Protocol listener, and a ~60-line injected shim reproduces the UI’s own `chrome.webview` JSON-RPC dialect including token authentication. The driver initializes games, allocates GPU memory, runs solves, and exports strategy trees with per-action expected values. I then used it as an external oracle to price the decisions of my self-play poker bots in EV terms. The result is a measurement finding I think is worth sharing: ****action-frequency divergence from equilibrium and EV regret tell opposite stories about the same bot**** , and small heuristics leaks (out-of-position lead betting) that look harmless in a self-play pool have a measurable, non-zero price. Everything below is measured; sample sizes and limitations are stated explicitly.
_*Postscript worth reading first: after this draft was written, my frame-classification pass showed that none of the probe solves below used a correctly-specified preflop frame — every scored number is a distance-from-a-mis-specified-premise, not a bot-quality measurement. I publish them anyway, `[demoted]` markers attached, because the honest trace of a falsified premise is the better story.*_
**—**
**## 1. The problem**
I build and evaluate No-Limit Hold’em bots (self-hosted play-money app, TypeScript engine, ISMCTS-based planner). Evaluating such bots has a well-known trap: ****outcome is not quality.**** Over a few thousand hands, bb/100 is dominated by card luck, and inside a self-play pool a shared mistake is invisible because nobody punishes it. What I wanted was an external referee that answers, for a specific decision: _*what would an approximate-equilibrium strategy do here, and what does deviating cost in chips?*_
TexasSolver [1] and its GPU variant [2] compute approximate equilibria via CFR iterations. The GPU build is attractive for exactly this use — but it ships as a ****desktop GUI with no CLI, no stdin protocol, and no documented API****. The goal was programmatic control with full fidelity: anything the GUI can solve, the driver must be able to solve, including strategy exports.
**## 2. The instrument**
The application is a WebView2-hosted single-page app. Static analysis of the bundled JavaScript showed an HTTP-shaped route table (`POST /api/init`, `POST /api/allocate`, `POST /api/gpu-solve`, `GET /api/solve/status`, export routes) whose dispatch goes through the WebView2 `chrome.webview` postMessage channel — ****not**** a TCP socket. Three pieces were sufficient:
****(a) A debug surface that cannot be refused.**** Chromium flags reach the embedded browser via the WebView2 environment; the `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` environment variable was ignored by this app’s creation path (contrary to the common assumption — the documented precedence is app property < env var < registry policy [3][4]). The reliable layer is the ****registry policy**** `AdditionalBrowserArguments`, scoped by value name to the one executable (so no other WebView2 app on the host gets a debug port):
```powershell
New-Item -Path “HKLM:\SOFTWARE\Policies\Microsoft\Edge\WebView2\AdditionalBrowserArguments” -Force
Set-ItemProperty -Path “HKLM:\SOFTWARE\Policies\Microsoft\Edge\WebView2\AdditionalBrowserArguments” `
-Name “TexasSolverGpu_131.exe” -Type String `
-Value “–remote-debugging-port=9333 --remote-allow-origins=*”
```
Port 9333 deliberately, because the conventional 9222 was contended on my machine; `–remote-allow-origins` is required since Chromium ~111 for CDP WebSocket handshakes [5]. HKCU works without elevation.
****(b) A dialect-faithful shim.**** Via `Runtime.evaluate` over CDP I inject a small pending-request map that mirrors the UI’s exact envelope — `{id, method, params}` out, `{id, ok, result | error}` back — including `bridge.ping` → `bridge_token` acquisition and re-auth on `UNAUTHORIZED`. To the native side, the driver is indistinguishable from the same operations clicked in the GUI.
****(c) The payload encodings.**** Two conventions silently corrupt runs if wrong: cards are `rank_index * 4 + suit_index` (ranks `23456789TJQKA`, suits `cdhs`), and ranges are ****1326-element weight vectors in the app’s pair-combination order**** , not the 169-class (`AA`,`AKs`,`AKo`) syntax the CPU build accepts. A cheap end-to-end check caught wiring errors immediately: after removing 3 board cards, the exported `card_strings.length` must equal `C(49,2) = 1176`.
A negative result worth stating plainly: the nominally generic `http.request` bridge method exists in the route map but returns `NOT_IMPLEMENTED` — ****a route table’s existence proves the transport, not the implementation.**** I drive the named solver methods directly. (A loader-DLL swap was also tried and provably changed nothing; reverted byte-identical.)
**## 3. Validation**
- ****Live environment check:**** bridge ping returns a token; `solver.environment.check` reports driver CUDA 13.5 / RTX 3080 Ti (cc 8.6), all green.
- ****Solve quality:**** my probe solves converge to exploitability ≈ ****0.45**** (solver-internal percent units, `max_iterations=200`, `target_exploitability=0.5`) — in the same range as the GUI-observed 0.42–0.55 % from my earlier CPU/GPU cross-check.
- ****Throughput:**** measured ****4–30 s per spot**** end-to-end (init → allocate → solve → export), so a 100-spot probe set costs ~0.5–1 h unattended. The earlier instrument comparison measured 5.6 CFR iterations/s on GPU vs ~4 iterations/min on the CPU build for the same spot.
- ****Robustness in production:**** the batch driver survives a hung solve — `POST /api/solve/stop` + retry, and only terminal outcomes (scored / off-range) count as done so transient `BUSY` errors are retried. A naive first version stalled permanently on the first stuck solve; worth mentioning because every batch driver for this class of app needs it.
**## 4. What the oracle showed about my bots**
> ****Update since measurement (2026-11-10) — what I measured before I knew the premise was wrong.**** After the snapshot below was drafted, my frame-classification pass showed that ****zero of 4,312 extracted probes were true single-raised-pot frames**** : preflop sequences that looked SRP-shaped were actually limp-defend, 3-bet, and 4-bet+ continuations scored against canonical BTN-open vs BB-defend ranges. Every priced number in this section is a _*distance-from-a-mis-specified-premise*_ , not a bot-quality measurement — internally I demoted the whole class to methodology-validation. I keep them in place, `[demoted]` markers attached, because an oracle faithfully pricing the wrong premise is the part most worth publishing. (The 146 error records are ~99 % transient `BUSY` responses — the solver occupied at init/export/solve, retryable — plus one real extraction fault my wiring check caught. Selection check on the available proxy: no large difference in pot-size distribution between the 146 error records and the 575 evaluable (scored+off-range) records — medians 100 vs 90 chips; pot size is a proxy and preflop frame is not in the probe schema, so this is a proxy test, not a full frame audit.)
> ****Second update (same night, a level deeper) — the absence itself was an extraction artifact.**** The probe extractor could not see SRP at all. My preflop filter keyed on the action’s `street` label, but the street-_*closing*_ call logs `street:“flop”` (the logger writes the street _*after*_ the action). For open-defend and limp-through frames that closing call is the responder’s only preflop action — so the responder’s stack was never recorded, `eff_stack` computed as 0, and ****100 % of those hands were dropped**** at the spot gate. The frames were not misclassified; they were never extracted. Verified on the audited search-axis segment: ****510/2,500 hands (20.4 %) are open-defend**** ; after the fix the full corpus yields ****12,573 probes where open-defend is the plurality class at 43.6 %**** (limp-through 16.6 % — an entirely unmeasured class before). Same bug, second cost: for every responder-closed frame the closing call’s chips were also missing — ****pot understated (median +30 chips, 0 % unchanged across re-matched spots) and effective stack wrong (up to −118 chips)**** — so every record ever scored priced a systematically smaller, shallower game than the real one. The demotion stands and deepens: it was not only wrong ranges, it was the wrong game. Corrected numbers use frame-specific field-measured ranges over the fixed extraction and are being collected now.
> ****Third update (same night) — the corrected numbers landed (direction-grade).**** Rescoring over the fixed extraction with frame-specific field-measured ranges (108 frames; ranges trimmed to 85 % combo mass — the corpus tail is observation noise — and effective stacks priced as-if ≤12 SPR because late-tournament stacks reached 1,000+ bb and price infeasible on a 16 GB GPU) yields ****79 converged solver scores**** on the pre-registered paired-arm queue. ****Scope limit first, because it bounds everything else: 62 % of those scores carry the SPR≤12 cap — the matrix prices predominantly the shallow regime, while the field’s target character is deep-stack. Structurally this is the frame mismatch one level down: not charts for the wrong preflop path, but solver spots in the wrong stack regime for the target population. The texture answer below holds for the shallow regime; whether it holds deep is unmeasured.**** The thinker-persona class carries ****~0.139 bb mean regret vs the heuristic class’s ~0.026 bb — a 5.3× weighted gap that is tail-driven, not level-driven**** (cell means sit 5.6–12.7× above their medians; the top-1 record carries 41–58 % of every cell’s sum — a heavier distribution tail in the thinker class, not a systematically shifted level). Texture carries ****no supported split**** : dry ≥ wet in _*both*_ arms (+0.012 and +0.011 bb) — a consistent direction below detection. The dominant deviation is over-aggression (****31/44 non-consistent scored records = 70 %**** — bots bet/raise where the solver checks). The regret sits OOP (0.155 vs 0.027 bb) — and role coverage is ~92 % OOP, so the IP arm (n=6) is below comparison resolution: the honest statement is “OOP regret is high; the matrix is nearly an OOP-only measurement”, not “position effect confirmed”. The 20 off-range records are ****all trim-artifacts**** — every combo exists in that frame/side’s _*untrimmed*_ field-measured range and was cut by the 85 % trim (an earlier classification pass claimed “unobserved-in-field” via a format-mismatched substring check on concrete cards vs class names — registered as instance DSP-11, same proxy-class as the extractor bug). So the coverage statement is a plain coverage cut, not a bot-field divergence finding: scored = bot ∩ field ∩ trim-boundary, and 20 % of probes sit on the wrong side of my own trim. Persona-class spots carry regret; whether _*search*_ is the carrier is not identified — the flag is exactly the persona class. Answering that needs same-persona search on/off ablation — ****frame-conditioned**** : deep-stack-eligible, OOP-specific, on the bot∩field overlap set, else it inherits all three limits above; and paired per-spot (same probes, both policies) rather than unpaired, because at this variance an unpaired contrast needs ~200 records while the paired design removes spot variance by construction. Texture interaction (shallow regime): answered negative. Class gap: directed only. A population-level convergence series (n=28, 200–5000 search iterations × 4 seeds, run same day) corrects both earlier reads: the 6-spot over-bet read was a top-|d| selection artifact (population flat, +0.8pp), and the apparent negative sign-skew proved substantially a UCB1-allocation artifact — starved arms carry bad-draw-biased means. Unbiased single-arm measurement (K=2000 determinizations per action) is near-balanced (~14/28 negative); the residual negatives cluster on marginal low-EV spots — a clustering, not a population direction. A same-day determinization-variance pass then localized part of the divergence mechanistically: the search samples villain ranges from fixed class shapes (q≈0.68 for callers) while the field-measured ranges it models vary 0.45–0.85 per frame — only 17–67% of sampled holdings lie inside the premise support — and the divergence direction is spot-heterogeneous, not a single bias. (A further same-day pass — measured, in the repo, queued for the follow-up post — inverted h1130, the originally cleanest divergence case, to a +30ch leaf-eval-inflation case on a trips board, and localized a second divergence channel (calling-width) whose mechanism turned out to be “check structurally dominates bet” rather than “bet is punished”. The residual-negative count in this section is therefore an upper bound; both findings ride the same follow-up as the frame-corrected rescore.)
I ran a seeded 6-max self-play corpus (bots only, real engine, ~900 ms search budget per Thinker decision) and priced heads-up flop decisions against the solver with fixed canonical preflop ranges. Regret is computed per combo as `max_a action_evs[a] − action_evs[chosen]`, i.e. a ****single-node, lower-bound**** measure. Blinds are 5/10 throughout, so ****1 bb = 10 chips****.
****Status of the data (refreshed snapshot):**** drafted at 1,013 hands / 40 scored / 40 off-range; the corpus has since grown (one live segment alone reached its 2,500-hand cap), and the same measurement path now holds ****721 records — 309 scored / 266 off-range / 146 errors; regret mean 0.140 bb, median 0.020 bb — right-skewed, the mean is ~7× the median `[demoted — same premise]`****. The 40-decision basis below is kept explicit for two reasons: it explains why its means can sit far from 0.140 bb without contradiction (different sampled nodes under the same wrong premise), and it sets the expectation honestly — ****40 records under a demoted premise are an illustration of the process, not evidence****.
****(i) The concrete finding: out-of-position lead (“donk”) bets, n = 22 [40-decision basis].**** Of the 40 scored decisions, 22 were OOP lead bets at the flop root. ****18 of 22 had GTO frequency < 0.03.**** Regret per occurrence: ****mean 2.26 chips (0.23 bb) `[demoted]`, median 0.97 chips (0.10 bb) `[demoted]`**** , range 0.11–19.77 chips — right-skewed, with a few expensive outliers driving the mean. And the frequency is not an extraction artifact: my extractor only covers flop-root OOP decisions and IP-after-check, so I measured the underlying behavior directly — ****OOP leads in 55 % of all HU flop first-decisions in the corpus (208/378 — a separate count over the hand logs — a different measurement infrastructure than every other number in this section, which all come from `regrets.jsonl`; that source separation is part of why it survives; definition: any lead bet by the preflop defender out of position at the flop root of a heads-up pot) — a corpus-behavioral count that survives the demotion**** (it needs no solver). One honest tension, left open: this is the aggregate bot donk rate over the whole HU flop-root population, and its magnitude is not calibrated against the three solver-audited boards (which showed 7–36 % donk mass per persona but are no random sample of the donk-rate distribution) — the definition gap is identified, the magnitude gap is not yet closed. It survives the solver demotion; it does not transfer to human populations — this corpus is bot self-play, and my human anchor is still open work. The bots lead out of position more than half the time; the equilibrium almost never does. In the self-play pool this line _*works*_ , because nobody punishes it, which is exactly why an external oracle matters.
****(ii) Frequency and EV are different measurements — so I report them separately, not as one ranking.**** The two columns below answer different questions: “how much did deviations cost?” (EV) and “how often was the chosen action simply never in the equilibrium?” (frequency). They need not agree, per-persona sample sizes are small, and all figures are `[demoted]` under the frame audit:
| persona | scored n | mean regret (bb) | freq-0 share |
| — | — | — | — |
| reg (ISMCTS) | 19 | 0.29 | 15/19 |
| tag (ISMCTS) | 11 | 0.04 | 4/11 |
| station | 3 | 0.04 | 1/3 |
| lag | 6 | 0.01 | 0/6 |
| rock | 1 | 0.00 | 0/1 |
(19 + 11 + 3 + 6 + 1 = 40 — all numbers in this section come from one snapshot; an earlier draft mixed two snapshots minutes apart, which is why its table summed to 32 under a 34 headline. Noted for honesty, fixed.)
The number that _*connects*_ the two metrics is regret per freq-0 decision: reg ≈ ****0.37 bb `[demoted]`**** , tag ≈ ****0.10 bb `[demoted]`**** — reg’s zero-frequency choices were roughly 3.5× as expensive as tag’s _*under the mis-specified premise*_. And a caveat I mean literally: lag’s 0/6 is not distinguishable from 0/10; it says “no zero-frequency action in six decisions”, not “lag plays well”.
****(iii) This is an aggregation, not a reproduction — and the ordering is texture-dependent.**** My earlier two-spot whitepaper ordered the same personas differently (rock worst on a monotone board; here rock sits at 0.00 with n = 1). The 40 scored decisions break down as 19 two-tone, 18 rainbow, ****only 3 monotone**** — the wet-board regime that drove the whitepaper’s largest regrets is nearly absent here. So the honest statement is: _*the two-spot result (frequency divergence without matching EV cost) is consistent with the corpus aggregate, but the persona ordering is texture-dependent and not stable across aggregation.*_ I will not claim a persona ranking until the probe set is texture-stratified.
****(iv) Pot types matter — most of these leads are NOT in the class my oracle covers best.**** Of the 22 scored leads, ****7 were single-raised pots and 15 were 3-bet+ pots****. Two consequences. First, the solver-addressable HU-SRP-flop class (6.0 % of all decisions) is not where most of the measured leak lives — the leak measured worse in re-raised pots `[demoted]` — Second, a caveat against my own current numbers: my probe solves use SRP-canonical ranges (BTN-open vs BB-defend), so the 3-bet-pot scores were computed against a ****mis-specified equilibrium**** — indicative only until I generate proper 3-bet ranges. The post-draft frame audit showed this undercut is broader than this paragraph claimed: ****zero true SRP frames among 4,312 probes**** , and the SPR keying itself was wrong — `open-4bet` frames measured median SPR 5.3 with 58 % above the ≤4 menu bound. I flag this rather than let precise-looking numbers imply more than they can support.
****(v) The off-range rate is 46 % — and it is itself a premise-mismatch finding.**** Refreshed: 266 of 575 non-error scorings are off-range (the denominator is a choice — 266/721 = 36.9 % across all records; I report per-evaluable), meaning the bot’s hole cards are ****absent from the canonical GTO preflop ranges**** — a direct preflop-divergence measurement, not a bet-size mapping failure. The post-draft decomposition sharpened it further: ****82 % of OOP off-range cases are an SB-limp-path artifact**** — the bot arrives via a limped-SB line that the canonical BB_defend premise never contemplates — so much of the “divergence” is again the wrong premise, not deviant play. Per-persona off-range with denominators and Wilson 95 % CIs (recount updated the audit’s earlier rates by 0.4–1.2 pp): station 49/81 = 60.5 % [49.6–70.4], reg 130/278 = 46.8 % [41.0–52.6], tag 56/127 = 44.1 % [35.8–52.8], rock 8/20 = 40.0 % [21.9–61.3], lag 23/69 = 33.3 % [23.4–45.1] — and the consequence spelled out: the five CIs overlap pairwise, so the numerical ordering is a ****point-value ordering, not an evidenced one**** ; no persona’s off-range rate is inferentially separable from another’s at these n. What survives as a bot finding: the bots do hold off-chart hands at meaningful rates; what does not survive: treating the magnitude as a clean divergence quantification. Either way the scored subset is ****selected toward in-chart holdings**** , so priced regret is _*understated*_ relative to the full decision distribution — under the demoted premise, doubly so.
For prioritization, the same corpus gives a full ****coverage decomposition**** (share of all decisions): preflop 52.9 %, flop 25.0 %, turn 14.2 %, river 7.9 %; the solver-addressable HU-SRP-flop class is ****6.0 %****. Multiway pots account for ~58 % of postflop decisions, and ~49 % of decisions occur below 50 bb effective stack. These numbers — not intuition — set my training order: preflop distillation first, solver postflop tables as a bounded pilot.
**## 5. Limitations (the honest list)**
- ****Heads-up only.**** The solver does not solve multiway pots; ~58 % of my postflop decisions are outside its reach. I do not silently transfer HU numbers to multiway spots.
- ****Range circularity.**** The oracle solves against _*approximated*_ canonical ranges, not ground-truth GTO ranges. Regret is therefore a ****relative**** metric for comparing bot versions under frozen ranges — not an absolute distance to GTO.
- ****Lower bound, not full regret.**** Single-node pricing assumes equilibrium play afterwards; downstream bot errors are uncounted.
- ****Selection bias.**** Corpus-sampled probes only cover spots the bot reaches. My next iteration adds synthetic reference-distribution probes (canonical ranges × texture grid, policy replay) to measure spots the bot never reaches because it misplayed into them.
- ****Small, non-random n so far.**** 40 scored decisions is an early signal — and the scored subset is selected toward in-chart holdings (see §4 v), so regret is likely understated. 3-bet-pot scores additionally use SRP-canonical ranges (§4 iv) and are indicative only. The full frozen-manifest baseline is in progress and will be reported texture- and pot-type-stratified with confidence intervals.
- ****Post-draft demotion (the load-bearing one).**** After drafting, frame classification showed ****zero true SRP frames among 4,312 probes**** — all priced numbers here are distance-from-mis-specified-premise `[demoted]`, useful as methodology evidence only. Frame-corrected rescoring (field-measured limp ranges, SPR-keyed menus) is running; the corrected numbers will be a ****follow-up post**** , announced as such so the correction is citable rather than silent.
**## 6. Reproduce it**
1. Install TexasSolverGPU 0.3.0 [2]; apply the registry policy above; fully restart the app (surviving `msedgewebview2.exe` children keep a stale environment).
2. Verify: `curl http://127.0.0.1:9333/json` lists the `appassets.local` page.
3. `tsg_bridge.py` — CDP attach, shim injection, authed solver calls; `run_spot.py` — full init/allocate/solve/export flow; `probe_driver.py` — corpus extraction → batched solves → per-combo EV regret with off-range classification and solve caching.
4. Analyze: `analyze-pro.mjs` (position-aware RFI/3bet stats vs public 6-max targets), `coverage.py` (decision-class coverage).
Code and raw logs: project repo on Codeberg (poker-project, `tools/texassolver/`, `packages/server/tournament/`; the solver bridge is being prepared for standalone release). All numbers above are regenerable from the JSONL logs with the commands listed there.
**## 7. Why I think this is useful beyond my project**
- For ****any**** WebView2-packaged desktop app with a JS-to-native RPC layer, the pattern (exe-scoped registry policy → CDP → dialect-faithful postMessage shim) is a reusable instrumentation recipe — no binary patching, no fragile UI clicking.
- For bot evaluation generally: ****price decisions, don’t count frequencies.**** A solver’s `action_evs` turns “my bot looks aggressive” into “this line costs 0.2 bb” — and that difference changes what you fix first.
- For self-improvement loops: an external, priced oracle is the practical antidote to self-play drift. The solver is teacher and referee, never the runtime player.
Feedback, replication reports, and collaborators welcome — especially on multiway evaluation methodology and on probe-set design (how would you sample spots to make regret estimates comparable across bot generations?).
**—**
**### References**
1. TexasSolver (CPU, open source): GitHub - bupticybee/TexasSolver: 🚀 A very efficient Texas Holdem GTO solver ♠♥♣♦ · GitHub
2. TexasSolverGPU (GUI, WebView2-packaged): GitHub - bupticybee/TexasSolverGPU: 🚀 🚀 🚀 A extremely efficient Texas Holdem GTO solver ♠️♥️♣️♦️ · GitHub
3. Microsoft WebView2 — `CoreWebView2EnvironmentOptions.AdditionalBrowserArguments`: CoreWebView2EnvironmentOptions.AdditionalBrowserArguments Property (Microsoft.Web.WebView2.Core) | Microsoft Learn
4. WebView2Feedback issue #5571 — environment-variable vs app-property precedence: [Problem/Bug]: WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS wins over an explicit AdditionalBrowserArguments property · Issue #5571 · MicrosoftEdge/WebView2Feedback · GitHub
5. Chrome DevTools Protocol — remote debugging incl. `–remote-allow-origins` (Chromium ~111 handshake change): DevTools Protocol Viewer
6. Zinkevich et al., _*Regret Minimization in Games with Incomplete Information*_ (CFR, NIPS 2007): Regret Minimization in Games with Incomplete Information
7. Brown & Sandholm, _*Superhuman AI for multiplayer poker*_ (Pluribus, Science 2019) — blueprint self-play + real-time search: https://www.science.org/doi/10.1126/science.aay2400
8. Brown et al., _*Deep Counterfactual Regret Minimization*_ (ICML 2019): Deep Counterfactual Regret Minimization
9. Burch et al., _*AIVAT: A New Variance Reduction Technique for Agent Evaluation in Imperfect Information Games*_ (AAAI 2018): https://poker.cs.ualberta.ca/publications/aaai18-burch-aivat.pdf
10. Gilpin & Sandholm — potential-aware card abstraction; bet-size translation for off-tree sizes (CFR abstraction literature).
11. Project artifacts: `tools/texassolver/WHITEPAPER.md` (full reverse-engineering field report incl. dead ends), `SYNTHETIC_PIPELINE.md` (the measurement-first training pipeline this oracle plugs into).