How the code is organised, how to build and test it, and the rules the design depends on. For using the app, see the README.
Visual Studio 2026 with the C++ and CMake components (CMake 4.2 or newer, which knows the VS 2026 generator). From a Developer PowerShell in the repository root:
cmake --workflow --preset ci # configure, build Release, run the tests
cmake --preset default
cmake --build --preset debug
ctest --preset debug
cmake --preset asan # AddressSanitizer build in build-asan/
cmake --build --preset asan
ctest --preset asan # needs the MSVC bin\Hostx64\x64 folder on PATHOur code builds at /W4 /permissive- with warnings as errors; third_party/ is a system include. Everything is compiled with Control Flow Guard and SDL checks, and both executables are linked CET-compatible and carry an application manifest.
src/core/ pure logic: no windows.h, no driver calls, no D3D. Unit-tested in CI.
types.hpp Telemetry, GpuControl (the hardware seam), AppliedState
objectives.* the four profiles as data
config.* gao.json round-trip
stress_math.* exact-float stress inputs and the CPU reference result
stability.* the stress run loop and its verdict
journal.* write-ahead log of clock candidates; a freeze becomes a ceiling
search.* the tuner: baseline, power, memory, core, soak; the recovery after a driver reset
boot.* when a logon apply may run (strikes, driver, card) and the verified apply
retest.* the saved tune tested again on a new driver: five minutes, then applied or stock
watchdog.* the tray app's decisions: re-apply, give up, back off, driver changed
task_xml.* the logon task definition
update.* reading GitHub's "latest release" answer; version numbers
vf_curve.* the voltage/frequency curve: its points, the flat top, a write that reads back and corrects
undervolt.* the automatic undervolt search: the stock load clock on the lowest stable voltage
fan_curve.* fan curves, the per-second controller and the FanDriver
src/hw/ the only code that touches hardware or the OS state folders.
nvml.* telemetry and power limit via NVML
nvapi.* clock offsets via NVAPI, verified by read-back
gpu_control.* wires NVML and NVAPI into GpuControl
stress.* the DX11 compute load; the GPU checks every value it computes
app_files.* gao.json (atomic writes), the crash journal and boot.log, flushed to disk
boot_task.* the logon task and the Program Files copy
nvidia_app.* whether the NVIDIA app's Automatic Tuning is on, read from the driver's settings database
update_io.* what an update needs from Windows: HTTPS download, SHA-256, unzip, file version
src/app/
common.* logic both programs share: optimize, logon apply, apply-at-logon on/off
main.cpp gao.exe, the command line
gui/ GpuAutoOptimizer.exe: window, tray, optimize worker thread, watchdog loop
tests/ doctest, core only
third_party/ doctest, nlohmann/json, Dear ImGui: vendored as source, no package manager
src/core/objectives.cpp defines each profile as a thermal ceiling, a perf push (the fraction of the highest stable offset that is applied) and which of core, memory and power tuning it enables:
| Profile | --optimize |
Max temp | Perf push | Core | Memory | Power |
|---|---|---|---|---|---|---|
| BestOfMyGpu (default) | best |
75 °C | 0.7 | on | on | on |
| Quiet | quiet |
80 °C | 0.4 | on | on | on |
| CoolAndEfficient | cool |
65 °C | 0.3 | off | off | on |
| MaxPerformance | max |
83 °C | 1.0 | on | on | on |
The applied offset is always at least one step below the confirmed edge. Profiles with a perf push below 0.5 also look for the lowest power limit that costs less than 2 % score.
| Decision | Why |
|---|---|
| NVAPI and NVML directly, not MSI Afterburner | An earlier version drove Afterburner by editing its profile files; the edits never reached the hardware, and Afterburner offers no per-step read-back, which the whole search depends on. |
| Instability = a wrong result or a lost device | A GPU computes wrong before it crashes, so a self-checking compute load catches instability earliest and cheapest. A TDR (DXGI_ERROR_DEVICE_REMOVED) is recoverable, unlike a freeze. The load's iterations per second double as the score. A third way to fail is STALLED: no wrong value and no lost device, but almost nothing computed (see the STALLED row). The search takes a lost device, a stall and lost telemetry as a driver reset, not as proof that the candidate itself is unstable: another program, or a forced reset in a test, gives the same verdict. |
| Memory stops at the bandwidth peak | GDDR6/GDDR6X retry failed transfers, so memory overclocks lose bandwidth long before they return wrong results. |
| Without the bandwidth measurement the memory is left at stock | The search used to fall back to a climb on stability alone when the measurement failed at +0. On a tester's 3080 Ti (0.4.3) that climb went to +2300 MHz with the score falling from +2150 on, got its first wrong result there, and the machine froze with a corrupted screen in the 30 s confirm probes just below it. The first wrong result is no edge to stand next to: the memory was correcting errors for hundreds of MHz before it. Now a measurement that does not work means no memory overclock (left_at_stock), the run says so, and the core is still searched. optimize keeps the stability climb only for a caller that gives no measurement at all; the app always gives one. Why the measurement failed on that card is not known: the log was only in the window and went with the reboot. |
| A confirm probe that is too hot at once confirms nothing | A confirm probe that ends TOO HOT keeps the clock edge: over 30 s the card may run a little hotter than over the power step's 20 s, and that is the soak's business, which lowers power. But the stress run stops at its first reading above the limit, so a card that is above it when the probe begins gives TOO HOT within a second, and that counted as holding too. It happened on the tester's RTX 3080 Ti (hardware check 91): after a driver reset the health probe loads the card at stock power, which on that card means 76 C against a limit of 75, and confirm core +225 was confirmed by a probe that had tested nothing. Now such a probe counts only when the clock was loaded for kConfirmMinS (10 s, clearly longer than the 3 s probe that found the edge) before it got too hot. Otherwise the value is not confirmed, and the card rests (io.rest, the 20 s of a recovery, with the fans where the curve last put them) before the next try one step lower: without the rest every lower value would start just as hot, and heat alone would step the edge down to nothing, which is what keeping the edge on TOO HOT was for. The same value is not tried again; one step is the price. Not changed: the health probe still runs at stock power, and a soak that begins too hot still lowers the power limit by a step. |
| A profile that promises speed keeps no result that is slower than stock | Best of my GPU ended 3.8 % slower than stock on the tester's RTX 3080 Ti (4749 against 4937 it/s, hardware check 91), with the fans at 80 % instead of 66 %, and showed it as Done, applied and kept at every logon. Every step had done what it is for: the card runs 83 C at stock with its fans at 100 %, the profile allows 75, so the power limit went to 65 %, and +150 MHz of core clock did not win that back. But a profile called "the best of my GPU" that leaves the card slower has not done what it says, and a result like that is not offered as one. After the soak, optimize compares its score with the measurement at stock that went before the run (the "before" the result screen shows, so the rule judges by the number the user sees): below kNotSlowerScore (0.99: one percent is what two five-minute runs differ by) the run ends as a failed one does, at stock, with nothing saved, and says what it found, why, and that Undervolt is the profile for a card that runs warm. Objectives::faster_than_stock marks the two profiles this holds for, Best of my GPU and Max performance. Not Quiet and Cool & efficient, which trade speed for noise and heat and say so. Not the overclock step of All in one either: it goes on to an undervolt and a fan tune, and its result is judged as a whole; whether that whole may be slower than stock is not decided. This is a backstop, not the cure: what such a card needs from these profiles is in docs/planned.md ("never hotter than at stock"). |
| The memory search has two passes, and remembers where a card's memory gave out | What went wrong. Four runs on one day applied +650, +1600, +550 and +100 MHz on the reference RTX 4070, a card that runs every game at +1500 (hardware check 94). That card has no bandwidth peak: bandwidth rises by about 0.4 % per 50 MHz step all the way to +2500 and the stress load computes its first wrong result at +2550. So the scan could only end early by chance, and where it did decided the offset. The cause, found late. The chance came from another program on the card: an editor stood open all that day and took 2.2 % of the 3D engine, and with it the bandwidth readings scattered by 0.4 to 1.3 % and changed level by 1.8 %. On a card that is left alone they scatter by 0.2 % (hardware check 96). A run now looks for such programs before it begins and asks (the row on other programs on the card). What is left for the search to do is to give a sound result also when the user goes on next to them, and not to go to the memory's edge more often than it has to. A detour, taken back. The first answer to the scatter was a timed run four times as long (128 GB copied in one go). It was steadier next to the editor, and no steadier than the short one on a quiet card. With it every scan ran to where the memory gives out and met it with that copy: five scans out of five lost the device there, at +2450 or +2500, and the fifth took the whole machine down within seconds (check 95 b to d). In the one scan before that got to the edge, the short copy read a number at +2450 and +2500 and the stress load reported the wrong result at +2550, with no reset. The long run is gone; kBwDispatches is 64 as it was. The design. The scan goes up and only has to find where it ends: a step that is not stable, or one more than kBandwidthDrop (5 %) below the best. Two things keep a single reading from deciding: a reading that would end the scan is taken kBandwidthReadings (3) times and the middle one counts, and the best that readings are held against is the highest middle of three steps in a row, not the highest reading (the highest of fifty scattering readings is always one that came out high). 5 % is far more than readings move by themselves, also next to another program; the scan is not where the offset is chosen any more, so it can afford to be coarse. The second pass chooses the offset: it starts kPeakMarginSteps (4 steps, 200 MHz) below the highest step the scan passed (second_pass_top), reads again a step at a time going down until a reading is more than kBandwidthTie (1 %) below the best of them, and keeps the lowest offset within that 1 % (bandwidth_peak_below). On the reference card: the scan passes +2500, the second pass reads +2300, +2250, +2200 and +2150, and +2200 is kept; confirmed there, 70 % applied, +1500. Whatever the readings do, the offset comes out of a few steps below a point the card itself sets, so it repeats: +2050 to +2300 at the scatter of a quiet card. The margin is kept whatever ended the scan, a drop and the driver's own range included: what is above was never tried. After a reset event in the scan the second pass is skipped, and confirming starts four steps lower still. The edge is met once. A scan that ended in a wrong result or a reset event writes that offset to the journal with the GPU id and the driver version ({"mem_edge":2550,"rig":"<GPU id> / <driver>"}, a line without an id, so older builds skip it). A later scan on the same card and driver stops four steps below the lowest such offset, and says so; its second pass starts where the first run's did, because the margin counts from the edge and not a second time from where the scan was held. With another driver or another card the line does not count. A scan that ended for another reason (a bandwidth peak, readings that did not settle, the driver's range) writes nothing. What it costs. A card that does have a peak keeps an offset up to 200 MHz below it when the scan ends close above the peak: the margin does not know a drop from an edge. A scan that ends for a harmless reason (readings that did not settle) loses those 200 MHz as well. The second pass adds about four steps, a quarter of a minute. Not chosen. The long copy, anywhere: see above. Three readings at every step: two minutes a scan, and it decided nothing a single reading did not. Only remembering the edge: the result would still be chance for whoever goes on next to another program. The stress load probing four steps ahead of the copy: there is no heavy copy left to protect the edge from. A fixed top for the scan (an offset, a share of the range, a gain over +0): there is nothing to choose the number by. Tested on a simulated card with the reference card's numbers (wrong result at +2550): the second pass never reads above +2300, in the first run and in the one after it; with readings scattering by 1.3 % in both passes and level changes thrown in; with the edge anywhere from +300 to +2900; and the tests fail when the margin is set to nothing. On the card: one run, with the edge already in the journal (hardware check 95 f): as designed. See the row below for what that leaves. |
| Open: the first meeting with a memory edge has not been seen with the two-pass search | The search as it is now ran once on the reference card (hardware check 95 f), on a quiet card and with the edge at +2450 in its journal: the scan stopped at +2250, the second pass read +2200 down to +2050 and kept +2100, +1450 was applied, there was no reset, and the result was 3.1 % faster than stock. That is the run after the one that finds the edge. The run that finds it has not been seen: on a card whose journal has no edge the scan goes up until the stress load computes wrong or something gives, with the same copy the released versions use; that ended in a wrong result the one time it was seen before (check 94), and seeing it again means removing the edge from a journal and going there. Still not known. Whether a card with a real bandwidth peak behaves as assumed; none has been measured through a scan (the tester's RTX 3080 Ti never got a reading to settle at +0). And why the long copy failed at the edge where the short one did not: the amount copied in one go, or only that five runs are more than one. It no longer matters for the search, and is noted because it was never explained. |
| Open: why the bandwidth measurement fails on some cards, and what then | On the tester's RTX 3080 Ti it failed at +0, and since 0.4.4 that means no memory overclock at all on such a card, though it computed right far beyond +2000 MHz. The measurement gives a number only when three runs in a row agree within 1 % (kBwSettle), and it throws away every run the driver's timer calls disjoint (the clock changed under it). That card was held at a power limit of 65 % and ran at 83 C when it was measured; the reference card has only ever been measured at 105 %, where the measurement itself draws 179 of 210 W. So the suspicion is the power limit, and hardware check 90 tests exactly that on the reference card. What it shows decides what is built, and nothing is built before it: (1) runs are thrown away as disjoint, and the ones that are kept agree: the timer is what fails, not the card. Time the copy differently (wall clock around a flush, or count a disjoint run when its neighbours agree) and keep the method. (2) the readings themselves scatter by more than 1 %: a peak that is found by a 1 % drop (kBandwidthDrop) cannot be found in that noise. Then the memory has to be measured where the card is not held back: search it before the power step, at stock power, where a probe and its measurement last five seconds; or take more readings and compare medians. (3) it does not fail at 65 % on the reference card: the power limit is not the cause. Then it was the heat (a card throttling at its temperature limit changes clock as well), or another program, and only the tester's run.log can say: since this change a measurement that never settled writes how many runs gave a reading, between which values, and how many were thrown away. In no case does the climb on stability alone come back. Outcome of check 90: (3). On the reference card the measurement gave a number fifteen times out of fifteen at 100, 80 and 65 %, in three or four runs, with nothing thrown away, and the bandwidth was the same at every limit. The power limit is ruled out; (1) and (2) are not built. What is left is the heat of that card, another program, or a timer query that failed, and the tester's run.log is the next thing that can tell them apart. The tester's run (check 91): it is (2), but only inside a run. Twenty readings between 763.9 and 821.2 GB/s, none thrown away, three seconds after the load began; after thirty seconds of load the same card read within 0.2 %. The suspicion now is the moment, not the card: the measurement is taken while the power limiter is still catching up. If gao --bandwidth --after-load 3 scatters on that card and --after-load 30 does not, that is it, and the fix is to measure when the card has settled (keep taking runs for some seconds instead of twenty runs), not to change the method. |
Every line of a run is also in run.log |
logged() wraps the log hook of both front ends: a line is appended and flushed before it is shown, so the step a machine freezes in is the last line on disk. One file in the state folder, moved aside at 1 MB (run.log.old). Only elevated and only after ensure_app_dir has verified the folder: an elevated process appends to nothing a user could have put there. The measurement at stock writes a trace, so that one ordinary run and its run.log answer what used to take a separate measurement by hand: a line at the first reading and one every ten seconds after it (TraceClock, trace_line), and one for the last reading when the measurement ended between two: 0:10 1716 MHz 78 C 285 W fan 61 %. The readings are the ones the measurement takes anyway; nothing of it changes. The summary line gives one clock and one temperature, and whether the clock fell as the card warmed up is in between: that is the question the tester's RTX 3080 Ti raised, which runs 1710 MHz at 288 of its 350 W at stock. Before the trace, the card's own temperature target (temp_target_text), in every run whatever the profile: the card's own temperature target: 84 C (its default), or 77 C, NOT its default of 83 C: another tool has set it ... when it is not. A tool like MSI Afterburner leaves its temp limit on the card when it is closed, and the card then slows itself down from that temperature on: the measurement is not one of the card at stock, and the log has to say so because nothing else does. The target comes from NVML; its default from NVAPI's thermal policy information (Nvapi::TempTargetDefaultC, an undocumented read that answers with the minimum, the default and the maximum: 65, 84 and 88 C on the reference card). Where the default is not to be had the line gives the target alone. |
| A run does not start while the NVIDIA app's Automatic Tuning is on | Switched on, a service of the driver scans the card when it sees fit: it loads it, changes its clocks, and starts again by itself (hardware check 89: a scan that was stopped started again 14 ms later, and after a restart of the service it resumed within two seconds). A run next to that measures nothing it can trust, so it is refused, like a run next to Afterburner, and with the same weight: a courtesy, not a guard. Read from where that service keeps the switch, %ProgramData%\NVIDIA Corporation\nvtopps\nvtopps.db3, table ocScannerSettings, fields backgroundScanEnabled and grdOcEnabled, joined to TelemetryGpuCfg so that only a card that is in the PC counts (a row of an earlier card can never be switched off again). That this is the source and not a copy of it was measured, not assumed: with the field set the service took the scan up again after every restart, with it cleared it did not, while the NVIDIA app itself showed the switch as off. Nothing documents the file. So: opened read-only and immutable (no lock, the service never waits for us), through winsqlite3.dll from System32 (no dependency added), and everything that is not exactly as expected reads as "unknown", which starts the run as before. The registry value the app sets next to it (HKLM\SOFTWARE\NVIDIA Corporation\Global\NvApp\OC\<user SID>) changes at the same moment but belongs to the app, not to the service, and is not used. The overlay is not checked: no setting for it was found, its processes run whether it is open or not, and the one value that moved (IsShadowPlayEnabled) followed the service being up, not a switch. NVAPI was not tried: an undocumented call is no better than an undocumented file, and it cannot be compared with anything. |
| A run does not start next to MSI Afterburner or RivaTuner | other_tool_refusal, asked before anything else in run_optimize, run_undervolt, run_all_in_one and run_retest, so the window and gao say the same. Afterburner sets clocks and fans itself; RivaTuner hooks into every program that uses the card, the stress load among them. By file name (MSIAfterburner.exe, RTSS.exe), which is all a list of processes gives; a list that cannot be read counts as "none runs", because the run's own read-backs and the watchdog are what it relies on. Not the cause of the freeze above: on that PC Afterburner was closed and RivaTuner not installed. |
| In the undervolt profile the fan curve moves up with the temperature limit of a card that runs hot | An undervolt is asked for by people whose card is hot and loud, and the first one that worked on the tester's RTX 3080 Ti made it louder: 285 to 231 W and 86 to 74 C, with the fans at 93 % where the driver's own control had held 66 %. The search already ran with the limit that card has: it reaches 86 C at stock, so candidates were stopped at 85 C and not at the profile's 75 (temp_ceiling, the rule of docs/planned.md). The fan controller did not follow. It was still built with 75, and a curve that is drawn against 75 reaches 100 % there, so the fans held a card at 74 C that had never been that cool, and spent the whole gain of the undervolt on it. No curve on the Fan page could change that: the 100 % from the limit on is the controller's, not the curve's. Now the undervolt run gives the fan controller the same ceiling as the search, and moves the curve up by as much as the ceiling is above the limit the curve was drawn against (shifted_curve): the fan-stop threshold and the first point stay where they are, so the card idles as before, and every point above moves up by the difference. Normal becomes 50 C 35 %, 70 C 45 %, 80 C 65 %, 85 C 100 %; at 74 C that asks for 53 %. The ceiling is saved with the profile (temp_ceiling_c, written only when it is above the preset's limit) and profile_ceiling is what the fans, the Fan page, the fan tune and the test of a saved tune use from then on; the moved curve is saved as the tested one. The Fan page's presets are offered moved up by the same amount, and the page says why its red line is not at the profile's limit. The top of the ceiling is the card's own temperature target, where the card reports one: the temperature from which it slows itself down instead of running its fans faster (Nvml::TempTarget, nvmlDeviceGetTemperatureThreshold with the acoustic threshold in force, read without administrator rights when a run begins; GpuControl::temp_target_c). On the reference RTX 4070 it reads 84 C, with 90 C as the maximum operating temperature, 94 C where the card slows down hard and 99 C where it shuts down; the memory's temperature is not reported. A run has no business above the target, and fans that reach 100 % there keep the card from getting to it; an owner who set a lower one with another tool has it respected without being asked: with 77 C the Normal curve moves up by two degrees, not by ten. The fixed 85 C stays where the card reports no target or the call fails, so nothing changes there; it also stays the top when the card's target is above it, since the measurement at stock stops at 85 C and nothing above that was seen. A target below the profile's own limit does not lower that limit. gao --status shows the target, and whether it is the card's default. Assumed, not confirmed: that this threshold is what MSI Afterburner calls the temp limit. The ranges agree (65 to 88 C on the reference card in both), and it would explain the tester's card, which ran its fans at 66 % at 86 C and only 1710 MHz at 288 of its 350 W: a card above its target slows down and stays quiet. It takes nvidia-smi -q -d TEMPERATURE on that card, with his limit of 77 C applied and without, to know. A curve is moved once. What it is moved by is the ceiling minus the limit it was drawn against, and a curve saved by a run on this card was drawn against that run's ceiling: the run after it moves nothing. On a card that stays under the profile's limit nothing changes, not a number and not a byte of gao.json: the ceiling is the limit, the shift is zero, and the field is not written. A lower ceiling moves nothing down: when the card reaches less at stock than the ceiling its curve was drawn against, the curve stays and the fans go to 100 % from the lower ceiling; louder, and on the safe side. Not in this step: the profiles that promise speed still run with their fixed limit and their curve as drawn (the rest of the design in docs/planned.md); and nothing measures how loud the result is. On the tester's card the moved Normal curve should settle near 80 C at about the 65 % the card ran at stock; that is an estimate from two points, and whether the tune holds at that temperature is what a test of it has to show. Ending the undervolt profile with the fan tune the all-in-one run has is the next step, and is not built. |
| Before a run the tool looks at what else uses the card, names it, and asks | A run measures the card under full load, and every other program on the card takes from what it measures. That was found the long way: for a whole day of card tests the reference RTX 4070 scored about 5300 it/s in the stress load and read 444 to 455 GB/s of memory bandwidth, scattering by 0.4 to 1.3 %, until an editor that had stood open since the morning was closed; from that second on it read 480 GB/s and 5750 to 5830 it/s, steady to 0.2 % (hardware check 96). The editor was idle and used 2.2 % of the 3D engine. A video playing in a browser makes the readings jump by 3 to 8 % from one to the next. Nobody thinks of an open editor as a heavy program, so telling people to close heavy programs does not reach it; the tool has to look. How. Windows keeps the card's use per process and engine (the GPU Engine performance counters, what Task Manager shows); gpu_use in src/hw/gpu_users.cpp reads them for three seconds, for the NVIDIA adapter only, without elevation. busy_programs in src/core/busy_card.cpp adds a program's processes and engines together and names what is at kBusyPct (0.5 %) or more: programs that only stand there read 0.01 to 0.05 %, the editor 2.2 % in one look and 1 % in another. Never named: the tool itself, and what Windows needs to draw the desktop, which nobody can close. A reading outside 0 to 100 % is left out; the counters give one now and then. It asks, it does not refuse. Another tuning tool writes to the card and a run is refused next to it. These programs change nothing on the card; they only make the numbers worse, and the user may have a reason to keep one open. So busy_gate asks through OptimizeHooks::busy: the window shows the list with Check again, Go on anyway and Cancel; the command line prints the list and does not start, unless --busy-ok is its last argument. The all-in-one run asks once, not in every step. Nothing is closed by the tool: a program may hold work that is not saved. It is always written down. Every look goes to the run's log (other programs on the card: Trae (2 %), or none), also when the user goes on, so a log says what the card was shared with. That line would have saved the day this was found on. A second look after an optimize run, when the load is off the card again: when the programs differ from the ones before it (busy_change), the result carries a note that its numbers and the ones at stock were not measured on the same card. That is what happened in the run the editor was closed under: stock at 5321 it/s, the scan from then on at 5760. Limits. The look is taken while the card idles: under the run's own load the other programs' share reads near nothing, so the tool cannot watch during a run, only before and after. A program that is busy only now and then can be missed by a three-second look. An application that draws through an embedded browser is named as that browser (msedgewebview2), not by its own name; the process that owns it would have to be looked up. The second look is not built for the undervolt, the all-in-one and the retest runs. And where Windows does not give the counters, nothing is found and nothing is said: this is advice, not a check a run relies on. gao --busy shows what a run would find. |
GpuAutoOptimizer.exe --demo plays a run in the window |
Looking at a change to the run screen used to mean sitting through a real run: five minutes at stock before the first step, another five for the soak, administrator rights, and a loaded card. --demo plays one instead (OptimizeWorker::start_demo): the question about other programs (fewer of them every time it is asked again), every step with its countdown, the lines of the log, and a result, in about a minute, with made-up numbers; --demo=run begins at the steps. It goes through the same hooks and the same screens as a real run, so what is seen is what the window does. It cannot reach the card: it refuses to start with administrator rights, and without them nothing in the window can write. It stands next to the copy in the tray under a window class of its own (the command line and an update find the real app by its class), has no tray icon, starts no update check, and ends when its window is closed. The result it shows says that it was a demo and that nothing was saved. Not a test of anything the run does; only of how it looks. |
| Edges are confirmed, then backed off | A 3 s probe can pass by luck; the edges get 30 s probes before the safety margin is applied, and the result must pass a 300 s soak. |
| The search climbs to a per-card bound | A fixed +300 MHz cap decided the result on cards that hold more. The bound is the offset range the driver reports, accepted only when it is plausible (spans stock, contains the applied offset, below a sanity limit). Bisecting such a range would start hundreds of MHz too high, so the search climbs from stock. |
| Both searches go one grid step at a time (15 MHz core, 50 MHz memory); nothing is bisected | The first value that fails is then exactly one step above the last one that passed, for an ordinary failure and for a driver reset alike, and the climb probes nothing more after a value that failed or reset the driver. The confirm probe then runs one step below an ordinary failure (at the last value that passed) and, after a reset, four steps below the last value that passed. The earlier scheme, 60 MHz strides and a bisect after the first failure, could leave the last passing value up to 45 MHz below the edge and bisected right below a value that had just reset the driver. The cost is more probes and a longer run; how much longer has not been measured. |
| Memory is searched before the core | The first driver reset ends exploring (see the reset budget row). The assumption, not measured, is that a reset is far more likely in the core climb than in the memory scan: memory normally loses bandwidth long before it fails, and both resets seen so far came from the core. With the core first, its reset would leave the memory untuned. The core climb runs with the chosen memory offset applied; memory is tuned at stock core clocks, and the combination is proven by the soak. If the memory search itself has a reset, the core is still searched (seen on the reference RTX 4070: a memory scan ended in a reset, the core climb ends in a wrong result), and the run can then take one more reset. |
| The optimize run reconnects after a driver reset | The edge is found by crossing it, and past it the driver resets. A call into NVML faulted after a reset, in the tray app (hardware check 33) and in an optimize run (check 51), so the run owns guarded connections: a fault inside a driver DLL becomes a failed call, a reconnect re-creates both libraries, and it never happens with a journal entry open. A fault that still escapes ends the run with the entry closed as CRASHED, and the card is then reset to stock. |
| One NVML session for the process, and the power limit and the fan speed are asked first | On driver 617.42 the clock, temperature and power readings come from memory the driver shares with the process. A driver reset takes that memory away, the library goes on reading it, and every such call is an access violation from then on (hardware check 84); one that was caught leaves nvmlShutdown waiting for ever (check 80). Only a complete nvmlShutdown and a new nvmlInit bring the memory back. The calls that ask the driver instead answer a reset with a status, so device() in nvml.cpp makes two of them before every other call on a card: when one that worked before says the card is gone, nothing reads, and two seconds later the session is stopped and started again. nvmlInit is counted by the library, so all Nvml objects share one session: with one each, the window's would keep the stale session alive under the reconnect of a run. |
| After a driver update the tune is tested again, not found again, and only on a click | A tune belongs to the driver it was tested on (decide_boot), and a new driver can change what is stable: 617.42 did. Finding the tune again takes a whole run; whether it still holds takes the five minutes a result is tested for anyway (retest_saved_tune). Stable: the profile is stamped with the new driver and stays applied. Anything else: stock, and the profile is not touched, so it stays refused. The test never starts by itself: at logon the app only says that the driver was updated and the tune is not applied, and the dashboard has the one button. Five minutes of full load that nobody asked for, in the middle of whatever the PC is doing, is not what this app does. The fans are as in daily use, on the app's curve or with the driver, because that is what the tune will run with. What is planned on top of this is in planned.md. |
A probe that computes almost nothing is STALLED |
Past its edge an RTX 5070 passed a probe at 3 % of its baseline score with no wrong value and no lost device (hardware check 51). Below a quarter of the baseline a "stable" probe is STALLED. A running probe ends as soon as its last three seconds fall below that floor, so a 300 s soak does not keep loading a card that stopped computing; three seconds, so that one delayed batch does not look like a dead card. Probes shorter than that are judged after they end. |
| A clock search stops exploring at its first driver reset; a run ends at one more than its searches may have | Nothing can guarantee that a probe will not reset the driver, so resets are counted. What counts: a probe that ends DEVICE LOST, STALLED or NO TELEMETRY; a candidate, power or soak write that fails; a failed bandwidth measurement on an overclocked candidate, unless the load measured and its readings only did not agree (other programs using the card): that ends the memory scan at the best value measured and counts as nothing. At the first one in a clock search that search ends at the last value that passed (in a bandwidth scan: the bandwidth peak of the values that passed), confirmation starts four steps lower, and a reset during a confirm probe or the soak moves the next try four steps down instead of one. Each clock has its own search: after a reset in the memory search the core is still searched, because on the reference RTX 4070 a memory scan ended in a driver reset at +2550 MHz while the core climb ends in a wrong result, and skipping the core left that card with no core offset at all (hardware check 52). A run allows one reset, and two when the first came in the memory search and the core is searched after it; at one more the run ends at stock, nothing saved, nothing else started. With no clock offset applied (the baseline, the power step, a soak at stock clocks) there is nothing to back off to, and the first one ends the run. Only DEVICE LOST proves a reset; the others suggest one, so gao can count a reset the Windows log does not show. |
| After a reset: rest, then prove the card is back | In round A of hardware check 51 the first probe after a forced reset, at core +30 and about two seconds after the driver restarted, scored 325 it/s at 45 W and was judged STALLED; the desktop was unusable afterwards. Why that probe scored so low is not known: in the build round A ran on, the stress load re-created its D3D device and recompiled its shader in the first batch after a lost device, and that time was counted in the probe's score; the card itself may or may not have been back. So before any further candidate the search reconnects, writes stock, leaves the card without load for 20 s, and runs a 5 s probe at stock that must be STABLE at 90 % of the baseline or better: up to three such probes, a rest before each. A card that does not come back ends the run, and so does a reset during that check: no step of the recovery starts another recovery. The numbers are first choices, not measured. |
| Known limits of the health check | After a reset the stress load is rebuilt once, after the rest and before the health probe, outside its timing: it creates a new D3D device and recompiles its shader, so the probe's score no longer includes that. A rebuild that fails counts as a failed try of the health check (the rest and the check are tried up to three times) and its reason is logged (the stress load could not be rebuilt: ...); a fault during the rebuild ends the run through the crashed path (journal entry closed, card reset to stock). The load is rebuilt even when it did not notice the reset itself. Not yet measured on hardware. Still unknown: how long the rebuild takes after a reset (gao --stress-recreate measures the rebuild on a healthy driver only), whether the first health probe then passes at once, and whether the rest keeps the desktop usable. |
| Nothing about a reset is kept for later runs | A reset says nothing certain about the candidate, and a stored one would let a single invalid probe lower the bound for good. The cost: a run on a card it can push past its edge crosses that edge again every time. Only a freeze is remembered, through the crash journal. |
| An entry at stock clocks never becomes a ceiling | A ceiling of zero would mean "never search this clock", which no freeze at stock can justify. The soak and the +0 sample of the memory scan do not journal a clock that is at stock, and an open entry with an offset of 0 is reported as a freeze but lowers nothing. |
| No voltage lock | Locking a voltage point hard-froze the reference RTX 4070. An undervolt is a shape on the curve instead (see below). |
| An undervolt is a profile, in place of an overclock | gao --undervolt and the Undervolt card save a profile with the preset undervolt, stock clocks and power, and the flat top: the anchor's voltage (the card's own number; a slot is the driver's), the clock, and the raise it was tested with. A core offset and a flat top are one table on the card (hardware check 65), so apply_profile refuses a profile that has both, and saving one replaces the other. Applying looks the anchor up by voltage and refuses, at stock, when the curve has no such point or the point would have to be raised more than it was tested with plus what the built-in curve moves with temperature. A profile whose undervolt cannot be read whole is no profile: read without it, it would apply as plain stock and look applied. A build from before this reads the unknown preset as no profile too. |
| A flat top is judged by the curve, not by the offset read-out | The offset read-out reports core +0 with a flat top on the card (hardware check 66). curve_state tells stock, a flat top at the saved voltage and anything else apart by the raw offsets alone: frequencies move with temperature. The watchdog, gao --status and the dashboard use it. Every apply removes a flat top first, because an offset write that asks for what the driver already reports may change nothing. |
| The watchdog counts resets, and gives up at stock | A driver reset removes a core offset and a flat top alike (hardware check 68), and the watchdog re-applies. A reset that leaves the tune applied counts the same: the tray app sees driver resets through its display device and passes them on, so a tune the driver kept across its resets is still found unstable. A search's own resets are not counted. On the fourth within an hour the card is set to stock, not left with whatever the driver kept. |
| The points above the anchor are stored lower than it | The built-in curve moves with temperature, each point by its own step. A top written exactly flat ran in two ways on the warm reference card with the same offsets: on the anchor's voltage one clock step lower, or on a higher voltage holding the clock, for 12 to 14 W more (hardware check 72). The points above the anchor are therefore stored kVfTailDropKhz (45 MHz, three steps of the clock grid) below it, which makes the anchor the highest point whatever the temperature does. The card reports for every point the most that it or any point below it runs, so those points keep reading the anchor's frequency and cannot be checked by it: apply_flat_top writes the top flat first and reads it back there, as before, then lowers them in one write and checks that the offsets were stored, that the anchor still is on the target and that nothing reads above it. Measured: 155 W instead of 166 W in the soak, for a clock that is one step lower on the warm card. gao --curve-flatten <mV> <MHz> 0 still writes the exactly flat top, for comparisons. |
| An overclock and an undervolt are two ends of one line | An overclock is the same offset on every point of the curve: at every frequency the card then runs on less voltage than it did, which is what an undervolt is. With the same margin on the raise there is nothing left to add: on the reference card the undervolt searched on top of core +135 saved 5 W of 201 (hardware check 74). So the two do not stack. What can be chosen is where on the line the card runs: the overclocked clock, the stock clock, or a clock in between. |
| A profile can carry both, as one shape | The core offset and the curve are one table on the card (hardware check 65), so an undervolt next to an overclock is one curve: the core offset on every point below the anchor, the anchor at the target, the points above it lower. apply_flat_top takes the offset as base_raw, levels the curve to it and keeps it below the anchor; apply_profile sets power, core and memory and writes the flat top over the core offset; curve_state tells that shape from the bare offset and from stock. Every raise is counted over the built-in curve, offset included: in the journal, for the margin, and in the profile's raise_khz. The offset read-out keeps reporting the core offset under the flat top (hardware check 73). |
| All in one takes the middle of the line | One run for a card nobody has tuned: five minutes at stock; the overclock of Best of my GPU; an undervolt on top of it at a clock half-way between what the card ran at stock and what it runs with the overclock (UndervoltBase::clock_khz); the fan tune; five minutes with everything applied. The half-way clock is a choice, not a measurement: it is the point where the result is better than stock on both counts, speed and power, which is what one button for everything should give. Wanting all of the speed is Best of my GPU or Max performance; wanting all of the saving is Undervolt. When the margin allows no raise beyond the overclock, the overclock's curve cut off at that clock is the result: at a lower clock the cut alone already means a lower voltage. A step that fails keeps what the steps before it found; a stop request ends at stock. The steps are the existing runs called in a row under one tuning lock, not a new search. |
| The fans are tuned by measuring, last | tune_fan: under the load of the finished tune the fans are set by hand, the temperature is given time to settle (within 1 C over three looks 15 s apart, three minutes at most), and the speed goes down 5 % at a time until the card settles above the target, 5 C under the profile's limit. The lowest speed that held is one point of the saved curve; full speed sits at the limit. It runs after the clocks because the heat to carry away is the tune's. The final five minutes then run on that curve: a tune that only held with louder fans does not get the quiet ones, and keeps the profile's own curve. |
| One click is all of it | A run from the window that ends saved switches apply-at-logon on itself (UiState::keep_at_logon, a ticked box on the Optimize page while it is still off): the promise is that the user presses one button and is done, and a second switch to find afterwards breaks it. It is a box and not a silent default because it copies the app to Program Files and registers a logon task. The command line does not do it: gao --boot on stays its own command. |
| The run draws the curve | During a run from the window the telemetry tick reads the curve back once a second and the run screen draws it over the last read at stock: the search's own writes, not a picture of the plan. It is a read from the window's own driver connection, like the applied-state read that was there already. |
| A card that reaches 85 C at stock is measured until then, and the run goes on | The measurement at stock leaves the fans to the NVIDIA driver, because that is the "before". The driver's fan control is not ours to judge by our limit: a 3080 Ti Founders Edition reached 85 C under this load with its fans at 65 %, and every run ended before it began with the card is not stable at stock (TOO HOT), whatever fan curve was chosen for the run. Nothing was unstable. Now the measurement stops at kStockTempC as before, what it measured is kept (the result says how long it lasted and why), the card cools for at most a minute without load, and the run starts (stock_refusal). Shorter than kStockMinS it measured nothing: the card was hot already, and the run asks for it to cool down. No step lets the card get hotter than it did before this change. |
| The undervolt profile measures stock as every other run does, and does not stop a hot card's candidates for being hot | The undervolt profile had a stock measurement of its own: five minutes with the fans on the curve and the profile's 75 C as the limit, and anything but STABLE ended the run with the card is not stable at stock. That is the mistake of the row above, left standing in a second place: the tester's RTX 3080 Ti runs 82 to 83 C at stock even with the fans at 100 %. Now run_undervolt starts with measure_stock, the one measurement every run begins with (fans with the driver, stopped at kStockTempC, kept when it was, a cool-down after it), and hands it to the search as its reference (UndervoltBase::measured, which the all-in-one run already used for the overclock's soak); stock_refusal decides what such a reference may be, in both places. There is one more thing the overclock profiles do for a hot card that the undervolt cannot: they lower the power limit until the profile's temperature holds. An undervolt changes neither clocks nor power. So the search does not stop a candidate below the temperature the card reached in the reference (limit_c, never above kStockTempC): an undervolt makes a card cooler than it was, never hotter, and without this every candidate of a card that is above the profile's limit to begin with ends TOO HOT and reads as "this card holds no undervolt". On a card that stays under the limit at stock nothing changes: the limit is the profile's. The fans still go to 100 % at the profile's limit. Not for a run on top of the saved tune (--on-overclock without a soak handed over): its reference is that overclock, measured by the search at the profile's limit, and one that gets too hot now says so instead of "not stable". |
| With its reference handed over, the undervolt search warms its load up before the first candidate | A search that measures its own reference has five minutes of load behind it when the first 3 s candidate runs. One that is handed the reference (the measurement at stock, or the overclock's soak in All in one) starts its load with that candidate, and a load that has not run yet is slower in its first seconds than the card is: it sizes its batches and the card clocks up from idle. The candidate then averages a clock far below the target and reads SLOW, and a descent ends at its first failure. Seen three times on the reference card: 1070 mV (+15 MHz): SLOW ... clock=2323 MHz and 1050 mV: SLOW ... clock=2411 MHz in hardware check 92, where the undervolt profile then said "the card held no undervolt", and 1025 mV (+150 MHz): SLOW ... clock=2380 MHz in an All in one run of 2026-10-08. That last one means the undervolt step of All in one has kept the overclock's curve cut off at its chosen clock and searched nothing below it, in every release that has it; its numbers in the README were measured that way. Now the load runs kUvWarmUpS (5 s) at the reference's settings first; only whether the card computes is taken from it. The score threshold was not loosened: a candidate that is slow on a load that is running is still SLOW. |
| A flat top goes back on a card that is cooler than when it was found | The built-in curve moves with temperature: a point reads up to three steps (45 MHz) more on a cool card than on a warm one. A curve that was only cut off (All in one, when the margin leaves no raise above the overclock) is anchored on the point that ran the target by itself while the search had the card warm. Seconds later, in the next step, the same point read one step more, and apply_flat_top refused to put the tune back: "the point already runs more than the target" (hardware check 94). The warm-up of the row above is what made the search read its curve on a warm card; before it, the cut-off was anchored on a card that had idled, and the fault did not show. apply_flat_top now takes an anchor that runs up to kVfTailDropKhz more than the target and holds it on the target, at the voltage the tune was tested on; more than that is still refused, as not this tune's curve. curve_state knows such an anchor (stored below the base offset by no more than that, with the points above cut off) as this program's flat top, so the dashboard and the watchdog do not take it for another program's work. The other direction was always there: a top written on a cool card runs a step lower once it is warm (kUvClockSlackKhz). |
| The clock a candidate may fall short of the target by is five grid steps | The slack existed for the built-in curve moving with temperature between the write and the loaded probe (45 MHz cold against warm on the reference card). On an RTX 3080 Ti that runs 86 C at stock, the first candidate of an undervolt read 53 MHz low with its score at 98 % of stock, and the run stopped at its first step. The slack is now 75 MHz. The share of the stock score a candidate must keep is unchanged, and stays what guards the speed of the result; the 30 s confirm and the soak judge what was found as before. |
| The undervolt search keeps the fans on a curve | It left them to the driver and still stopped every probe at the profile's limit of 75 C. That holds on the reference card, where the driver's fans keep an undervolted card far below it, and cannot hold on a card the driver lets run at 85 C: every probe would end TOO HOT and read as "the card held no undervolt". The search now drives the fans as run_optimize does: the curve in use (the overclock's own, in an all-in-one run) or the preset's, 100 % at the limit. The undervolt is then tested with the fans it will run with, so the run turns the app's fan control on, as every other tune does. Not verified on a hot card: whether 100 % keeps a 3080 Ti below 75 C at stock power is not known, and the undervolt has no power limit to lower. |
| The clock search takes the five minutes at stock as its baseline | The search judges every probe by a score at stock: below a quarter of it a probe is STALLED, a card is back after a reset at nine tenths of it, and the efficient profiles look for the lowest power limit within 2 % of it. It used to take that score in 30 s of its own, right after the five minutes at stock that every profile now starts with (optimize(..., stock)); a stock measurement that was not stable is not used, and without one the search still takes its 30 s. The five-minute score is a little higher than a 30 s one on a card that is already hot (5705 against 5662 it/s on the reference card), which makes the 2 % of the efficient profiles slightly stricter. Not yet measured on hardware. |
| The search says which step it is at | OptimizeIo::measuring, as the undervolt and the fan tune have it: the step, and its length when it lasts (a confirm probe, the final test, the rest after a reset). A search probe lasts three seconds and gets no countdown, only its name. The window shows one row for all of it, as high with a countdown as without, so the page does not jump. |
| The fan chart draws the curve as set under the line the fans follow | A point below the card's minimum, in the fan-stop zone or past the profile's limit is overridden, and the line leaves it. Without the set curve, thin and dimmed through the points, that read as the line ignoring them. |
| The undervolt run measures stock for as long as it soaks | Five minutes each (kUvBaselineS = kUvSoakS; since the row above the measurement at stock is measure_stock's, which lasts as long). A card is not heated through after 30 s: on the reference card the short stock run read 59 to 61 C and 185 W where five minutes gave 67 C at 72 % fan and 196 W, so a result compared with it looked no cooler than stock. The run takes four and a half minutes longer for it. It also puts the clock to keep and the built-in curve on a warm card, as the result will run. The long measurements (stock, confirm, soak) are announced through UndervoltIo::measuring, and the run screen counts them down. The result table names the length of both runs and shows the fan speed each ended on next to its peak temperature. The optimize run of the clock profiles measures stock the same way before its search (measure_stock, the fans left to the driver) and shows that as the "before"; the search's own 30 s baseline stays, as the reference of the power search and of the stall and health thresholds. The all-in-one run hands its own stock measurement to the overclock step, so nothing is measured twice. |
| Fans through NVML, stop zone via the driver | NVIDIA's legacy NVAPI fan API is gone on RTX 20-series and newer; NVML's nvmlDeviceSetFanSpeed_v2 is public and verified by reading the target back. Below the stop threshold the driver owns the fans, so no failure of this app can leave them stopped. |
| Tray app plus logon task | Driver settings are volatile: a reboot or driver reset clears them. The logon task starts the tray app, whose watchdog keeps the tune applied; three crashing logons in a row switch it off. |
| An update is one click, and never more than that | The app asks GitHub for the latest release (core/update) and shows one line in the sidebar; nothing is downloaded until it is clicked. The zip must come from this project's releases over HTTPS and match the SHA-256 the GitHub API publishes for it, and both executables in it must carry the version the release claims. The order of these steps and every refusal are install_release in core/update, which gets its steps as callbacks and is tested with a fake source; a release without a usable SHA-256 is refused there too, not only by the parser, because an empty expectation would equal the hash of a file that cannot be read. Elevated, the download is unpacked under %ProgramData%\GpuAutoOptimizer\update, which only administrators can write, so nothing can be swapped between the check and the copy. The executables are replaced where they run from (a running one is renamed aside) and the app starts again with --resume, which keeps a tune that is applied under the watchdog. The trust this rests on is the GitHub account: the executables are not code-signed. |
| A version may carry a pre-release suffix | A pre-release build names itself 0.4.8-rc1: the tag and the built version must match (release.yml). version_number reads a suffix after a hyphen as the version it belongs to, so the update check compares numbers as before. GitHub's "latest release" answer never carries a suffix (pre-releases are refused by name, and the Release workflow marks a hyphenated tag as one), so from GitHub's side only plain versions are compared. A later 0.4.8 is not offered as an update to an 0.4.8-rc1: the numbers are equal. |
| The installed copy has a Start menu entry, and an update that cannot write asks for the rights | Without an entry Windows' search does not find the app, and a copy that is not running cannot be started except from its folder. The entry is made with the logon task, for all users, removed with it, and made by the copy that starts after an update when it is missing (installs of 0.4.1 and older have none). A copy started from that entry is not elevated and cannot replace its own files in Program Files: 0.4.0 failed there with error 5. The click on the update now starts the copy again elevated with --update-now, and that copy installs it (hardware check 85). |
| A newer copy takes over from an older one | The app is single-instance, and the copy in the tray usually runs elevated. A copy that is started later reads the running one's version from a window property (builds before 0.3.1 have none and count as older) and, when it is newer itself, offers to replace it: it starts again elevated with --replace, asks the old copy to exit through its own tray-menu command (which hands the fans back and stops a run cleanly) and ends it only when it does not go and no optimize run is under way. The logon copy follows when apply-at-logon is on and that copy is not newer (update_logon_copy), because a profile saved by a new build must be one the logon copy accepts; but only after something the user did with the new build: an update, taking over from an older copy, or a run that saved a profile. A copy that was merely started installs nothing and says in its log that the logon copy is older. Until 0.4.0 the copy installed itself the moment it started elevated, and the task was created by starting schtasks.exe: on 2026-10-10 Windows Defender judged exactly that sequence in a build nobody had seen before (the app restarting itself elevated, copying itself to Program Files and creating a logon task with the highest rights, all within a second) as Behavior:Win32/Execution.A!ml, and removed the build, the logon copy and the task (hardware check 83). The task is now registered through the Task Scheduler's own interface (ITaskFolder::RegisterTask), and nothing is started for it. |
| A logon apply that ends cleanly is no strike | Strikes count logons that may have crashed the machine. An apply that was refused before any write, or failed and reset the card to stock, did not: the strike it recorded up front is taken back. Only a failed reset keeps it. Before 0.3.1 three refused logons read as disabled after 3 crashes. |
| An undervolt is a flat top on the curve, found like a core offset | The driver lets a program offset the frequency of each point of the voltage/frequency curve; voltages cannot be written. To run a clock on less voltage, a lower point (the anchor) is raised to that clock and no point above it runs more, so the card has no reason to ask for more voltage. The points below keep the built-in curve, so idle clocks stay as they are. No voltage lock is set: the lock call hard-froze the reference RTX 4070 in the Python version. The write (apply_flat_top) uses the units the reference card stores (kHz, one to one; hardware check 61) and estimates nothing. What protects a card that stores them differently is the order: the first round raises a point only half of the way, so a card that moves twice as far lands on the target and not above it, and a card that did not move the anchor by what was written (within one step of its clock grid) is refused before the full step; each later round adds a point's own remaining gap as read back, which also follows a curve that shifts with temperature; no offset is ever written beyond what the plan asks plus two grid steps; and a card that has not arrived after four rounds is refused and cleared. (A first version estimated the units from the read-back; a review showed that estimate could push the anchor far above the target, and it was removed before any write ran on hardware.) A reset to stock zeroes a shape on the curve first and judges it by reading back; a plain core offset, which is the same offset on every point, still goes with the offset reset alone. The search (find_undervolt) keeps the clock the card runs under load at stock and moves the anchor one point down at a time. What a candidate asks of the card is its raise, the anchor's frequency above its built-in one, which is a core offset seen at one point; so the core search's rules carry over unchanged: first failure ends the descent, 30 s confirm, the same margin on the raise, 300 s soak, journal entry ("uv") before the write, any failure ends at stock. The core offset and the curve are one table (hardware check 61), so a profile will carry one or the other. First version: gao --undervolt only, not saved, not in the window; no write has been run on hardware yet (checks 62 to 65). The curve calls are undocumented NVAPI, as the offsets are; the write procedure and the search are this project's own, with Green Curve (aufkrawall/green-curve, MIT) read as a worked example of what goes wrong on real cards. |
| Dear ImGui on DX11 | One small binary with no runtime, and the D3D11 device is in the process anyway for the stress load. |
Partly verified on hardware. The handling of a driver reset during an optimize run (the rows above on the reconnect, the in-probe STALLED, the reset budget, the rest and health check, and the memory-first single-step search) describes what the code does. It is implemented and unit-tested. Seen on hardware, in hardware check 84 (c): a Best of my GPU run met a driver reset in a memory candidate, reconnected, rested 20 s, passed the health check, confirmed the memory clock lower and went on to its result, with its window drawing throughout. Not seen on hardware: the in-probe STALLED, a run that uses up its reset budget, and a reset in the core climb. Round A of hardware check 51, on a build that reconnected but took no rest, left the desktop unusable after a forced reset.
src/core/is pure logic. It reaches hardware only throughGpuControland callbacks, so CI tests it without a GPU.- Every hardware write is verified by reading it back; a mismatch is a failure. Any failed apply ends at stock.
- The crash journal's
beginline is flushed to disk before a clock candidate touches the hardware. - A reconnect to the driver never happens while a journal entry is open.
- A run stops exploring at the first driver reset and ends at the second. Every reset event is counted in one number, the recovery's own health probe included.
- After a reset event no candidate is probed until the card has rested and passed a health probe at stock. No step of that recovery starts another recovery.
- A probe that ended
DEVICE LOST,STALLEDorNO TELEMETRYis never used as a measured failure of its candidate: the search does not bisect below it and stores nothing about it for later runs. - A journal entry at stock clocks never becomes a ceiling.
- A stop request ends the running probe within one batch (about 250 ms); the candidate's journal entry is closed as
ABORTEDand the run ends at stock. - Everything the elevated logon task touches is admin-only: the copy in
%ProgramFiles%and the state folder in%ProgramData%. A state folder someone else created first, or a link in its place, is refused. Both locations come from the registry, not from environment variables. - The CRT is linked statically and every non-system DLL (
d3d11,dxgi,d3dcompiler_47,dwmapi) is delay-loaded from System32, so nothing placed next to the executable is loaded.
core_tests covers src/core/, and app_tests the part of src/app/ that can be tested with the driver libraries stood in for. Both run in CI on a GitHub-hosted windows-2025 runner, once normally and once under AddressSanitizer. CI also builds both executables, so a link error in the hardware layer is caught there.
The hardware layer cannot run in CI (the runner has no GPU). It is checked by hand on the reference RTX 4070 through hardware-checks.md; record the results there.
The version lives in src/core/version.hpp. Pushing a v* tag that matches it builds, tests and packages both executables into a draft GitHub release with a SHA-256 file and a build provenance attestation; publishing the draft is a manual step. Running the Release workflow by hand is a dry run that only uploads the zip as an artifact.