| name | pcx-perf-budget |
| description | Turns the update/render separation rule into enforceable numeric budgets using mono_us() measurements. Covers per-frame targets at common refresh rates, per-call cost rules of thumb, a drop-in profiler recipe, and read-coalescing patterns. Always active when writing or reviewing performance-sensitive render or update routines.
|
| license | MIT |
Performance Budget — Frame-Time Targets for PCX Scripts
Turns game-cheat-guidelines rule #4 (separate update from render) into enforceable numeric budgets, so the question "is my script too slow?" gets answered with mono_us() measurements instead of vibes. Covers per-frame targets at common refresh rates, per-call cost rules of thumb, the drop-in profile_begin/end recipe, and the read-coalescing patterns that produce the biggest wins.
Always active when writing or reviewing performance-sensitive paths (render routines, update routines, entity loops, pattern scans inside hot paths).
Prerequisite: docs/enma/addon-time.md for the timing primitives (mono_us, now_us, sleep_ms); skill://game-cheat-guidelines rules #4 (update/render separation) and #7 (per-frame construction).
Trigger
Render stutter, FPS drop on overlay enable, "my script feels slow," profiling questions, write-up of per-feature performance, decisions about whether to cache or recompute, multi-routine scripts where update + render share a frame budget.
1. Know the Frame Budget at Your Target Refresh Rate
The frame budget is the entire wall-clock window between two consecutive render calls. Everything — your update, your render, the game's own rendering, the GPU present — must fit inside it.
Total frame budgets:
| Refresh | Budget per frame | PCX render budget (target) | PCX update budget (target) |
|---|
| 60 Hz | 16.67 ms | ≤ 2.0 ms | ≤ 4.0 ms |
| 120 Hz | 8.33 ms | ≤ 1.5 ms | ≤ 3.0 ms |
| 144 Hz | 6.94 ms | ≤ 1.5 ms | ≤ 2.5 ms |
| 240 Hz | 4.17 ms | ≤ 1.0 ms | ≤ 1.5 ms |
| 360 Hz | 2.78 ms | ≤ 0.7 ms | ≤ 1.0 ms |
The render budget is small because the game's own renderer + the GPU present + your overlay all share the frame. If your render path takes 5 ms at 144 Hz, you've eaten 72% of the frame by yourself, leaving 1.94 ms for the game's render — which causes the game to drop frames even though it would have hit 144 Hz without your overlay.
The update budget is more generous because, if you separate update from render properly (rule #4), update runs less frequently and on its own clock — it competes with the game less directly. But "less directly" is not "for free": a 10 ms update routine running at 60 Hz costs the same total CPU as a 2 ms render routine running at 144 Hz × 2.
Heuristic: measure once, then forget. If your script runs at the target FPS with no stutter on the lowest-spec machine you ship to, the budgets are met. If it stutters, instrument first (Step 3) before optimizing.
Why: Hard numeric targets prevent the "feels slow, must be fast" loop where you over-cache things that don't matter and miss the one routine that does. The render budget being tight is non-negotiable; the update budget is the negotiable lever — push work into update, off the render path, and most stutter disappears.
2. Per-Call Cost Rules of Thumb
Order-of-magnitude costs for the operations you'll write most. Measure on your target; these are guides for which order of magnitude to expect, not contracts.
| Operation | Cold (page-fault) | Warm (cached) | Notes |
|---|
proc.ru8/16/32/64 | 10-100 µs | 1-5 µs | Cold = first read of a page; warm = same page already touched this frame |
proc.rf32/rf64 | 10-100 µs | 1-5 µs | Same as integer reads — cost is the cross-process read, not the type |
proc.read_vec3_fl32 | 30-300 µs | 5-15 µs | One read of 12 bytes vs three separate reads |
proc.read_memory(N) bulk | 30-500 µs depending on N | 10-100 µs | A single struct-dump is almost always cheaper than N scalar reads |
proc.find_code_pattern | 5-200 ms first scan | N/A | Cold path only — never in update/render. Run in main() and cache. |
is_key_pressed / is_key_down | < 1 µs | < 1 µs | Cheap; fine in hot paths |
draw_rect / draw_line / draw_circle | 1-10 µs | 1-10 µs | Cost dominated by GPU command submission, not CPU |
draw_text | 5-50 µs | 5-50 µs | Per-glyph atlas lookup + GPU submission; longer strings cost more |
world_to_screen (pure math) | 1-5 µs | 1-5 µs | When matrix is cached; if you re-read the matrix per call, add a read_memory cost |
GUI widget query (section_* reads) | < 1 µs | < 1 µs | Reading widget state is a local memory access |
now_us / mono_us | < 0.5 µs | < 0.5 µs | Cheap; safe to call multiple times per frame for profiling |
The implication: a render path that does 50 entity boxes with one read_vec3_fl32 per entity inside the render routine costs 50 × 5-15 µs = 0.25-0.75 ms if the entity pages are warm. Cold-cache, it could be 50 × 30-300 µs = 1.5-15 ms — already over the render budget at 144 Hz on the high end. Solution: move the reads to update (cache the cold-page cost there), draw from the cache.
Why: The single most important number to internalize is that cross-process memory reads are very expensive relative to draws and math. A NOP loop running 1000 iterations costs nothing; 1000 ru32 calls can be 30 ms. Every performance problem in a PCX script is either too many reads or reads on the wrong thread.
3. The profile_begin/end Drop-In Recipe
A minimal inline profiler with no new modules, no allocation, no rebuilds. Drop into any script, get per-routine breakdowns in console or on screen.
The pattern uses mono_us() (monotonic; safe for deltas) and a small fixed-size accumulator. No map needed — name your buckets explicitly.
import "vec";
import "color";
const int32 NUM_BUCKETS = 8;
string g_bucket_name[8];
int64 g_bucket_total_us[8];
int64 g_bucket_count[8];
int64 g_bucket_max_us[8];
int64 g_profile_last_dump = 0;
int64 g_profile_dump_interval_us = 1000000;
int64 g_bucket_start_us[8];
void profile_begin(int32 bucket) {
g_bucket_start_us[bucket] = mono_us();
}
void profile_end(int32 bucket) {
int64 dur = mono_us() - g_bucket_start_us[bucket];
g_bucket_total_us[bucket] += dur;
g_bucket_count[bucket] += 1;
if (dur > g_bucket_max_us[bucket]) {
g_bucket_max_us[bucket] = dur;
}
}
void profile_dump_if_due() {
int64 now = mono_us();
if (now - g_profile_last_dump < g_profile_dump_interval_us) return;
g_profile_last_dump = now;
println("── PROFILE ──");
for (int32 i = 0; i < NUM_BUCKETS; i++) {
if (g_bucket_count[i] == 0) continue;
int64 avg = g_bucket_total_us[i] / g_bucket_count[i];
println(format(" {s}: avg {d}us max {d}us ({d} samples)",
g_bucket_name[i], avg, g_bucket_max_us[i], g_bucket_count[i]));
g_bucket_total_us[i] = 0;
g_bucket_count[i] = 0;
g_bucket_max_us[i] = 0;
}
}
const int32 BKT_UPDATE_ENTITIES = 0;
const int32 BKT_RESOLVE_OFFSETS = 1;
const int32 BKT_RENDER_ESP = 2;
const int32 BKT_RENDER_HUD = 3;
int64 main() {
g_bucket_name[BKT_UPDATE_ENTITIES] = "update_entities";
g_bucket_name[BKT_RESOLVE_OFFSETS] = "resolve_offsets";
g_bucket_name[BKT_RENDER_ESP] = "render_esp";
g_bucket_name[BKT_RENDER_HUD] = "render_hud";
register_routine(cast<int64>(on_update), 0);
register_routine(cast<int64>(on_render), 0);
return 1;
}
void on_update(int64 data) {
profile_begin(BKT_UPDATE_ENTITIES);
profile_end(BKT_UPDATE_ENTITIES);
}
void on_render(int64 data) {
profile_begin(BKT_RENDER_ESP);
profile_end(BKT_RENDER_ESP);
profile_begin(BKT_RENDER_HUD);
profile_end(BKT_RENDER_HUD);
profile_dump_if_due();
}
Sample output after one second:
── PROFILE ──
update_entities: avg 1840us max 4200us (12 samples)
render_esp: avg 320us max 510us (144 samples)
render_hud: avg 45us max 80us (144 samples)
Interpretation:
update_entities averages 1.84 ms — fine, well under the 2.5 ms update budget at 144 Hz
- But
max 4200us is the spike to watch — a single 4.2 ms update will be visible if it lands on a render frame; this is the cold-page cost
render_esp at 0.32 ms is healthy; render_hud at 45 µs is excellent
Why: Real numbers replace arguments. Without a profiler, every conversation about "is this fast enough" devolves into hand-wave. With one, you point at the bucket and either fix it or move on. The max column is more useful than the average — averages hide spikes that cause user-visible stutter.
4. Read Coalescing — The Single Biggest Win
Cross-process memory reads dominate cost. Bundling 8 scalar reads from the same struct into one read_memory call is typically 5-10× faster.
The entity loop is the canonical offender. Eight reads per entity, fifty entities = 400 cross-process reads per update. With page-warm reads at 3 µs each, that's 1.2 ms; cold, it's tens of ms.
void on_update(int64 data) {
for (int32 i = 0; i < MAX_ENTITIES; i++) {
uint64 ent = g_proc.ru64(g_entity_list + cast<uint64>(i) * 8);
if (ent == 0) continue;
g_cache[i].health = g_proc.r32(ent + OFFSET_HEALTH);
g_cache[i].team = g_proc.r32(ent + OFFSET_TEAM);
g_cache[i].position = g_proc.read_vec3_fl32(ent + OFFSET_POSITION);
g_cache[i].velocity = g_proc.read_vec3_fl32(ent + OFFSET_VELOCITY);
g_cache[i].view_yaw = g_proc.rf32(ent + OFFSET_VIEW_YAW);
g_cache[i].view_pit = g_proc.rf32(ent + OFFSET_VIEW_PITCH);
}
}
struct entity_struct_layout {
} [[packed]];
void on_update(int64 data) {
array<uint8> buf;
buf.resize(0x200);
for (int32 i = 0; i < MAX_ENTITIES; i++) {
uint64 ent = g_proc.ru64(g_entity_list + cast<uint64>(i) * 8);
if (ent == 0) continue;
if (!g_proc.read_memory(ent, buf, 0x200)) continue;
g_cache[i].health = buf_read_i32(buf, OFFSET_HEALTH);
g_cache[i].team = buf_read_i32(buf, OFFSET_TEAM);
g_cache[i].position = buf_read_vec3(buf, OFFSET_POSITION);
g_cache[i].velocity = buf_read_vec3(buf, OFFSET_VELOCITY);
g_cache[i].view_yaw = buf_read_f32(buf, OFFSET_VIEW_YAW);
g_cache[i].view_pit = buf_read_f32(buf, OFFSET_VIEW_PITCH);
}
}
Order of optimization (high impact to low):
- Coalesce per-entity scalar reads into struct-dumps. Biggest single win for entity loops.
- Cache the view matrix once per update, share across entities. Currently common to re-read per W2S call.
- Skip cold entities. Read just the alive/team field first; if dead or friendly, skip the rest of the read.
- Bound entity counts. A
MAX_ENTITIES = 64 cap on a list that's structurally bounded at 128 saves half the reads if many slots are empty.
Why: Cross-process reads are the dominant cost in any non-trivial script. A read-coalescing pass typically halves total CPU time of an entity-heavy script. The cost of structuring the read is a one-time resize and a handful of byte-offset getters — trivial relative to the win.
5. Cache What's Expensive to Get, Recompute What's Cheap
Pattern scans, module bases, view matrix (across many entities) — cache. Colors, vec2s, format strings, font handles — recompute. Caching cheap things adds state without measurable savings.
| Cache | Recompute |
|---|
Pattern scan results (in main(), never again) | color(r,g,b,a) (4 bytes, stack-allocated, zero cost) |
| Module base / module size (until process re-attach) | vec2(x, y) (8 bytes, stack-allocated) |
| Resolved RIP addresses (until reload) | get_font20() (returns a cached handle internally) |
| View matrix once per update (shared across all W2S in this frame) | format("{d}", n) for short HUD text |
Entity data in g_cache (the whole point of update/render separation) | World-to-screen result (it's just float math; do it where you need it) |
| Local player position once per frame (read in update, used by N features) | is_key_down(VK_F) (a cheap intrinsic; safe per-frame) |
Anti-cache (don't):
color g_white;
int64 main() {
g_white = color(255, 255, 255, 255);
return 1;
}
void on_render(int64 data) {
draw_text("HUD", vec2(10.0, 10.0), color(255, 255, 255, 255),
get_font20(), 1, color(0, 0, 0, 180), 1.0);
}
Pro-cache:
float64 g_matrix[16];
void on_update(int64 data) {
g_proc.read_memory(g_view_matrix_addr, g_matrix_buf, 64);
}
void on_render(int64 data) {
for (int32 i = 0; i < g_entity_count; i++) {
vec2 screen;
if (world_to_screen(g_cache[i].position, g_matrix, screen)) {
draw_circle(screen, 4.0, color(255, 0, 0, 255), 1.0, true);
}
}
}
Why: Caching cheap things makes the script harder to reason about (mutable globals, lifetime questions) for zero performance benefit. Caching expensive things (or things on cold paths) is the explicit purpose of rule #4 (update/render separation) — the whole point of "do it in update" is that the result lives until next update. Use that mechanism for what it's for; don't extend it to things that don't need it.
6. When to Break the Rule
The budgets are steady-state targets. Bursts are fine. Don't split a one-frame initialization across ten frames to "meet budget" — the user-visible cost is the same and the code is worse.
Legitimate bursts:
- Initial process attach and sig resolution in
main() — can take 10-50 ms total, runs once, before the user starts using the overlay. Don't split.
- First-frame entity cache fill after a level load — a one-frame 5 ms spike that lets every subsequent frame run at 0.5 ms. Worth it.
- Patch-day re-resolution if you detect base address changed mid-session — let it stutter once.
- Config save on
on_unload — file I/O takes ms; doesn't matter, the script is exiting.
Illegitimate bursts (these you DO need to fix):
- A pattern scan in a callback that fires periodically (should be in
main)
- A
find_string_refs or struct_dump call on the render thread (cold paths only)
- Allocating a 4 KB array inside
on_render every frame (move to global, reuse the buffer)
- Calling
is_valid_address on every pointer in a chain on every frame (validate at update time, cache the bool)
The test: if the user could feel the cost as a one-frame stutter, is it acceptable? A 50 ms stutter at script load is invisible (the overlay just appears 50 ms later). A 50 ms stutter mid-game is felt as a hitch.
Why: Performance work that makes the code uglier without changing the user-visible behavior is bad performance work. The framing of "everything must fit in 6.94 ms" applies to steady-state operation; setup, teardown, and event-driven one-shots get a pass. Save the optimization energy for the actual hot path.
Summary
| # | Rule | One-liner |
|---|
| 1 | Know your budget | 16/8/7/4 ms at 60/120/144/240 Hz; render ≤ 1.5-2 ms, update ≤ 2.5-4 ms |
| 2 | Internalize per-call costs | Cross-process reads = expensive; draws and math = cheap |
| 3 | Profile with mono_us | Drop-in profile_begin/end with fixed buckets — measure before optimizing |
| 4 | Coalesce reads | One read_memory struct-dump replaces 8 scalar reads; biggest single win |
| 5 | Cache expensive, recompute cheap | Sigs, bases, matrix — yes; colors, vecs, fonts — no |
| 6 | Bursts are fine | Don't split one-shot setup across frames to "meet budget" |
Cross-references: skill://game-cheat-guidelines rules #4 and #7; knowledge/common-patterns.md for read-coalesced entity loops; docs/enma/addon-time.md for mono_us / now_us; skill://pcx-patch-day-playbook Step 5 (post-patch re-resolution that legitimately spends a frame budget).