OXYGENHUB
◆ rrdc-perf enhanced-x16-emulator / tests / test_perf_budget.c
26.3 KiB · C · last changed feat: the RRDC control port (-controlport), contract 0.6: GET/POST /perf from the Performance panel's measurements, built by perf_json.c with DAP's x16/perfStats; a guest marker region named in advance; a framed guest's regions carried across the frame boundary and an interrupt after $F1 counted as work (#1) 25 days ago source blame history raw
1
// Checks for guest performance budget accounting (src/perf_budget.c).
2
//
3
// The module's whole job is to decide which cycles were WORK and which were
4
// WAITING, so most of what is worth testing is the classification: that a spin
5
// loop is recognised, that something which merely looks like one is not, and
6
// that the three layers override each other in the documented order. The
7
// statistics on top of that are ordinary arithmetic, but they are the numbers a
8
// developer would act on, so the percentile and overrun maths is pinned too.
9
//
10
// perf_budget.c reaches for no emulator state, so this drives it directly: no
11
// CPU, no memory, no video, no SDL.
12
13
#include "perf_budget.h"
14
#include "support/harness.h"
15
16
#include <stdio.h>
17
#include <string.h>
18
19
// The machine this file assumes throughout: 8 MHz, VGA. Stated once here so a
20
// change of default in the module cannot quietly rewrite what the checks mean.
21
#define KHZ              8000
22
#define CYCLES_PER_FRAME 134400
23
#define SCANLINES        525
24
25
static void
26
configure(void)
27
{
28
	perf_budget_zone_clear();
29
	perf_budget_set_enabled(false);
30
	perf_budget_set_capacity(PERF_DEFAULT_CAPACITY);
31
	perf_budget_configure(KHZ, CYCLES_PER_FRAME, SCANLINES);
32
	perf_budget_set_target_fps(60.0f);
33
	perf_budget_set_idle_mode(PERF_IDLE_AUTO);
34
	perf_budget_set_enabled(true);
35
	perf_budget_reset();
36
}
37
38
// Run `count` instructions of straight-line work, each costing `clocks`.
39
static void
40
run_work(uint32_t clocks, int count)
41
{
42
	for (int i = 0; i < count; i++)
43
		perf_budget_step(clocks, (uint16_t)(0x2000 + i * 3), 0, false, 0);
44
}
45
46
// Run `passes` iterations of a three-instruction loop at `base` that stores
47
// nothing -- the shape of `lda $9F27 / and #1 / beq -`.
48
static void
49
run_spin(uint16_t base, int passes)
50
{
51
	for (int p = 0; p < passes; p++) {
52
		perf_budget_step(4, base, 0, false, 0);
53
		perf_budget_step(2, (uint16_t)(base + 3), 0, false, 0);
54
		perf_budget_step(3, (uint16_t)(base + 5), 0, false, 0);
55
	}
56
}
57
58
int
59
main(void)
60
{
61
	// ── Derived machine figures ─────────────────────────────────────────────
62
	{
63
		configure();
64
		check_eq(perf_budget_cycles_per_frame(), CYCLES_PER_FRAME,
65
		         "a VGA frame at 8 MHz is 134,400 cycles");
66
		check_eq(perf_budget_cycles_per_scanline(), 256,
67
		         "which is 256 cycles per scanline over 525 lines");
68
		check_eq(perf_budget_budget_cycles(), CYCLES_PER_FRAME,
69
		         "and at 60 fps the budget is one frame's worth");
70
		check_eq((uint32_t)perf_budget_frames_per_budget(), 1,
71
		         "spanning a single vsync");
72
73
		const float hz = perf_budget_vsync_hz();
74
		check(hz > 59.0f && hz < 60.5f, "vsync is derived at ~59.5 Hz");
75
	}
76
77
	// ── A lower target fps buys more frames, not longer ones ────────────────
78
	{
79
		configure();
80
		perf_budget_set_target_fps(30.0f);
81
		check_eq((uint32_t)perf_budget_frames_per_budget(), 2,
82
		         "30 fps lets the work span two vsyncs");
83
		check_eq(perf_budget_budget_cycles(), CYCLES_PER_FRAME * 2,
84
		         "so the budget is two frames' cycles");
85
86
		perf_budget_set_target_fps(20.0f);
87
		check_eq((uint32_t)perf_budget_frames_per_budget(), 3, "20 fps spans three");
88
89
		perf_budget_set_target_fps(60.0f);
90
		check_eq((uint32_t)perf_budget_frames_per_budget(), 1, "and 60 fps is back to one");
91
	}
92
93
	// ── Nothing is idle until something says so ─────────────────────────────
94
	{
95
		configure();
96
		run_work(4, 100);
97
		perf_budget_frame_end(0);
98
99
		perf_frame_t f;
100
		check(perf_budget_last_frame(&f), "a completed frame is retained");
101
		check_eq(f.total, 400, "every cycle stepped is counted");
102
		check_eq(f.idle, 0, "straight-line code is never idle");
103
		check_eq(perf_frame_work(&f), 400, "so all of it is work");
104
		check_eq(f.instructions, 100, "and the instructions are counted");
105
	}
106
107
	// ── WAI is idle, unambiguously ──────────────────────────────────────────
108
	{
109
		configure();
110
		run_work(4, 10);                             //  40 work
111
		for (int i = 0; i < 10; i++)                 // 100 idle
112
			perf_budget_step(10, 0x3000, 0, true, 0);
113
		perf_budget_frame_end(0);
114
115
		perf_frame_t f;
116
		perf_budget_last_frame(&f);
117
		check_eq(f.idle, 100, "WAI cycles are idle");
118
		check_eq(perf_frame_work(&f), 40, "and the rest is work");
119
	}
120
121
	// ── A store-free tight loop is recognised as waiting ────────────────────
122
	{
123
		configure();
124
		run_work(4, 10); // 40 work
125
		run_spin(0x4000, 20);
126
		perf_budget_frame_end(0);
127
128
		perf_frame_t f;
129
		perf_budget_last_frame(&f);
130
		// Each pass is 9 cycles. The detector re-anchors on the loop head and
131
		// only credits confirmed passes, so a couple go to work; the point is
132
		// that the great majority is recognised.
133
		check(f.idle >= 9 * 16, "a store-free spin loop is mostly idle");
134
		check(perf_frame_work(&f) < 40 + 9 * 4,
135
		      "and only the run-up is charged as work");
136
	}
137
138
	// ── A loop that stores is computing, not waiting ────────────────────────
139
	{
140
		configure();
141
		for (int p = 0; p < 20; p++) {
142
			perf_budget_step(4, 0x4000, 0, false, 0);
143
			perf_budget_step(2, 0x4003, 0, false, 0);
144
			perf_budget_note_store(); // the difference from the case above
145
			perf_budget_step(3, 0x4005, 0, false, 0);
146
		}
147
		perf_budget_frame_end(0);
148
149
		perf_frame_t f;
150
		perf_budget_last_frame(&f);
151
		check_eq(f.idle, 0, "a loop with a side effect is never idle");
152
		check_eq(perf_frame_work(&f), 9 * 20, "all of it counts as work");
153
	}
154
155
	// ── A loop wider than the span is not a spin ────────────────────────────
156
	{
157
		configure();
158
		for (int p = 0; p < 20; p++) {
159
			perf_budget_step(4, 0x5000, 0, false, 0);
160
			perf_budget_step(2, 0x5000 + PERF_SPIN_SPAN + 8, 0, false, 0);
161
		}
162
		perf_budget_frame_end(0);
163
164
		perf_frame_t f;
165
		perf_budget_last_frame(&f);
166
		check_eq(f.idle, 0, "a loop spanning more than PERF_SPIN_SPAN is not a spin");
167
	}
168
169
	// ── Interrupt cycles are counted, and are never idle ────────────────────
170
	{
171
		configure();
172
		run_spin(0x4000, 20);
173
		for (int i = 0; i < 10; i++)
174
			perf_budget_step(5, (uint16_t)(0x9000 + i * 2), 0, false, 1);
175
		perf_budget_frame_end(0);
176
177
		perf_frame_t f;
178
		perf_budget_last_frame(&f);
179
		check_eq(f.irq, 50, "cycles at interrupt depth are attributed to the handler");
180
		check(f.idle > 0, "the spin before it is still recognised");
181
		check(f.idle <= f.total - 50, "but the handler's cycles are not idle");
182
	}
183
184
	// ── An idle zone overrides the heuristic ────────────────────────────────
185
	{
186
		configure();
187
		const int z = perf_budget_zone_add("wait", 0x6000, 0x6100, -1, true);
188
		check(z >= 0, "an idle zone can be defined");
189
190
		// A loop that stores, which the heuristic would call work.
191
		for (int p = 0; p < 10; p++) {
192
			perf_budget_step(4, 0x6000, 0, false, 0);
193
			perf_budget_note_store();
194
			perf_budget_step(3, 0x6002, 0, false, 0);
195
		}
196
		perf_budget_frame_end(0);
197
198
		perf_frame_t f;
199
		perf_budget_last_frame(&f);
200
		check_eq(f.idle, 70, "an idle zone declares its cycles waiting regardless");
201
		check_eq(perf_frame_work(&f), 0, "leaving no work at all");
202
	}
203
204
	// ── Zones attribute cycles, and respect their bank ──────────────────────
205
	{
206
		configure();
207
		const int z0 = perf_budget_zone_add("sprites", 0x2000, 0x2100, -1, false);
208
		const int z1 = perf_budget_zone_add("banked", 0xA000, 0xA100, 3, false);
209
		check(z0 == 0 && z1 == 1, "zones take the first free slots");
210
211
		run_work(4, 4);                                    // $2000,$2003,$2006,$2009
212
		perf_budget_step(7, 0xA000, 3, false, 0);          // matching bank
213
		perf_budget_step(9, 0xA000, 5, false, 0);          // wrong bank
214
		perf_budget_frame_end(0);
215
216
		perf_frame_t f;
217
		perf_budget_last_frame(&f);
218
		check_eq(f.zone[0], 16, "a zone collects the cycles inside it");
219
		check_eq(f.zone[1], 7, "a banked zone collects only its own bank");
220
		check_eq(f.total, 32, "while the frame total counts everything");
221
	}
222
223
	// ── Removing a zone stops attribution ───────────────────────────────────
224
	{
225
		configure();
226
		perf_budget_zone_add("gone", 0x2000, 0x2100, -1, false);
227
		check(perf_budget_zone_remove(0), "a zone can be removed");
228
		check(!perf_budget_zone_get(0, NULL), "and is then unknown");
229
		run_work(4, 4);
230
		perf_budget_frame_end(0);
231
232
		perf_frame_t f;
233
		perf_budget_last_frame(&f);
234
		check_eq(f.zone[0], 0, "cycles are no longer attributed to it");
235
	}
236
237
	// ── Markers: the guest says where its work is ───────────────────────────
238
	{
239
		configure();
240
		check_eq(perf_budget_marker_read(), PERF_MARKER_PROTOCOL,
241
		         "the marker register reads back a protocol version");
242
243
		perf_budget_marker_write(0xF0); // work begins
244
		run_work(4, 10);                // 40 work
245
		perf_budget_marker_write(0xF1); // work ends
246
		run_work(4, 10);                // 40 idle -- and it stores, and it is
247
		perf_budget_note_store();       // straight-line, so only the marker
248
		run_work(4, 10);                // could possibly call this waiting
249
		perf_budget_frame_end(0);
250
251
		perf_frame_t f;
252
		perf_budget_last_frame(&f);
253
		check_eq(perf_frame_work(&f), 40, "only the marked span is work");
254
		check_eq(f.idle, 80, "everything after the end marker is idle");
255
	}
256
257
	// ── Marker regions claim zone slots, and nest ───────────────────────────
258
	{
259
		configure();
260
		perf_budget_marker_write(0xF0);
261
		perf_budget_marker_write(0x01); // enter region 1
262
		run_work(4, 5);                 // 20 -> region 1
263
		perf_budget_marker_write(0x02); // enter region 2, nested
264
		run_work(4, 5);                 // 20 -> region 2 (innermost wins)
265
		perf_budget_marker_write(0x82); // leave region 2
266
		run_work(4, 5);                 // 20 -> region 1 again
267
		perf_budget_marker_write(0x81); // leave region 1
268
		run_work(4, 5);                 // 20 -> unattributed
269
		perf_budget_frame_end(0);
270
271
		perf_frame_t f;
272
		perf_budget_last_frame(&f);
273
		check_eq(f.zone[0], 40, "an outer region collects its own cycles");
274
		check_eq(f.zone[1], 20, "the innermost open region wins while nested");
275
276
		perf_zone_t z;
277
		check(perf_budget_zone_get(0, &z), "a marker region claims a zone slot");
278
		check(z.is_marker && z.marker_id == 1, "tagged with the region it stands for");
279
		check(strcmp(z.name, "region 1") == 0, "and named after it");
280
	}
281
282
	// ── A framed guest's work may run across the emulator's frame boundary ──
283
	{
284
		// A game's frame starts where it waits for the vertical blank, which
285
		// is not where this module's frame ends. Its phases run straight
286
		// across the boundary, and the one running there keeps its cycles.
287
		configure();
288
		perf_budget_marker_write(0xF0);
289
		perf_budget_marker_write(0x01); // phase 1
290
		run_work(4, 10);
291
		perf_budget_marker_write(0x00);
292
		perf_budget_marker_write(0x02); // phase 2, running at the boundary
293
		run_work(4, 10);
294
		perf_budget_frame_end(0);
295
		run_work(4, 10);                // phase 2, after it
296
		perf_budget_marker_write(0x00);
297
		perf_budget_marker_write(0xF1);
298
		run_work(4, 10);                // the wait
299
		perf_budget_frame_end(0);
300
301
		perf_frame_t f;
302
		perf_budget_frame_at(1, &f);
303
		check_eq(f.zone[0], 40, "phase 1 before the boundary");
304
		check_eq(f.zone[1], 40, "phase 2's cycles before the boundary");
305
		perf_budget_last_frame(&f);
306
		check_eq(f.zone[1], 40, "and phase 2's after it, carried across");
307
		check_eq(f.idle, 40, "the wait after $F1 idle");
308
	}
309
310
	// ── An interrupt during the wait is work ────────────────────────────────
311
	{
312
		// After $F1 the guest is waiting; the IRQ handler that runs in that
313
		// wait is not, and main (work less irq) must not come out short of it.
314
		configure();
315
		perf_budget_marker_write(0xF0);
316
		run_work(4, 10);
317
		perf_budget_marker_write(0xF1);
318
		for (int i = 0; i < 10; i++) perf_budget_step(4, 0x2000, 0, false, 0); // waiting
319
		for (int i = 0; i < 5; i++)  perf_budget_step(6, 0xE000, 0, false, 1); // the IRQ
320
		perf_budget_frame_end(0);
321
		perf_frame_t f;
322
		perf_budget_last_frame(&f);
323
		check_eq(f.irq, 30, "the handler's cycles are IRQ");
324
		check_eq(f.idle, 40, "only the waiting is idle");
325
		check_eq(perf_frame_work(&f), 70, "the handler counted as work");
326
	}
327
328
	// ── A marker region can be named before the guest enters it ─────────────
329
	{
330
		configure();
331
		const int zi = perf_budget_zone_add_marker("sound", 7);
332
		check(zi >= 0, "a named marker region claims a zone slot");
333
		perf_zone_t z;
334
		check(perf_budget_zone_get(zi, &z) && z.is_marker && z.marker_id == 7 &&
335
		      !strcmp(z.name, "sound"), "under its name, standing for region 7");
336
		perf_budget_marker_write(0xF0);
337
		perf_budget_marker_write(0x07);
338
		run_work(4, 5);
339
		perf_budget_marker_write(0xF1);
340
		perf_budget_frame_end(0);
341
		perf_frame_t f;
342
		perf_budget_last_frame(&f);
343
		check_eq(f.zone[zi], 20, "and the guest's region 7 lands in it");
344
		check(perf_budget_zone_add_marker("x", 0) < 0 && perf_budget_zone_add_marker("x", 0x40) < 0,
345
		      "ids outside $01-$3F are refused");
346
	}
347
348
	// ── Unbalanced markers cost one frame, not every frame ──────────────────
349
	{
350
		// A guest that does not frame its work ($F0 ... $F1): the boundary is
351
		// the only thing that can close a region it forgot to leave.
352
		configure();
353
		perf_budget_marker_write(0x05); // entered and never left
354
		run_work(4, 10);
355
		perf_budget_frame_end(0);
356
357
		run_work(4, 10); // the next frame must not still be inside region 5
358
		perf_budget_frame_end(0);
359
360
		perf_frame_t f;
361
		perf_budget_last_frame(&f);
362
		check_eq(f.zone[0], 0, "a region left open is closed by the frame boundary");
363
364
		// A guest that does frame its work closes it itself, at its next $F0:
365
		// the missing leave costs one of ITS frames.
366
		configure();
367
		perf_budget_marker_write(0xF0);
368
		perf_budget_marker_write(0x05); // entered and never left
369
		run_work(4, 10);
370
		perf_budget_frame_end(0);
371
		perf_budget_marker_write(0xF0); // the guest's next frame
372
		run_work(4, 10);
373
		perf_budget_frame_end(0);
374
		perf_budget_last_frame(&f);
375
		check_eq(f.zone[0], 0, "a framed guest's open region ends at its next $F0");
376
377
		// Leaving a region that was never entered is ignored rather than
378
		// corrupting the stack.
379
		perf_budget_marker_write(0x93);
380
		run_work(4, 5);
381
		perf_budget_frame_end(0);
382
		perf_budget_last_frame(&f);
383
		check_eq(f.total, 20, "an unmatched leave is ignored");
384
	}
385
386
	// ── Marker mode does not outlive a reset ────────────────────────────────
387
	{
388
		// Marker mode suppresses the heuristic. If a reset did not clear it, a
389
		// program that wrote one marker would disable automatic detection for
390
		// the rest of the session, with no way back.
391
		configure();
392
		perf_budget_marker_write(0xF0);
393
		run_work(4, 5);
394
		perf_budget_frame_end(0);
395
396
		perf_budget_reset();
397
		run_spin(0x4000, 20);
398
		perf_budget_frame_end(0);
399
400
		perf_frame_t f;
401
		perf_budget_last_frame(&f);
402
		check(f.idle > 0, "a reset gives automatic detection back after markers");
403
404
		// And a guest still driving markers re-asserts the mode immediately.
405
		perf_budget_reset();
406
		perf_budget_marker_write(0xF1); // work has ended: all of this is idle
407
		run_work(4, 10);
408
		perf_budget_frame_end(0);
409
		perf_budget_last_frame(&f);
410
		check_eq(f.idle, 40, "while the next marker write takes control again");
411
	}
412
413
	// ── Idle modes ──────────────────────────────────────────────────────────
414
	{
415
		configure();
416
		perf_budget_set_idle_mode(PERF_IDLE_NONE);
417
		run_spin(0x4000, 20);
418
		for (int i = 0; i < 5; i++)
419
			perf_budget_step(10, 0x3000, 0, true, 0);
420
		perf_budget_frame_end(0);
421
422
		perf_frame_t f;
423
		perf_budget_last_frame(&f);
424
		check_eq(f.idle, 0, "PERF_IDLE_NONE classifies nothing as waiting");
425
426
		configure();
427
		perf_budget_set_idle_mode(PERF_IDLE_MARKERS);
428
		run_spin(0x4000, 20);
429
		perf_budget_frame_end(0);
430
		perf_budget_last_frame(&f);
431
		check_eq(f.idle, 0, "PERF_IDLE_MARKERS ignores the spin heuristic");
432
	}
433
434
	// ── Overruns, against the budget ────────────────────────────────────────
435
	{
436
		configure();
437
		// Three frames under budget, two over.
438
		const uint32_t under = CYCLES_PER_FRAME / 2;
439
		const uint32_t over  = CYCLES_PER_FRAME + 1000;
440
		const uint32_t counts[5] = {under, over, under, over, under};
441
		for (int i = 0; i < 5; i++) {
442
			perf_budget_step(counts[i], (uint16_t)(0x2000 + i), 0, false, 0);
443
			perf_budget_frame_end(1000);
444
		}
445
446
		perf_window_t w;
447
		check(perf_budget_window(0.0f, &w), "the whole ring is a valid window");
448
		check_eq((uint32_t)w.frames, 5, "holding every frame recorded");
449
		check_eq((uint32_t)w.overruns, 2, "two of which exceeded the budget");
450
		check_eq(w.worst_overrun, 1000, "by 1000 cycles at worst");
451
		check_eq(w.work.max, over, "the worst frame is the maximum");
452
		check_eq(w.work.min, under, "and the best the minimum");
453
		check(w.max_utilization > 100.0f, "so peak utilisation is over budget");
454
		check(w.mean_utilization < 100.0f, "while the mean is under it");
455
	}
456
457
	// ── The overrun drain, which the DAP event rides on ─────────────────────
458
	{
459
		configure();
460
		perf_overrun_report_t r;
461
		check(!perf_budget_drain_overruns(&r), "a clean run reports nothing");
462
463
		perf_budget_step(CYCLES_PER_FRAME + 500, 0x2000, 0, false, 0);
464
		perf_budget_frame_end(0);
465
		perf_budget_step(CYCLES_PER_FRAME + 2500, 0x2000, 0, false, 0);
466
		perf_budget_frame_end(0);
467
468
		check(perf_budget_drain_overruns(&r), "overruns are reported once seen");
469
		check_eq(r.overruns, 2, "coalescing every frame since the last drain");
470
		check_eq(r.worst_overrun, 2500, "and keeping the worst of them");
471
		check(!perf_budget_drain_overruns(&r), "draining clears the accumulator");
472
	}
473
474
	// ── Percentiles ─────────────────────────────────────────────────────────
475
	{
476
		configure();
477
		// 100 frames of 1000..100000 cycles, ascending.
478
		for (int i = 1; i <= 100; i++) {
479
			perf_budget_step((uint32_t)i * 1000, 0x2000, 0, false, 0);
480
			perf_budget_frame_end(0);
481
		}
482
		perf_window_t w;
483
		check(perf_budget_window(0.0f, &w), "a hundred frames make a window");
484
		check_eq((uint32_t)w.frames, 100, "all of them retained");
485
		check_eq(w.work.min, 1000, "minimum is the smallest sample");
486
		check_eq(w.work.max, 100000, "maximum is the largest");
487
		check_eq(w.work.mean, 50500, "the mean is the average of 1..100 thousand");
488
		check_eq(w.work.p50, 50000, "nearest-rank p50 over 100 samples");
489
		check_eq(w.work.p95, 95000, "p95");
490
		check_eq(w.work.p99, 99000, "p99");
491
	}
492
493
	// ── The ring wraps, keeping the newest ──────────────────────────────────
494
	{
495
		configure();
496
		check(perf_budget_set_capacity(8), "the ring can be resized");
497
		check_eq((uint32_t)perf_budget_capacity(), 8, "to the size asked for");
498
499
		for (int i = 0; i < 20; i++) {
500
			perf_budget_step((uint32_t)(i + 1) * 100, 0x2000, 0, false, 0);
501
			perf_budget_frame_end(0);
502
		}
503
		check_eq((uint32_t)perf_budget_frame_count(), 8, "which then holds eight frames");
504
		check_eq(perf_budget_total_frames(), 20, "having seen twenty");
505
506
		perf_frame_t f;
507
		perf_budget_last_frame(&f);
508
		check_eq(f.total, 2000, "the newest frame survives the wrap");
509
		check_eq(f.frame, 19, "with its original ordinal");
510
511
		perf_window_t w;
512
		perf_budget_window(0.0f, &w);
513
		check_eq(w.work.min, 1300, "and the window holds only the last eight");
514
		check_eq(w.work.max, 2000, "up to the newest");
515
	}
516
517
	// ── A window narrower than the ring ─────────────────────────────────────
518
	{
519
		configure();
520
		for (int i = 0; i < 600; i++) { // ten seconds at ~60 Hz
521
			perf_budget_step(1000, 0x2000, 0, false, 0);
522
			perf_budget_frame_end(0);
523
		}
524
		perf_window_t w;
525
		check(perf_budget_window(1.0f, &w), "one second is a valid window");
526
		check(w.frames >= 55 && w.frames <= 65,
527
		      "holding about one vsync period's worth of frames");
528
529
		check(perf_budget_window(5.0f, &w), "and so is five seconds");
530
		check(w.frames >= 290 && w.frames <= 305, "with about five times as many");
531
	}
532
533
	// ── A budget spanning two frames sums the work across them ──────────────
534
	{
535
		configure();
536
		perf_budget_set_target_fps(30.0f);
537
538
		// Each frame does 3/4 of a frame's work: fine at 30 fps (1.5 of a
539
		// 2-frame budget), and would be an overrun at 60.
540
		const uint32_t each = CYCLES_PER_FRAME * 3 / 4;
541
		for (int i = 0; i < 6; i++) {
542
			perf_budget_step(each, 0x2000, 0, false, 0);
543
			perf_budget_frame_end(0);
544
		}
545
		perf_window_t w;
546
		perf_budget_window(0.0f, &w);
547
		check_eq((uint32_t)w.overruns, 0, "3/4 of a frame each is within a 30 fps budget");
548
		check_eq(w.budget_work.max, each * 2, "which is measured over two frames");
549
		check(w.max_utilization > 74.0f && w.max_utilization < 76.0f,
550
		      "putting utilisation at about 75%");
551
552
		// Now overshoot it.
553
		configure();
554
		perf_budget_set_target_fps(30.0f);
555
		for (int i = 0; i < 6; i++) {
556
			perf_budget_step(CYCLES_PER_FRAME + 5000, 0x2000, 0, false, 0);
557
			perf_budget_frame_end(0);
558
		}
559
		perf_budget_window(0.0f, &w);
560
		check(w.overruns > 0, "but a full frame of work each is not");
561
	}
562
563
	// ── A tight poll nested inside a compact routine that stores ────────────
564
	{
565
		// The case a real ROM found: a whole routine small enough to fit inside
566
		// PERF_SPIN_SPAN, containing a storing loop AND a store-free one. A
567
		// detector that anchors on PC drift rather than on the backward jump
568
		// treats the entire routine as one storing "loop" and reports no idle
569
		// at all.
570
		configure();
571
		for (int outer = 0; outer < 20; outer++) {
572
			// work loop at $C004: STA / DEX / BNE, 80 passes
573
			for (int i = 0; i < 8; i++) {
574
				perf_budget_step(4, 0xC004, 0, false, 0);
575
				perf_budget_note_store();
576
				perf_budget_step(2, 0xC007, 0, false, 0);
577
				perf_budget_step(3, 0xC008, 0, false, 0);
578
			}
579
			// idle loop at $C00C: NOP / DEX / BNE, store-free
580
			for (int i = 0; i < 8; i++) {
581
				perf_budget_step(2, 0xC00C, 0, false, 0);
582
				perf_budget_step(2, 0xC00D, 0, false, 0);
583
				perf_budget_step(3, 0xC00E, 0, false, 0);
584
			}
585
			perf_budget_step(3, 0xC010, 0, false, 0); // JMP back to the top
586
		}
587
		perf_budget_frame_end(0);
588
589
		perf_frame_t f;
590
		perf_budget_last_frame(&f);
591
		const float idle_pct = 100.0f * (float)f.idle / (float)f.total;
592
		check(idle_pct > 20.0f && idle_pct < 70.0f,
593
		      "a store-free poll nested in a compact storing routine is still found");
594
		check(f.idle > 0, "and its cycles are credited as waiting");
595
	}
596
597
	// ── A target faster than the machine scans gets a fraction of a frame ────
598
	{
599
		configure();
600
		// ~120 fps against a ~59.5 Hz vsync is very close to half a frame.
601
		perf_budget_set_target_fps(120.0f);
602
		check_eq((uint32_t)perf_budget_frames_per_budget(), 1,
603
		         "a sub-frame target still measures over one frame");
604
		const uint32_t half = perf_budget_budget_cycles();
605
		check(half > CYCLES_PER_FRAME * 45 / 100 && half < CYCLES_PER_FRAME * 55 / 100,
606
		      "and gets about half a frame's cycles");
607
608
		// A routine using three quarters of a frame genuinely misses a
609
		// half-frame budget, and this is the case where an overrun means
610
		// something: work cannot exceed a whole frame, so without sub-frame
611
		// budgets there would be nothing to overrun.
612
		for (int i = 0; i < 5; i++) {
613
			perf_budget_step(CYCLES_PER_FRAME * 3 / 4, 0x2000, 0, false, 0);
614
			perf_budget_frame_end(0);
615
		}
616
		perf_window_t w;
617
		perf_budget_window(0.0f, &w);
618
		check_eq((uint32_t)w.overruns, 5, "three quarters of a frame overruns a half-frame budget");
619
		check(w.max_utilization > 140.0f, "at around 150% utilisation");
620
	}
621
622
	// ── "60" means every frame, not 0.8% faster than the hardware ───────────
623
	{
624
		configure();
625
		// The machine scans at 59.52 Hz. Asking for 60 must not shave the
626
		// budget to 133,333 and put every saturated program over it.
627
		perf_budget_set_target_fps(60.0f);
628
		check_eq(perf_budget_budget_cycles(), CYCLES_PER_FRAME,
629
		         "a 60 fps target snaps onto the 59.52 Hz frame");
630
		perf_budget_set_target_fps(59.0f);
631
		check_eq(perf_budget_budget_cycles(), CYCLES_PER_FRAME,
632
		         "and so does 59");
633
	}
634
635
	// ── A late frame boundary is not an overrun ─────────────────────────────
636
	{
637
		configure();
638
		// A frame boundary is detected after the instruction that crossed it,
639
		// so a saturated program overshoots the nominal period by a few cycles.
640
		// That is where the boundary landed, not a budget failure.
641
		for (int i = 0; i < 10; i++) {
642
			perf_budget_step(CYCLES_PER_FRAME + 3, 0x2000, 0, false, 0);
643
			perf_budget_frame_end(0);
644
		}
645
		perf_window_t w;
646
		perf_budget_window(0.0f, &w);
647
		check_eq((uint32_t)w.overruns, 0,
648
		         "overshooting the frame by one instruction is not an overrun");
649
650
		// Well past the slack still is.
651
		configure();
652
		for (int i = 0; i < 10; i++) {
653
			perf_budget_step(CYCLES_PER_FRAME + 4000, 0x2000, 0, false, 0);
654
			perf_budget_frame_end(0);
655
		}
656
		perf_budget_window(0.0f, &w);
657
		check_eq((uint32_t)w.overruns, 10, "but a real overshoot still is");
658
	}
659
660
	// ── Disabled costs nothing and records nothing ──────────────────────────
661
	{
662
		configure();
663
		perf_budget_set_enabled(false);
664
		check(!perf_budget_is_enabled(), "profiling can be switched off");
665
		run_work(4, 100);
666
		perf_budget_frame_end(0);
667
668
		perf_frame_t f;
669
		check(!perf_budget_last_frame(&f), "and then records nothing");
670
671
		perf_window_t w;
672
		check(!perf_budget_window(0.0f, &w), "with no window to report");
673
	}
674
675
	// ── Reset forgets history but keeps the configuration ───────────────────
676
	{
677
		configure();
678
		perf_budget_set_target_fps(30.0f);
679
		run_work(4, 10);
680
		perf_budget_frame_end(0);
681
		perf_budget_reset();
682
683
		check_eq((uint32_t)perf_budget_frame_count(), 0, "reset empties the ring");
684
		check_eq(perf_budget_total_frames(), 0, "and the frame ordinal");
685
		check_eq((uint32_t)perf_budget_frames_per_budget(), 2,
686
		         "while leaving the target fps alone");
687
688
		perf_budget_set_enabled(false);
689
	}
690
691
	return x16_test_summary("perf_budget");
692
}