Adds multi-core support for the four "iterate candidates, rank outcomes" features, with a user toggle (default: multi-core) and automatic single-core fallback.
Parallelized features:
- Passive tree node power / heat map / "Show Power Report" (
CalcsTab:PowerBuilder) - Compare tab power report (
CompareTab) - Anoint notable stat-sorting (
NotableDBControl) - Item DB stat-sorting (
ItemDBControl)
Workers are one-shot isolated Lua VMs started via the engine's existing LaunchSubScript primitive (no engine changes). Each worker bootstraps a headless PoB (HeadlessWrapper pattern), reconstructs the build from an in-memory SaveDB XML snapshot, computes its batch using the same per-candidate functions the single-core path runs, and returns one JSON payload (dkjson). The main thread partitions candidates, aggregates progress via subList calls, validates results, and merges by stable IDs.
src/Modules/ParallelRunner.lua— worker pool / job manager: launch, cancel, progress aggregation, baseline validation, per-session sticky fallback, dev-mode job log (src/parallel_log.txt)src/CalcWorker.lua— worker script (read viaio.open, passed as scriptText likeUpdateCheck.lua): hardened headless stubs, script-path-relativeio.openresolution (worker threads don't inherit a guaranteed cwd), env probe modesrc/Modules/PowerCalcTasks.lua— shared per-candidate compute logic + JSON number sentinels + baseline extraction, loaded by both main thread and workersspec/System/TestParallelTasks_spec.lua— equivalence tests: single-core vs worker entry points in-process, through a JSON round trip, for all four features; merge staleness tests
src/Launch.lua—OnSubErrornow notifies CUSTOM subscript callbacks ((nil, errMsg)— audited compatible with all existing users); nil-guards inOnSubError/OnSubFinishedfor aborted subscripts; dev-mode F7 = worker environment probe (report written tosrc/probe_report.txt)src/Classes/CalcsTab.lua—PowerBuilder(filter)optional candidate filter (nil = byte-identical legacy behavior);EnumeratePowerCandidates/BuildPowerBatches(modKey-grouped, weight-balanced batches carrying authoritativenode.path/node.dependsid lists); dual-pathBuildPowerwith duplicate-launch guard;LaunchParallelPowerBuild/MergeParallelPower(staleness viaoutputRevision)src/Classes/CompareTab.lua—ComparePowerBuildersplit into enumerate /ComputeComparePowerCandidate/ format-row so both paths share candidate computation; dual-path report runnersrc/Classes/NotableDBControl.lua,src/Classes/ItemDBControl.lua—ListBuildersplit into filter / measure / finish; dual-pathDraw; candidates keyed by node id / item namesrc/Modules/Main.lua—computationMode("MULTI"/"SINGLE", default MULTI) andworkerCount(0 = auto) settings, persisted inSettings.xml; Options popup UI; loadsParallelRunner/PowerCalcTasksas globalssrc/Modules/Build.lua— cancels outstanding worker jobs on shutdownsrc/Modules/DataLegionLookUpTableHelper.lua— standalone bugfix:loadJewelFilecould cache empty jewel data to<jewel>.binwhenInflateis stubbed (headless/busted), silently corrupting timeless jewel stats for the full app afterwards. Now rejects empty data, reads an existing.bindirectly whenNewFileSearchis unavailable, and never writes the shared cache from headless contexts
- Workers and single-core run identical code; results cross the thread boundary as JSON with
inf/NaN sentinels - Every worker returns a baseline stat vector recomputed from its reconstructed build; any relative deviation > 1e-3 from the main thread's baseline fails the whole job → automatic single-core fallback (sticky for the session, cleared by toggling the option or a successful F7 probe)
- Main thread sends each candidate's live
path/dependsnode ids: a spec rebuilt from XML resolves equal-length shortest-path ties in differentpairs()order, which would otherwise changepathPowervs what the UI displays - Results merge only if
build.outputRevisionis unchanged since the snapshot; any build change mid-job cancels/discards and re-runs - Worker errors surface once via
OnSubError→ fallback; workers never write shared caches - Headless/CI:
HeadlessWrapper.luastubsLaunchSubScript→ everything transparently uses the single-core path; no platform checks anywhere (pure feature detection)
spec/System/TestParallelTasks_spec.lua: 6/6 (all four features numerically identical through the worker entry points + JSON round trip)- Full existing spec suite: zero regressions vs pristine master baseline (same harness, failure sets identical)
- Cross-process fresh-VM E2E: 781/781 nodes bit-identical on a 3.13 test build; pass on a live 3.28 build with a Brutal Restraint timeless jewel
- Real engine: worker baseline probe exact match on all stats; heat map on a level-95 build computes 4,348 candidates in ~15 s (8 workers) vs ~48 s single-core
- Engine facts established via probe: subscript VMs receive funcList injections (incl.
GetUserPath) but notNewFileSearch/Inflate/Deflate; worker-thread cwd is launch-dependent (both handled inCalcWorker.lua)
- Single-core: ~48 s (one core busy)
- Multi-core, 8 workers (auto default): ~15–27 s depending on system load
- Fixed overhead per job: ~5–7 s worker bootstrap (one-shot VMs must load all game data + build) + ~1–2 s snapshot/merge — this is the Amdahl floor; raise
worker threadsto physical cores − 1 to approach it
- Persistent warm workers (engine change — removes the ~7 s floor).
LaunchSubScriptVMs are one-shot; every job pays a full bootstrap. A persistent worker/channel primitive in SimpleGraphic (send job → receive result → VM stays alive) would drop heat-map latency to pure compute (~3–6 s at 15 workers). Largest win, requires C++ runtime work upstream. - Result caching across tree toggles. Allocating then un-allocating a node currently recomputes everything twice. Caching the last few power-result sets keyed by a spec/state hash makes "undo a change" instant. Cheap, and directly targets the most common interaction.
- Trim worker bootstrap. Profile
timing.initMsvsbootstrapMs(already reported by workers): skip unique/rare item DB loading outside itemdb mode, skip tree versions the build doesn't use, lazy-load legion/tattoo data unless present, and cache Data modules as precompiled bytecode (string.dump). Realistic target: 7 s → 3–4 s. - Wave scheduling for stragglers. The job ends when the slowest worker finishes; per-candidate cost varies (masteries, long paths). Splitting into ~2× more batches than workers and launching a second wave as results return evens out the tail without engine changes.
- Stat-scoped calculation (benefits single-core too). Each candidate runs a full offence+defence pipeline even when the heat map only needs one stat (e.g. Life). A calculation mode that skips unneeded sections could cut per-candidate cost several-fold — invasive, touches
calcs.perform. - Parallelize the Timeless Jewel search. "Find Timeless Jewel" brute-forces thousands of seeds through the same kind of loop and is a notorious multi-minute wait — it fits the existing ParallelRunner/batch pattern well and may be the single most user-visible follow-up.
- Progressive merge. Color the tree per-worker as payloads arrive instead of waiting for the last one — no wall-time change, large perceived-latency change.
- Auto worker count tuning. Auto currently clamps at 8 for memory safety; scale the cap with detected physical cores and available RAM instead (e.g.
min(cores − 1, RAM_GB / 1.5, 16)).