[math]::Pow(2, $n) - 1 builds the processor-affinity mask in DOUBLE
precision, and a double carries only a 53-bit mantissa. From n = 54 the
subtraction is lost to rounding: 2^n - 1 rounds straight back up to 2^n, so
the mask comes out with exactly ONE bit set.
Measured across the range, old math vs shifting:
n = 50..53 both select n CPUs ok
n = 54..62 old selects 1 CPU of n SILENTLY WRONG
n = 63..64 old throws converting to Int64 visible failure
The silent band is by far the worse half, and it is worst precisely in this
tool. On a 54-to-62-processor host, -Off would pin the title to a single
logical CPU while printing "affinity restored to all N processors", and
-Cores N would pin it to one CPU while reporting N. A throttling tool that
reports the opposite of what it did does not just fail, it invalidates every
measurement taken through it -- which is the exact failure this tool exists
to prevent.
Get-AffinityMask shifts a uint64 instead, and special-cases a full 64
because .NET masks a 64-bit shift count to 63 (shifting by 64 quietly yields
1, not 0). Verified equivalent to the old expression for every n where the
old expression was correct (1..53), correct by popcount for 1..64, and
saturating above 64. ProcessorAffinity addresses a single processor group, so
a host with more than 64 processors is inherently limited to the group the
process already runs in; the mask now saturates there rather than throwing.
No behaviour change on any host where the tool previously worked -- this box
has 16 processors, where old and new masks are byte-identical (0xFFFF).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Runs fine here, players report slowdown there" is not reproducible on a fast
dev box, and guessing at the budget has cost us whole investigations. This
throttles a RUNNING title until the reported symptom appears, so the CPU
budget is read off a dial instead of argued about.
Uses the Windows Job Object CPU rate control -- the same hard cap the
scheduler applies to containers -- so it needs no launcher or runtime support
and attaches to a live process. That matters: an observability tool that
requires relaunching the session destroys the state you were trying to
observe, and the reported hitch often takes minutes of play to reach.
Two independent knobs, both reversible while the game runs:
-CoresWorth <n> hard CPU-rate cap as cores' worth of compute (slower clock)
-Cores <n> processor affinity: how many logical CPUs are available
-Measure sample the current draw, change nothing
-Off release the cap and restore affinity
The job is NAMED, so the cap can be re-dialed from a fresh shell without
re-assigning the process, and the script can exit while the cap stays on. Job
reuse is probed with CreateJobObject + ERROR_ALREADY_EXISTS rather than
OpenJobObject, because nesting a second job would be silently unrecoverable:
nested CPU caps take the MINIMUM, so a loosened dial would appear to do
nothing.
Releasing a cap sets it to the full machine (CpuRate 10000) rather than
clearing the control block, because the API validates the union against the
flags and rejects an all-zero block with ERROR_INVALID_PARAMETER.
Read it as a reproduction tool, not a profiler: the hard cap is enforced by
withholding scheduling quanta, so a heavily throttled process stutters rather
than degrading smoothly. Good for "does the reported hitch appear", wrong for
"what is the real frame time on that CPU". The header says so too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>