Skip to content

stats: replace TPS with: fps - tk - f/s - f/t, and fix what it was hiding - #87

Open
JayBeest wants to merge 29 commits into
murkantor:mainfrom
JayBeest:tick-field-accounting
Open

JayBeest wants to merge 29 commits into
murkantor:mainfrom
JayBeest:tick-field-accounting

Conversation

@JayBeest

Copy link
Copy Markdown

Wow, this went much deeper than expected. What started as an overlay fix turned into a full-on deep dive — the short version is below, the long version (what was executed vs. only read, and what's knowingly still open) is in the report that follows. Provided as-is.

On the +6K diff: that's mostly tests and docs, not shipped code. Three test harnesses — and honestly a fair chunk of this branch is testing the tests. One of the harnesses was previously modelling the pump in the wrong order and could not have caught the bug it was meant to catch; the suite was mutation-checked (reintroducing the old ratchet rule makes it fail) because "a green suite that can't fail" is this branch's recurring failure mode.

Summary: replaces the lying TPS readout with trustworthy fps/tk/f/s/f/t numbers, fixes four defects that surfaced behind it (D4–D7), fixes a midas.ini mode-record bug (F3-A), and fixes an audio pump ratchet that discarded its own learning below ~8.6 fps.


PR report — tick-field-accounting

A report for whoever reviews this branch, not the PR description itself. It says
what the branch changes, what was executed, what is only read, and what is
knowingly still open.

Base: audio-pump-wallclock_gen4 at 3a1ab62, not main. The branch is
stacked on the previous audio-pump work, which is 18 commits ahead of
main (4bab4e7). Diffed against main this PR would be 40 commits and 234
files, dragging in the macOS 64-bit port, the 32-bit ABI notes, the GCC 15 cmake
fix and 1.0.2b. Against its actual base it is the 33 files described below.

State at ef8a6c6, the tip when this report was last touched. Every number
here is dated on purpose: this branch has miscounted itself six times, and a
count written without a way to re-derive it rots silently. Run these before
quoting any of them.

git rev-list --count 3a1ab62..HEAD     -> 22 commits
git diff --stat 3a1ab62..HEAD          -> 33 files, +6,247 -239
git rev-list --count origin/main..HEAD -> 40 commits (the stacked total)
git status                             -> clean
git branch -vv                         -> local only; not yet pushed
build                                  -> clean; only the pre-existing
                                          "reducing alignment of section __DATA,__common" warning
ctest                                  -> 9/9

Not pushed. The branch tick-field-accounting exists only in the submitter's
checkout. origin/audio-pump-wallclock_gen4 is untouched and still at 3a1ab62,
so any PR open against it is unaffected. Push it with
git push -u origin tick-field-accounting; nothing needs rewriting first, and no
commit was rewritten by the rename — every SHA the branch cites that resolves is
still on it. (Two SHAs cited inside tps-the-lie.md did not resolve, from an
earlier rewrite; that is fixed in the same commit as this report's correction.)


1. What the branch is for

One defect, followed down. The overlay showed TPS, and TPS was
min(tick rate, 60) — it tracked the frame rate below 60 and read a flat 60
above it, so it described neither the tick rate nor game speed. Replacing it
with numbers that could be trusted (fps, tk, f/s, f/t) exposed three real
defects behind it, and the audit of that work found more:

  • D4 — a negative TPS for one stat window after every stage load.
  • D5 — tickhz=hw waited up to 30 fields for a frame and credited 8, silently
    discarding up to 367 ms of simulated time per frame. Measured at 45–50 fields
    per frame in Dam.
  • D6/D7 — f/s read a healthy 60.0 during every pause; the D4 guard could
    itself publish a negative under a render-thread race.
  • F3 — a session running hardware pacing left a stale numeric tickhz in
    midas.ini, so the file described a mode that had not run.
  • The pump's ratchet — found while checking an unrelated audio complaint: the
    queue target was max(ratcheted, one interval), so below ~8.6 fps the interval
    floor won unconditionally and every underrun the ratchet learned from was
    discarded on the next pump.

Two pieces of test work are the reason the branch is worth reading:

  • the frame/tick arithmetic and the whole D5 policy table are pure functions
    with their own ctest targets, so the numbers a player sees and the rule that
    decides how much simulated time a frame keeps are both checked without a
    window, a GPU or a ROM;
  • the audio pacing simulation, which had been modelling the pump in an order the
    pump does not use — so starvation was unobservable by construction — now
    models it correctly, and that is what let the ratchet defect be reproduced.

2. The commits, in phases

Read them as units. Every commit through af78f4a was checked out in a separate
worktree, built and run against ctest individually (recorded in
audio-pump-state-of-play.md; this session did not re-check each commit); the
four after it are documentation amendments to the pages below.

  • A. The numbers — ca41f25, 97659f6, 85fa9f1. The frame and tick maths
    as pure functions (port/src/tick_stats.c), fields/s published, the block
    showing tk / f/s / f/t instead of TPS.
  • B. Documenting and instrumenting them — 9873f43, bbc5c99, 2f8a6d0,
    6d02aa1. The design record, the manual claims, TPS dropped from the log,
    the hw field hold added to the hitch line.
  • C. The audit and its retraction — 52807f7, cb98c39, dbed958. A
    third-pass execution audit falsified four of phase B's claims; these correct
    them and replace a hitch-line comparison that read "healthy" at the moment the
    most time was being dropped (F1) with a measurement at the clamp site.
  • D. The two deferred behaviour changes — 87af349 (F3-A, the tickhz
    writer) and 17bf80b (D5, the stall cap yielding to a hardware-pacing hold).
  • E. What that exposed — ef16465, df881c9, f04d421, af78f4a. The
    state-of-play page, the pump ratchet fix, the harnesses made durable, a
    documentation sweep, and the decision not to fold the history.
  • F. This session — 13fedc5, 7b99f9c, e06105f, and the citation-audit
    fix. The audio
    simulation put in the pump's order with jitter as a scenario field, the two
    missing documents resolved, the branch renamed, and a pre-push audit's findings
    applied (a dead citation, a unit error in the new document, stale counts).

A five-phase reading map with the same content lives in
docs/port/audio-pump-state-of-play.md.


3. What changes for a player

Five behaviour changes, all deliberate, four of them only under options a player
has to turn on:

  1. Overlay row 1 reads fps, tk, f/s, f/t instead of FPS and TPS
    (port/src/bondview2.c). TPS is gone from audio_pump.log too.
  2. f/s reads 0 while paused, instead of a false healthy 60.0 (D6).
  3. No negative TPS after a stage load (D4/D7) — moot now that TPS is not
    displayed, but the guards are in the stat pass and the reset path.
  4. midas.ini records the tick mode. A session running hardware pacing now
    writes tickhz=hw rather than leaving whatever number was there (F3-A). One
    consequence worth knowing: a hand-written tickhz=hw outranks maxfps
    across a launch, which is pre-existing and recorded as such.
  5. Under hardware pacing, a held frame is credited the fields it waited for
    (D5), and below ~8.6 fps the underrun ratchet actually reaches the queue
    (the pump fix).

Everything else on the branch is diagnostics, documentation, or tests.

For completeness: 13fedc5 and 7b99f9c change no shipped behaviour — the first
is test code only, the second is documentation plus three comment-only source
edits (verified line by line in the diff).


4. Verification — what was executed

Provenance is stated for every number, because this branch has twice shipped a
claim that a later pass falsified.

Executed in this session (2026-10-25, macOS, Apple Software Renderer):

  • cmake --build build_macos -j8: clean except the pre-existing __DATA,__common
    alignment warning. ctest: 9/9 (native_layout, native_collision,
    native_aim, native_stan, native_movement, native_prop_collision,
    native_player, audio_pace, tick_stats).
  • The audio simulation's order finding, re-derived rather than quoted. One
    scenario — 1 fps, every fifth pump 940 ms instead of 870, 120 s (137 pumps) —
    with the pre-df881c9 rule in both arms:
refill-then-drain (the old harness): dry=0
drain-then-refill (the pump):        dry=27     one per spike

The same scenario under the shipped rule: dry=5. The test prints all three
counts, so this is reproducible from the binary.

  • Mutation check on the suite itself. Restoring max(ratcheted, floor) in
    port/src/audio_pace.c makes the pure-function case fail; with that case
    skipped, the simulation case still fails. A green suite that cannot fail is
    the failure mode this branch keeps hitting, so it was checked.
  • tick_stats: a sweep of every stat-window length the pass can close on
    (500–1000 ms) pins the healthy f/s band as arithmetic — 59.1–61.0 for
    nearest-whole-field counts, 58.1–62.0 with one field of boundary skew. The
    manual's measured 59.3–62.9 band therefore needs no defect to explain it.

Executed by earlier sessions on this branch, recorded in the docs, not re-run
here:
F3-A's six midas.ini cases (each from a backup, restored, eeprom.bin
md5 checked); D5's credit 8 of 52 → credit 12 of 57 under GE_HWSCALE=3;
the pump's live ratchet climb target 1041 → 1053 → 1064 ms after a dry; the
Dam stage-load price of 8.7 s.

Read and computed, not executed: the tick/field trace (tick-and-field-accounting.md
§1), the mode table, the model's coefficients, and the frametiming.c decision
that no harness is built for that file yet.

Not verified anywhere: Windows and Linux. Every measurement on this branch is
macOS, most of it from a software-rendered context where frames cost ~466–890 ms.


5. What is knowingly not done

Stated so a reviewer does not have to discover them.

  • src/game/lv.c's comment on g_GlobalTickCount is still false below 60 fps.
    It says the counter "must count 60Hz fields … always true at GE_TICKHZ=60"; at
    15 fps it counts ticks, each worth four fields (f/t 4.00 measured). It is the
    branch's one deliberately-live mislabel, because the fix is a parity decision —
    chraction.c, bondview2.c, propobj.c and gunfire.c all key off the
    counter as if one tick were one field — not a comment edit.
  • Three claims in the shipped manuals rest on inference, not execution: the
    paused f/s/tk pair (consistent with lv.c, never reproduced — the console
    has no pause verb); sub-ticking, f/t below 1.00 (needs a renderer faster than
    60 fps; this machine peaks near 38, lowest measured f/t is 1.03); vsync
    pinning tk at 60 on a real 60 Hz panel. One machine with a working GL driver
    settles all three.
  • D4's race has never been seen live. The reseat is unit-tested and 98
    consecutive live log lines showed no negative, but no stage load has been
    captured inside a stat window with a non-zero pre-load count.
  • The hw hold's blank screen is white, and should be black (user report).
    Deferred twice; recorded in n64-hw-pacing.md §5.
  • An unbounded audio spike still empties the queue. The ratchet is bounded at
    76 ms and a stage load is not. Recorded in audio-pump-history.md §9.
  • src/game/frametiming.c has no test target. The policy it calls is tested;
    the glue that gathers the inputs is not. The decision, the reason, and the
    shape a later pass should take are in audio-pump-state-of-play.md §4.8.

6. Where a reviewer should start

  1. docs/port/audio-pump-state-of-play.md — one page: what the branch set out
    to do, the findings register with a status per defect, and the remaining work.
  2. The two deferred behaviour commits — 87af349 (F3-A) and 17bf80b (D5).
    They are the two that were held back deliberately rather than the only two
    that change behaviour; §3 lists all five, and df881c9's pump fix is the third
    code change a player could feel. Both are small and both carry their own
    objection-and-answer in the message.
  3. port/tests/audio_pace.c — read simulateScenario's header and
    test_harness_order_decides_whether_a_starve_is_visible. If you read one thing
    on this branch, read why the harness that was supposed to catch the bug could
    not have.
  4. port/src/tick_stats.c — 92 lines, pure, and each rule has a case.

The long references are docs/port/audio-pump-audit-pass3.md (evidence) and
docs/port/tick-and-field-accounting.md (mechanism). docs/port/n64-hw-pacing.md
is new on this branch and covers the cost model behind tickhz=hw.


7. Risk

  • Blast radius. The two behaviour changes are gated: D5 only moves the credit
    cap when portTickHwPace() is true, and F3-A only changes text written to
    midas.ini. The overlay and the log are the only surfaces most players see.
  • The largest risk is not in the diff — it is in the confidence. This branch's
    documented failure mode is a fix verified on one shape of input and reported as
    the mechanism working; it happened five times, twice while fixing an earlier
    instance. Section 5 is the list of places where that is still possible.
  • History. The branch is not folded, by decision (audio-pump-state-of-play.md,
    "Why the history is not folded"): every commit builds and passes on its own, and
    folding would break 66+ SHA citations that are the branch's evidence system.
    Four commits contain content a later commit corrects; a checkout of those trees
    gives a lying manual, which is the cost that decision accepts.
  • Review surface. Three NEXT_AGENT*.md handoff files (~1,400 lines) are
    tracked and in this diff. They are process artifacts, not product; say if they
    should come out of the PR before it is opened.

8. How to reproduce the verification

git rev-list --count 3a1ab62..HEAD      # 22
cmake --build build_macos -j8           # only the __DATA,__common warning
cd build_macos && ctest --output-on-failure   # 9/9

# the audio simulation standalone, which is how the order finding was re-derived
cc -std=c99 -Wall -Wextra -Iport/include \
   port/tests/audio_pace.c port/src/audio_pace.c -o /tmp/ap && /tmp/ap

# the tick arithmetic
cc -std=c99 -Wall -Wextra -Iport/include \
   port/tests/tick_stats.c port/src/tick_stats.c -o /tmp/ts && /tmp/ts

iamsh4 and others added 29 commits October 18, 2026 19:55
Convert serialized ROM records to native layouts at load boundaries and remove 32-bit address and runtime-layout assumptions. Add Cocoa/SDL build and fullscreen support, ROM-free regression fixtures, macOS CI, build documentation, and converter attribution.
Fixes the crackle/pop and slow-music report in murkantor#82, and the reload-sound
latency that preceded it.

The pump in 1.0.2b took its render count from the simulation's field counter
(src/game/frametiming.c currentFrameCounter) and corrected an over-full queue
by skipping whole rendered field frames. Both are wrong in ways that compound:

  - alAudioFrame is the ONLY thing that advances the game's audio clock. The
    sound player's event queue and the three music sequencers all move in
    rendered samples (src/libultra/audio/synthesizer.c:143-227). So the render
    count is simultaneously "how much audio" and "how fast audio time runs",
    and tying it to the game loop means every stalled frame (stage load, the
    intro swirl, a window drag, alt-tab) delivers no audio: the device drains,
    plays silence, and resumes with a discontinuity. That is a click. Often
    enough, it is continuous crackle and music that runs behind real time.
    It also made audio behaviour depend on maxfps, since at uncapped rates the
    field delta is usually 0 and only the queue-low branch rendered at all.

  - Dropping a rendered frame throws away ~16.7 ms of synthesised audio
    mid-stream. That is a step discontinuity by construction - the N64 never did
    it. src/audi.c:529 sized the frame from the AI's remaining buffer instead
    ((g_FrameSize - (osAiGetLength() >> 2)) + 16 + EXTRA_SAMPLES): a
    sample-level servo, inaudible correction. The port lost it because
    portAudioPump calls alAudioFrame directly and the shim stubs osAiGetLength()
    to 0.

Both are fixed by splitting the one decision into the two it always was:

  - A LIVE DEVICE SETS THE RATE. Refill toward the target depth. The render
    rate then equals the DAC's consumption rate - the queue is stable, device
    clock drift is absorbed, and audio time is the hardware's own time. This is
    what amHandleFrameMessage did. The correction is a few samples of frame
    length, so nothing is ever dropped or duplicated.
  - THE WALL CLOCK SETS THE RATE ONLY WHEN THERE IS NO USABLE DEVICE, and those
    samples are discarded. Game audio time still advances, so a missing or
    wedged sink cannot freeze the sequencers or saturate the SFX queue.

Also in this change:

  - Target raised to 40 ms (from 40.6 ms nominal, but now actually held) and it
    ratchets up on an underrun, easing back only in a clean 10 s window. The
    effective target is floored at one pump interval plus a device period: a
    target shallower than the interval between pumps starves by construction.
  - The pump-interval floor is why 40 ms is enough. At 60 fps the interval is
    16.7 ms, so the floor never binds; below ~20 fps it takes over.
  - SINGLE-DIGIT FRAME RATES ARE SUPPORTED. Classic and demo modes can drop
    there, and at N fps the pump runs N times a second, so each pump must bridge
    1000/N ms of audio. A fixed 16 KB cap holds only 185 ms, which is less than
    one interval at 5 fps, so the cap now scales with the interval (and the
    per-pump render budget with it). Stated plainly: at those rates the backlog
    IS the audio latency - 500 ms at 2 fps. That is inherent to pumping from the
    render loop; continuous audio at high latency beats crackle at low latency.
  - portAudioPumpForce no longer floods the queue during music.c's track-stop
    spins, which used to push seconds of audio into a 186 ms buffer and leave
    the trim to clean up. A forced frame advances the sequencer and is queued
    only when the queue is genuinely shallow.
  - The queue cap is checked BEFORE alAudioFrame, not after. Rendering a frame
    that cannot be queued is not free: alAudioFrame advances the game's audio
    clock, so the drop path both burned the time and left the clock ahead of
    what was delivered. Nothing is now synthesised that cannot be delivered.
  - The pacing rules moved into port/src/audio_pace.c + port/include/audio_pace.h
    as pure functions - no SDL, no engine headers, no globals, no clock reads -
    driven by port/tests/audio_pace.c under GE_PORT_TESTS=ON (ctest -R audio_pace).
  - GE_AUDIODBG=1 prints a one-second trace (pumps, samples rendered vs needed,
    queue open->close vs target, clock credit, stalled, forced) so the next
    audio report can be read instead of guessed at.

Two bugs this change found by running the game rather than reasoning about it,
both now pinned by tests named after them:

  - The first cut paced on the wall clock AND steered the queue from the same
    budget (credit time, add a bounded bias toward the target, charge it back).
    It compiled and passed 15 tests including a closed-loop simulated device,
    and it starves on hardware: if total rendered must equal wall time while
    the device also consumes wall time, the queue depth is a constant of
    integration. A bounded bias changes when samples arrive, never the running
    total. Measured: queue cycling 1.2-3.6 KB against a 10 KB ratcheted target,
    24 dry pumps in the first 10 s.
  - Rounding the refill DOWN to the 16-sample alignment loses a few samples on
    every pump - ~11% short at 300 fps, which drains the queue whatever the
    target is. Round up; the "at or above the mark" early return is the
    hysteresis.

And one caught by review after the fact, now fixed: the over-cap check sat after
  alAudioFrame, so a frame that did not fit was synthesised and then discarded,
  which is exactly what the comment above it claimed to prevent.

Known limits, deliberately left for their own changes:

  - The device is still opened at an exact 22050 Hz with allowed_changes = 0.
    On Windows shared mode (48000) that puts an OS resampler and a fallback
    driver between us and the DAC, with mixer quanta near our 11.6 ms device
    period - a plausible environmental cause for murkantor#82 that we cannot reproduce on
    macOS. Briefing for the SDL_AudioStream fix: docs/port/rate_prompt.md.
  - A forced frame advances the sequencer's musical time without delivering it
    when the queue is not shallow, so a track-stop burst can push the playing
    tracks slightly ahead. Pre-existing (1.0.2b rendered and dropped the same
    frames) and bounded by the stop; the real fix is wall-clock pacing those
    spins in music.c.

Verified on macOS/CoreAudio at 22050 Hz, 256-sample period, title screen
(stage 90), 24 s run with GE_AUDIODBG=1: before, queue 1216->3584 B against a
10240 target with 24 dry pumps in the first window; after, queue steady at
3556 B against a 3520 target and no `ge: audio: last N s:` health line at all -
zero dry pumps, zero over-cap drops, zero skipped frames. ctest: 8/8, including
the 16 audio_pace cases (which cover 1-300 fps). Not verified on Windows, which
is the reporter's platform; the low-frame-rate path is covered by simulation
only, not by a real single-digit-fps run.

Full background, including the four earlier pump designs and why each was
replaced: docs/port/audio-pump-history.md.
`cl_audio_pump 1` draws the pump's state as six rows in the top-left corner:
FPS and TPS, pump calls against the ones that did work, queue against its
target and the cap, ms of audio rendered against the ms of wall time it was
worth, the four failure counters, and the stall verdict with the wall-clock
credit and device rate. `cl_audio_log 1` writes the same numbers, in the same
units, to Data/Logs/audio_pump.log once a second, flushed per line so a crash
keeps the tail. Neither is a midas.ini key: they are diagnostics, and a restart
should not leave one on because it was typed once.

The block REPLACES the FPS counter rather than sitting under it. show_fps draws
one number there and the block carries FPS and TPS itself, so with both on the
same two figures would print twice; the digits are suppressed while the block
is up, and the block takes over the corner the strip used, not a row below it.

The snapshot lives in port/include/port_audio_stats.h and is filled by the pump
in audio_pc.c, which already computed every number for its health line and
GE_AUDIODBG trace; this only publishes them. FPS and TPS are deliberately not
copied into the snapshot - the overlay reads g_PortStatFps/Tps directly, so the
block cannot disagree with the strip it displaces.

Three things the field work changed:

  - UNITS, on every field. The first cut printed window totals under a "/s"
    (`SND 11008/11025 /s`), which reads as a rate and is not one. Samples are
    now milliseconds of audio; CLK is milliseconds rather than samples x1000;
    DEV carries Hz.
  - PMP. portAudioPump runs every scheduler iteration, not once per drawn
    frame, so the call rate (~133k/s) dwarfs the useful work and says nothing
    about sound. The row now shows PMP and RND - calls per second against the
    ones that rendered anything (~130/s), which is the pair that makes an
    over-serviced pump visible. The old `IN` field is gone: at sub-millisecond
    intervals it read 0ms forever, a loop-speed number wearing an audio label.
  - One theme per row, six rows. FPS/TPS, PMP/RND, QUE/CAP, SND, the counters,
    and STALL/CLK/DEV - previously the first row mixed three unrelated
    measurements and the odd `130r` suffix had no unit at all.

And one bug found by running it rather than reading it: the window accumulators
were fed only while the overlay was on, so `cl_audio_log 1` on its own wrote a
file of plausible-looking zeroes. Both consumers now feed the window.

The log then gained two things the screen deliberately keeps out:

  - EXACT FIGURES ALONGSIDE THE READABLE ONES. `snd` carries the raw sample
    counts as well as ms, and `que` carries ms of audio as well as bytes. One
    sample is 0.045 ms, so the ms form rounds away anything under ~22 samples -
    a slow leak of a few samples per pump is invisible on screen and obvious in
    the counts. Queue in ms answers the other question a user actually asks
    ("why does the sound lag?") in the unit they experience. Both forms sit in
    the line, so a log line still reads beside a screenshot.
  - A WALL-CLOCK START STAMP in the header, with t= per line left relative.
    Nothing else the game logs carries a clock, so without this a report says
    "it happened at t=45000ms" and that cannot be tied to a crash.log mtime, a
    `ge: stats` line, or a user's "about 21:40". Wall clock on every line was
    rejected on purpose: repetition, and a mid-run clock correction would make
    the file read out of order, whereas relative t= cannot go backwards.

The block is gated per row on dynGetFreeGfx2, using the console's own 12-Gfx-
per-glyph rule. It is ~130 glyphs where the FPS strip draws two or three; a
cramped frame now shows a short block instead of overrunning the display list.
Weak stubs in libultra_shim.c keep the headless test targets linking, since
native_player.c already links bondview2.c.

tools/console_help.txt is the user-facing manual: what the pump is, how each
field reads, which values are healthy, the FPS-counter interaction, why the log
carries more units than the screen, what the timestamps mean, and what to
attach to a sound report. It ships beside the executable like
midas_ini_help.txt (CMake copy) and is listed in the readme's file table with
the log.

Verified on macOS: ctest 8/8; live runs through the GE_CONCMD harness with both
commands enabled, the log producing one line a second with correct values
(pmp ~147k/s, rnd ~130/s, que 3556/3520B 40/40ms, snd 11008/11025smp 499/500ms,
clk 0ms, all counters zero) under a `# started 2026-10-25 00:29:24` header. The
on-screen block was checked against three screenshots, including the target
easing back from its ratcheted 4544 B to the 3520 B floor and the FPS digits
disappearing when both overlays are on. Not verified on Windows or Linux.
The in-game block publishes rates derived from engine counters, and those
rates had no test: the arithmetic lived inline in gfx_sdl2.cpp's stat pass,
where it can only be checked by looking at a running game at a known frame
rate. This lifts it into port/src/tick_stats.c as pure functions, following
the audio_pace pattern already used for the audio pump's pacing rules, and
drives them from port/tests/tick_stats.c.

Three functions:

  portStatsRatePerSec(delta, dt_ms, scale)
      a counter delta over a window, as a rate per second, scaled and
      rounded. The numerator is widened to 64 bits before the multiply:
      delta * scale * 1000 overflows 32 bits as soon as a window is long
      (a debugger pause, a ten-second stall), and a wrapped numerator reads
      as a plausible but wildly wrong rate.

  portStatsFieldsPerTickX100(fields, ticks)
      the ratio, with 0 for a window that admitted no tick rather than a
      division by zero.

  portStatsReseatTickBaseline(last, now)
      a counter that went backwards is a reset, not a measurement.

The cases pin the three readings that matter: 60 Hz healthy (600 = 60.0
fields/s, 100 = 1.00 fields/tick); 120 Hz, where the tick rate is 120 while
fields/s is still 60.0 and fields/tick is 50; and the stalled window a
software-rendered run produced (8 fields in 479 ms = 167 and 800), which is
what identifies a simulation losing wall-clock time.

No behaviour change: nothing calls these yet. ctest grows from 8 to 9.

Verified: builds clean; ctest 9/9, tick_stats included.
…e TPS

Two things in the frame-timing stats, and one is a bug.

FIELDS. g_GlobalTickCount is not a field counter. lv.c advances it by one per
tick that pushed the clock forward, however many video fields that tick
absorbed, so its rate is min(tick rate, 60): it reads 60 at tickhz=120 and it
tracks fps whenever frames overrun a field. The title bar called it "engine
FIELDS/sec", which was only true while every frame fitted inside one field.

The honest field counter is g_PortFieldsTicked. The first cut of this change
advanced it in updateFrameCounters, from the same integer deltaFrames that
becomes speedgraphframes. That was wrong, and the wrongness is why it now lives
in lvlTick: updateFrameCounters runs BEFORE lvlTick decides whether the tick
counts, and lvlTick zeroes g_ClockTimer when the game is paused or the controls
are locked. Counting at the point of production made f/s read a healthy 60.0
through every pause - a false healthy on the one number that is supposed to be
trustworthy. It is now advanced in lv.c beside
`g_GlobalTimer += g_ClockTimer`, so the field count and game time are the same
sum by construction, and f/s reads 0 while paused.

It is a relaxed atomic because the stat pass and the hitch log read it from the
render thread.

With it, three new published values: g_PortStatTicks (the real tick rate,
from g_PortTickSerial - NOT g_PortTicksAdmitted, which boss.c increments
before its pendingGfx < 2 backpressure guard, so it counts ticks that never
reached lvlTick), g_PortStatFieldsX10 (fields credited per second, x10, where
600 = 60.0 = game speed correct), and g_PortStatFieldsPerTickX100 (fields per
tick, x100, where 100 = one field per tick). The rate maths is the tested
tick_stats.c from the previous commit.

THE BUG. lvlStageLoad zeroes g_GlobalTickCount, and this pass kept its
baseline in a static in another translation unit, so it could not see the
reset. The first window after a load computed 0 - last and published -2x the
pre-load tick count: -60 after half a second of play, -2400 after twenty, for
up to 500ms, into the title bar and the audio pump log. A reset is not a
measurement, so the baseline is re-seated through
portStatsReseatTickBaseline and that window reads 0. g_GlobalTickCount is
snapshotted once for the arithmetic, so a reset landing mid-window cannot
reproduce the publish the guard exists to prevent.

g_PortStatTps survives unchanged for the log and the title bar, with its real
meaning written at its definition so the next session does not re-derive it.

Verified: builds clean; ctest 9/9; a software-rendered run publishes
f/s 16.8 / f/t 8.00 where the credit cap is pinned, and 60.0 / 1.00 on a
normal frame.
The first row of cl_audio_pump read `FPS 60fps TPS 60tps`, and TPS was not the
tick rate. It could not be: lv.c advances g_GlobalTickCount once per tick that
pushed the clock forward, so its rate is min(tick rate, 60). A 120 Hz sim
printed 60, and below 60 fps it simply tracked the frame rate - the reading
that prompted this work was `15fps 15tps` in a tickhz=hw session, which looks
like a broken counter and is in fact the console's own behaviour.

Row 1 is now the four numbers that cannot mislead:

    %dfps %dtk %d.%df/s %d.%02df/t

fps presented frames, tk sim ticks per second, f/s fields credited to the sim
per second (60.0 = game speed correct, at any tick rate and any frame rate),
and f/t fields absorbed per tick (1.00 = one field per tick, above 1.00 =
frames overran a field and the tick carried the difference, below 1.00 = a
raised tick rate sub-ticking one field). tk is g_PortTickSerial, which advances
once per lvlTick call - NOT g_PortTicksAdmitted, which boss.c increments before
its pendingGfx backpressure guard and so counts ticks that never stepped.

f/s is the invariant that was missing from the display entirely; it is the
number that says whether simulated time is keeping up, and the only one that
exposes a sim losing wall-clock time.

The audio pump log gains the same two figures beside the old pair rather than
replacing them, so a log line still reads against older captures:
`FPS %d TPS %d tk %d f/s %d.%d f/t %d.%02d`. Its legend now states what TPS
actually is, and why f/s is the one to trust.

Both consumers still read the same globals the strip uses, so the block and
the file cannot disagree; port_audio_stats.h keeps saying so, now naming all
four.

Three comment-only corrections ride along, all of them fixing text that claimed
more than the code does. libultra_shim.c's weak portVideoShowFps default said
"no FPS/TPS overlay" when nothing draws TPS any more. pcsched.c and video.c
described the pump and the tick coupling in terms the counters do not support.
None changes behaviour.

Verified: builds clean; ctest 9/9. Live, the row reads 60fps 60tk 60.0f/s
1.00f/t on a healthy frame, 832fps 832tk 60.0f/s 0.07f/t uncapped, and
15fps 15tk 59.8f/s 4.00f/t in a level - the last of which is the reading that
proves no game time is lost when frames overrun fields.
tickhz=hw prices each frame at the number of video fields the console would
have needed (portHwPaceFields, 1..30) and boss.c holds the tick for that many
fields. The credit path does not have to agree: waitForNextFrame clamps a
measured tick at GE_TICKMAXSTEP, which defaults to 8 fields. Any frame the
model prices above 8 is waited for in full and then credited only 8, so the
simulation silently discards the difference and the game falls behind real
time. At the model's own ceiling of 30 fields that is 22 fields, 367ms, gone
per frame.

That is invisible at 60fps, where every frame is one field, and it is the
reason a field-rate figure was needed at all. This adds the pair to the hitch
line, where it is only printed when a frame was already long enough to log:

    sim: ... 1 tick(s) 8 fld model held 12

`fld` is the fields credited to the sim since the previous frame
(g_PortFieldsTicked), `model held` is what the pacer asked for. Equal is
healthy; fld short of it is lost simulated time.

The value is READ from g_PortHwFieldsHeld, published by portHwPaceFields in
the previous commit, and deliberately not recomputed here: this runs at present
time, which is the render thread whenever render_thread=1, and
portHwPaceFields mutates the model's statics and consumes the pending load
charge through portHwTakeLoadMs (a non-atomic read-then-clear that would drop a
charge arriving between the two). Both counters are read with relaxed atomics,
as every other cross-thread counter in this file is.

`model held 0` means the model has not priced a frame yet, not that it asked
for zero fields - a frame is always at least one - so the field is omitted
until there is something to print.

Verified: builds clean; ctest 9/9; observed live in a hw-pacing run.
The block's first row publishes rates derived from engine counters, and the six
source files that produce or consume them had nowhere to point for the
reasoning. This is that document.

docs/port/tick-and-field-accounting.md follows FPS and TPS from osGetCount to
the glyph, one hop per line, naming the file and either its line or the
symbol that does the work: the CPU counter, what a 60 Hz field is in its
units, the main loop's admission gate, the field banking in waitForNextFrame,
updateFrameCounters' split into speedgraphframes and portTickDelta, lvTick's
copy into g_ClockTimer, the one-line increment that makes g_GlobalTickCount a
tick counter rather than a field counter, the 500 ms stat window, and the
sprintf. It ends with the sentence the whole thing exists to make
unambiguous: FPS counts presents, TPS counts clock-advancing ticks,
g_GlobalTimer counts fields, and all three shared one word.

Then, in order: what the two digits mean (TPS is min(tick rate, 60), with the
eight-case table showing fields/s at 60.00 in every row); the four tick modes;
the three places the old labels were wrong; the two defects (a negative TPS
published after every stage load, and a tickhz=hw frame held for up to 30
fields but credited at most 8); the warp hypotheses ranked by mechanism; the
options considered and which was taken; a constants table; what is verified
and what is not; and the open questions.

Sections 11 and 12 were added after the implementation, from measurements
rather than reasoning: that the 8-field credit cap does not fire at ordinary
frame rates (a long spell at 15 fps ran f/t 4.00 with f/s 60.0 throughout);
that a Dam stage load is priced at 8742.8 ms and held for all of it with no
rendering, confirmed by playing it, with two thirds of the setup CPU in a single
coefficient; that maxfps=-1 written beside tickhz=0 re-arms hw pacing on every
launch, proven by hw forcing room_cache to 0; and that GE_STAGE launches never
reach the hold. 12 indexes every published number to the code producing it.

The white-blank-screen-during-the-hold question is recorded as DEFERRED rather
than solved, with its two parts kept apart: whether the framebuffer should be
cleared, and whether a loading indication belongs there at all.

Force-added: /docs/* is gitignored because the design docs live in a private
tree, but three others are tracked anyway and five source files cite this one,
so a dangling reference in a fresh clone would be worse than the exception.
Two user-facing manuals shipped beside the executable, and both described the
frame and tick rates in ways the code does not behave. Audited field by field
against the code and against measured sessions (105 and 157 one-second log
lines, plus screenshots of the same build).

console_help.txt had the block's first row wrong at the root: it documented
FPS and TPS, and TPS is not the tick rate. It is min(tick rate, 60), because
lvTick advances g_GlobalTickCount by one per tick that pushed the clock
forward, so a 120 Hz sim printed 60 and below 60 fps it simply tracked the
frame rate. The row is now documented as fps, tk, f/s and f/t, with f/s named
as the number to trust: 60.0 means simulated time is keeping up, at any tick
rate and any frame rate, and below it the game is losing real time.

Three measured examples replace the single one, because one sample cannot show
which figures are supposed to move. At 60, 738 and 15 fps, f/s reads 60.0 in
all three while f/t reads 1.00, 0.08 and 4.00 - the tick rate and the fields
per tick always multiply to the field rate, which is the property the row
exists to expose.

The audit also caught errors the TPS fix did not:
  - the pump described as running "once per frame" when it runs once per pass
    of the game loop (177k calls/s at 738 fps, 34,712/s at 0 fps);
  - PMP described as following the frame rate, which it does not;
  - RND "around 60 a second is the mixer keeping up" when 60 is the floor with
    the loop blocked, the observed range being 56-60 there and 120-170 in
    free-running play - SND, not RND, says whether the mixer kept up;
  - the log's growth rate, which this branch's own longer lines invalidated
    (8 KB a minute was measured at 12).

midas_ini_help.txt said "one game tick runs per frame, so this also sets the
tick rate", which is false for -1 - the mode where it matters most, since under
N64 pacing the tick rate stays 60 while the frame rate falls. It also said
nothing about what a player is buying, so it now records that a stage load is
held for the console's load time, several seconds on a blank screen, and that
entering Dam costs about 9 seconds. maxfps=0 was ambiguous: it is a number, but
it leaves both rates uncapped rather than coupling them.

The audio_pump.log legend in audio_pc.c carried the same PMP and RND claims and
is written into every log file a user attaches to a report, so it is corrected
in the same terms.
The audio pump log carried a TPS field, inherited from the commit that added
the log. It answers a question nobody asks and gives a misleading answer when
they do: lv.c advances g_GlobalTickCount once per tick that pushed the clock
forward, so its rate is min(tick rate, 60). A 120 Hz session printed 60, and
below 60 fps it simply tracked the frame rate - which is the reading that
started this whole investigation, `15fps 15tps` in a Hardware-pacing session.

The two numbers that replaced it in the block answer both halves properly:
`tk` is the real tick rate, and `f/s` says whether simulated time is keeping
up. TPS is redundant against them and adds nothing, so it goes, along with its
legend entry. Old logs and new logs differ by one field; the rest of the line
is unchanged and `t=` still heads it.

g_PortStatTps survives as a global and is still computed. It arrived with the
original decompilation import, nothing reads it, and deleting a symbol from
the imported set is not this change's business.

The in-game `help` still described `cl_showfps` as "the FPS / TPS strip" - text
from the original import that is now false, since no TPS is drawn anywhere -
and described the pump block as sitting "under" the strip when it replaces it.

docs/port/tps-the-lie.md records the whole mislabel: the four places that
shipped it (the manual entry, the log legend, the stat-pass comment, and the
still-live lv.c comment), the three measured rows that disprove it, the
negative-TPS defect, and what replaced it. It is force-added, because docs/ is
gitignored.
A third pass re-derived every claim in these files from the code and from
execution rather than from the branch's own account of itself. Five sentences
did not survive; this corrects them. No behaviour changes.

console_help.txt

  TK said "three quite different things hold it lower" and named the display,
  the machine and Hardware pacing. All three are instances of one gate the
  entry never stated: a tick is taken only once the previous frame has been
  presented (boss.c's pendingGfx guard), so TK tracks FPS whenever drawing is
  what the machine is limited by. Measured across three sessions, tk equals fps
  in 114 of 115 one-second windows, and the user's own captures read 60/60,
  738/738, 15/15, 20/20 and 832/832. The heading is now the general rule, with
  the three cases below it, and the "machine that cannot keep up" case says
  what it actually means - the renderer is behind, not the simulation, which is
  why f/s reads 60.0 beside a low tk.

  f/s said it "reads 0 during a stage load". It does not: two measured loads
  read 0.8 and 1.4, because a tick on one side of the hold lands inside the
  window. "Near 0 (under about 2.0)" is what the log shows, and the diagnostic
  sentence now uses the same threshold.

  f/s also implied 60.0 is the healthy reading. The figure is integer fields
  over a window that is never exactly 500 ms, so it scatters: measured healthy
  readings across four configurations were 59.3 to 62.9, and a run that matched
  every other documented value exactly still produced 62.6 once. The entry now
  says a few percent either side is window jitter, not drift.

  f/t listed "a stage load" among the cases that read 0.00. A load usually
  still lands one tick, so it commonly reads above zero there - measured 1.17.
  Corrected; 0.00 remains right for paused, controls locked and a very long
  frame.

  The log comparison section gained the caveat that FPS, tk, f/s and f/t are
  sampled once per drawn frame, so they repeat their last value while nothing
  is drawn. During the 8.7 s Dam hold the log keeps emitting lines with live
  pump columns beside frozen frame columns, and calling all of it "measured
  over the last half second" was wrong for that stretch.

midas_ini_help.txt

  The tickhz row claimed the value is "rewritten by the game; `hw` when maxfps
  is -1". It is not. videoPersistDerivedTick returns early when tickhz is hw,
  and a run with maxfps=-1 and a hand-written tickhz=120 reached hardware
  pacing in a stage and left the file reading 120 afterwards. The row now says
  Max FPS decides the rate, gives the derivation for each numeric range, and
  says a number left behind under maxfps=-1 is stale and ignored. The writer
  fix is deferred to its own change: this branch does not touch the ini writer,
  and asserting a fix before it lands is the exact error this row already
  shipped once.

audio_pc.c

  The legend written into every audio_pump.log carried the same TK, f/s and f/t
  claims, so it is corrected in the same terms, plus the sampled-per-frame note.

tick-and-field-accounting.md, tps-the-lie.md

  §2.2 records the measured healthy band and that "60.0" throughout the
  document means "in that band"; §11.1 marks its narrow quoted bands as that
  session's readings rather than the definition. D5 gets an explicit DEFERRED
  section - confirmed, not fixed here, with the candidate fix and its
  counter-argument written down - because the clamp is a pacing decision and
  this branch does not touch pacing.

Verified: builds clean; ctest 9/9; the regenerated legend was read back from a
live Data/Logs/audio_pump.log, and line length is unchanged (174-175 bytes), so
the manual's "roughly 10 KB a minute" still holds.
…s hold

The hitch line printed `fld` (fields credited to the sim since the previous
present) beside `model held N` (the field hold the hw pacer priced), and the
comment said "fld short of N is simulated time thrown away". Measured, that
comparison reports the opposite of the truth.

A hw-pacing run in Dam on a software renderer, frames ~367 ms:

    sim: longest 365.6 ms, pacer 0.0 ms, 1 tick(s) 8 fld model held 4

fld (8) exceeds the hold (4), so by the stated rule the frame was healthy.
367 ms of wall time is 22 fields at 60 Hz and the sim kept 8: fourteen fields
discarded, 63% of wall time, reported as fine. Of 240 such lines, 198 read
`8 fld model held 4` and only 2 read `fld < held`, both by a single field.
The two numbers are not comparable: `model held` is what the pacer asked for,
`fld` is what the credit path allowed, and on a renderer slower than the model
the frame spans far more wall time than either. It read most favourably exactly
when the most time was being dropped.

The clamp site is the only place that knows both halves, so the report now
comes from there. waitForNextFrame calls portTickNoteClamp with the raw elapsed
counts, before it truncates them, and publishes the pair as
g_PortTickClampMeasured / g_PortTickClampCredited behind a sequence number.
The hitch line prints `credit 8 of 53` when the cap fired and nothing when it
did not, so the field's presence is the signal and the shortfall is the
difference. Measured over the whole hw-pacing run: 311 hitch lines, 241 of them
carry it, every one with a shortfall (35 to 227 fields per frame, most commonly
45-50). The same windows previously read "healthy". The 70 lines without it are
all frames under 133 ms, below the point where the cap can fire - so the field
appears exactly on the frames long enough to lose time, and on no others.

The arithmetic is two pure functions in port/src/tick_stats.c, tested beside
the existing rate maths: a 367 ms frame is 22 measured fields credited 8, a
cap of 0 means uncapped, a sub-field frame credits nothing without dividing by
zero, and a ten-second stall does not wrap.

`model held N` becomes `pacer asked N` and is documented as what it is - the
hold the model priced, not a baseline for the credit. It is still read from
g_PortHwFieldsHeld and never recomputed here: this runs at present time, on the
render thread when render_thread=1, and portHwPaceFields mutates the model and
consumes the pending load charge.

The sequence number costs one relaxed load pair in the common case and exists
because a render-thread reader could otherwise take the measured value of one
clamp and the credited value of the next, printing a shortfall that never
happened.

Verified: builds clean; ctest 9/9 with tick_stats grown; the new field observed
live in a hw-pacing Dam run (241 of 311 lines carry it, all with a shortfall;
the 70 that do not are all under the 133 ms cap threshold); and 0 of 110 hitch
lines carry it on healthy 60 fps front-end frames, where the cap never fires.

D5 itself - whether a deliberate hold should be clamped at all - is NOT fixed
here. It is a pacing change and is recorded as DEFERRED in
docs/port/tick-and-field-accounting.md §5.2.
The prior two passes on this branch were read-only and both shipped false
statements while correcting earlier ones - one replaced a false sentence in a
shipped manual with its opposite, another shipped a document whose central claim
was that its subject had not shipped yet. A third pass re-derived every claim
from the code and from execution instead. It found six things, two of them
already fixed in the preceding commits, and this commit records the whole of it
rather than only what is still broken.

NEXT-AGENT2.md

  The brief the third pass was handed, kept because it is the reason that pass
  found what the first two could not. It reverses the review direction - the
  documents are the specification under audit and the code is the evidence -
  demands four independent directions (docs to code, code to docs omissions,
  message to diff to tree, and execution), and requires a prediction written
  down before every run. It also states the shapes of error this branch had
  already produced, so the auditor would hunt rather than confirm: a correction
  that inverts the error, a number described as displayed that nothing displays,
  the wrong counter named, zero conflated with "nothing happened", a document
  describing its own uncommitted state, and a caveat that invents a limitation.

  Its staffing rule earned itself: subagent findings must be re-verified by the
  parent, because in this project a child's report had already arrived truncated
  and had already contained an unverified claim about a commit's contents.

docs/port/audio-pump-audit-pass3.md

  Findings with both halves each - where the claim is, where the code or the
  run contradicts it, what is actually true, and a severity. The two that
  mattered: the new `model held N` / `N fld` pair read "healthy" while the sim
  discarded 63% of wall time, and the settings manual asserted a `tickhz`
  rewrite the game does not perform. Four more are sentences execution
  falsified: a load reading f/s 0 when it reads 0.8-1.4, a healthy band of
  59.8-60.5 when measurements ran 59.3-62.9, a `tk` rule stated as three special
  cases, and log columns that freeze during a load.

  §2 lists what could not be verified and why, including three claims that
  need a GPU this environment lacks. §4 is the execution record - ten rows,
  each with the prediction written down before it ran, and the four that
  failed. §6 adds the post-fix numbers, because otherwise they exist only in
  commit messages.

  It also records a mistake of its own, kept deliberately: the `tk` finding was
  first written as "tk cannot exceed the presented frame rate", rated major, and
  both were wrong - `pendingGfx` is tested `< 2U` and the manual's "machine that
  cannot keep up" does describe what was measured. The correction is left
  visible rather than edited away, because inverting an error from reading alone
  is the exact failure this branch has now produced three times.

NEXT_AGENT_V3.md

  The work still ahead, written as a self-contained task for a fresh session.
  Two code items: the `tickhz=hw` writer (the file records a pair the loader
  resolves differently) and D5 itself (hw pacing waits for up to 30 fields and
  the credit path keeps 8). Then the four smaller open items - the lv.c comment
  that is still false, the two cited-but-missing design documents, the three
  claims needing hardware, and D4's fix never having been seen under a live
  reset.

  Each task carries the traps that cost this branch time: the `-2` sentinel and
  the `s_CfgTickSeenInIni` guard in the writer, and the fact that the hw model's
  6-field cap, the 30-field total cap and the 8-field credit cap are three caps
  on one quantity. The verification sections give the command and the number to
  beat, taken from §6 of the audit.

  §8 is new: a fourth, read-only pass that reviewed the three commits landing
  after the audit and signed the branch off. It confirmed F3 against the code
  (`videoPersistDerivedTick` returns before writing when the tick is hw) and F2
  against the admission gate, checked the new clamp-report concurrency by hand
  (sequence start value, torn-pair re-check, ordering against the hitch
  threshold, buffer bounds) and found no race, plus one honest bound: the report
  is last-clamp-wins per present.

  It found one gap the audit did not, and it is the reason §8 exists rather than
  a one-line approval: `credit N of M` and `pacer asked N` are documented
  nowhere a user will read them. `tools/console_help.txt` has no hitch section
  at all - "hitch" appears in it exactly once, in an unrelated sentence. That is
  not a false statement, so it is minor, but the field is the branch's only
  per-frame evidence of D5 time loss and the manual does not explain it.

  §8.3 records how this branch was actually verified, because the method is the
  finding: four passes alternating reading and execution, each catching what the
  other could not see, and every one of them fixing real defects while
  introducing at least one of its own.

The audit is force-added, like the other tracked files under docs/ - without it,
the handoff's first instruction ("read the audit first") would send the next
session to a path that exists on one disk only. That was itself one of the
defects this pass logged against the tree: a reference that resolves to nothing.

This commit was amended before it was pushed, after a fifth read of its own
contents. Five statements in it were wrong and are corrected here, which is the
same pattern §8.3 describes rather than an exception to it:

  - NEXT_AGENT_V3.md §0 said the audit report was untracked, while §7 of the
    same file said it was force-added and this commit had in fact force-added
    it. §0 now states what git ls-files shows.
  - §8.1 restated the audit's retracted claim as "structural, not empirical":
    one tick and one present per loop iteration. The gate is `pendingGfx < 2U`,
    so two ticks per completed task are allowed and no observation here saw
    even one exceeded. Corrected to the two-per-task bound, with the audit's
    114-of-115 measurement carried over.
  - §8.1 cited "FPS 61 tk 61" for a 120 Hz tick with vsync on, attributed to a
    pass-2 session whose report is not in the tree. Pass 3's own attempt at that
    configuration measured fps 30 tk 30, and the audit lists the vsync case as
    unverified. It is now recorded as unverified rather than dropped.
  - §8.5 said "nothing in the review found a false statement", which the two
    items above falsify. The sign-off now rests on what was actually checked.
  - §8.3's "twelve findings, all fixed" for pass 2 has no report in the tree to
    count against, and is marked inherited.

Verified: no source behaviour changes in this commit. Builds clean and ctest 9/9
at the parent; `52807f7` and `cb98c39` were each checked out separately and
re-verified here - 0 errors, 9/9 - rather than taking the earlier review's word
for it. Every SHA cited in either document resolves to a commit, every file path
exists, and the line citations were spot-checked against their targets. The
sign-off's own numbers were checked before being written down: `video.c:929` is
the early return it claims, "hitch" occurs once in the shipped manual, and the
hitch buffer's worst case is 56 bytes inside a 64-byte array.
WHAT WAS WRONG. A session running hardware pacing (maxfps=-1, or a file's
tickhz=hw) left whatever numeric tickhz the file already had.
videoPersistDerivedTick returned early on the resolved -1, and the live
maxfps=-1 path returned before calling the writer at all. The file therefore
described a different mode from the one that ran, which is the invariant
docs/port/tick-and-field-accounting.md 11.3 states and the audit's F3.

WHAT IS TRUE NOW. The writer emits the literal `hw` for hw pacing, through
portTickIniValue() (port/src/tick_stats.c) - a pure function, NULL for the
-2 re-derive sentinel, so that state can never reach the file. A file with no
tickhz line still gets none, and a live maxfps change persists at once in both
directions.

The second half matters as much as the first: who records what the FILE says.
The tickhz handler in videoSetDisplayOption used to record it, but
videoCoupleTickToMaxFps calls that handler with "hw" while the file is still
being read, so the synthetic call could claim the file said hw when it said a
number. That is why F3 stayed invisible. The ini parse loop now records the
file's own line, for that key only, before any clamp; the handler records
nothing.

A FIRST CUT WAS WRONG and is recorded in the docs rather than in history: it
used a parse-in-progress flag set around the whole dispatch, which let the
synthetic call record hw again, and it passed only because this machine's
midas.ini lists maxfps above tickhz. With the two lines reversed the file was
still left stale. Caught by the user reading the diff before it was committed;
the second executed case below is the one it failed.

VERIFIED BY EXECUTION (macOS, Apple Software Renderer, GE_FRAMES=90, each case
starting from a backed-up midas.ini, restored afterwards - md5 unchanged):

  maxfps=-1 then tickhz=120  -> tickhz=hw   (logged "midas.ini 120 -> hw")
  tickhz=120 then maxfps=-1  -> tickhz=hw   (the order the first cut failed)
  tickhz=hw then maxfps=-1   -> no write    (file and running state agree)
  maxfps=-1, no tickhz line  -> line still absent
  tickhz=hw + live cl_maxfps 120 -> maxfps=120 tickhz=120
  tickhz=hw then maxfps=120, cold launch -> stays hw, nothing written (the
    file and the running mode still agree; a hand-written hw line outranks
    maxfps across a launch, which is pre-existing and reported, not changed)

VERIFIED BY TEST. tick_stats gains tick_ini_value: -1 -> "hw", 0 -> "0",
60 -> "60", 120 -> "120", -2 and -3 -> NULL, undersized buffer -> NULL.
ctest 9/9; build clean apart from the pre-existing __DATA,__common warning.

NOT VERIFIED. The Windows and Linux ini writers. The GE_NOINISAVE / GE_WINDOW
suppression path is reasoned (the writer now records the value only when
portIniWriteStrs reports a write) but not executed.

DOCS. tools/midas_ini_help.txt's tickhz row said a stale number "is ignored
rather than an override", which this change makes false; it now says the game
rewrites the line to the mode that ran. tick-and-field-accounting.md 11.3
records the mechanism and the six cases; the audit's section 6 records the
resolution and the first cut's failure.
WHAT WAS WRONG. tickhz=hw prices each frame at the fields the console would
have needed (portHwPaceFields, 1..30) and boss.c holds the tick for that many.
The credit path then clamped the measured elapsed time at portTickMaxStep(),
default 8 fields. For any frame priced above 8 the loop waited the full N and
kept 8, so N-8 fields of simulated time were discarded per frame - the audit
measured 45-50 fields per frame in Dam. The cap cannot tell a deliberate hold
from an unintended stall, so it was truncating the hold it had just been given.

WHAT IS TRUE NOW. Under hardware pacing a tick is credited max(cap, hold)
fields. The policy is one pure function, portStatsStepCap() in
port/src/tick_stats.c; portTickCreditCap() in frametiming.c only gathers its
inputs.

The objection recorded in docs/port/tick-and-field-accounting.md 5.2 was that
threading the expectation through boss.c adds a cross-module contract that can
itself be wrong. The answer is where the hold is read, not an assertion that it
is safe: boss.c is not involved. portHwPaceFields() already published the hold
it priced (g_PortHwFieldsHeld, added by 6d02aa1 for the hitch log), so the
clamp reads a number the pacer wrote. The flow stays one-way - the pacer
publishes, the clamp reads - and no new parameter or call site exists.

What keeps the cap honest, each clause a test case:
  - the raise is only to the hold, and portHwPaceFields caps the hold at 30
    fields, so a 90-second window drag cannot become a 90-second step;
  - only under portTickHwPace(), which is already false in menus, in demos and
    outside a stage - so a drag there still clamps at 8;
  - an explicit GE_TICKMAXSTEP is obeyed exactly, because that knob is how this
    cap is measured;
  - 0 still means uncapped, never "cap of zero".

The elapsed wall clock is still measured and still compared - the hold sets the
ceiling only. A patch that trusted the hold blindly would simulate time that
never elapsed, and that alternative was rejected for that reason.

THE THREE CAPS (design record 10), decided together: the 8-field credit cap is
the one that moved. The 6-field render cap is left alone - it bounds the fit,
not the tick, and 10's open question about whether that fit saw load hitches
stays open. The 30-field total cap is left alone and is now the bound on this
raise as well.

VERIFIED BY EXECUTION (macOS, Apple Software Renderer, GE_STAGE=dam,
GE_HWSCALE=3 to price holds above the cap, cl_audio_log 1, user's midas.ini
restored afterwards - md5 unchanged, Data/Saves untouched):

    8 credit 8 of 52 pacer asked 6      hold under the cap: still clamps at 8
    6 credit 8 of 54 pacer asked 6
    1 credit 12 of 57 pacer asked 12    hold over the cap: the hold is admitted

One frame in fifteen carried a hold above 8, and that frame's credit moved from
8 to 12 - the change is confined to the frames it is about. The 14 frames with
a 6-field hold clamp at 8 as they should: 6 < 8, and the rest of their 52
fields is the software renderer, not a hold. GE_HWSCALE=3 also explains why
holds above 8 are rare in practice - scaling the render model 3x saturates it
against its own 6-field cap, so a 12-field hold is 6 render fields plus a
~100 ms load charge. The raise lands on load frames, which is the population D5
was about.

VERIFIED BY TEST. tick_stats gains stall_cap_hold: hold under/over/at the cap,
not under hw pacing, explicit GE_TICKMAXSTEP, uncapped. ctest 9/9; build clean
apart from the pre-existing __DATA,__common warning.

NOT VERIFIED. Real hardware. Every number is from a software renderer where
frames cost ~890 ms, so the renderer sets the frame length; the raise is
exercised but a correct hw session at the model's 30-field ceiling is not
reproduced here, and the 30-field boundary is covered by the unit test only.
Windows and Linux not run. Audit 2's hardware items stay unverified.

DOCS. tick-and-field-accounting.md 5.2's DEFERRED section now records the
decision, the rejected alternative, the three-caps ruling and these
measurements; 10 and the constants list follow it. The audit's 3 bullet is
annotated and 6 records the resolution. tools/console_help.txt gains a THE
HITCH LINE section - closing audit 8.2, which found that credit N of M and
pacer asked N, this branch's only per-frame evidence of the loss, had no
shipped text anywhere. It also says which of the two to read before blaming the
cap.

NOTE ON AUDIO. The same run's audio columns (dry 0 over 0 skip 0 stall 0, one
dry event in 23 lines, que ~880 ms against a 927 ms cap) show the pump was not
starving at 1 fps; the audible artefact in that configuration is the world
running at 9.2 fields/s while music is sequenced at wall rate, not this change.
No claim about audio on real hardware is made here.
One page that says what the branch set out to do, what is fixed, what is not,
and which claims rest on measurement. It does not replace the audit or the
design record - it says which to open and why, which is the thing the last two
handoffs had to spell out in prose each time.

The register covers F1-F6, D4-D7 and the audit's 8.2 gap with commit IDs, and
the open items are ordered by what a reader should do next rather than by
severity:

  4.1 two cited documents still do not exist (doable now; 11 of the design
      record is the evidence that belongs in the first one)
  4.2 lv.c's comment still asserts something false (needs a parity decision,
      not a comment edit)
  4.3 three manual claims need hardware, not care
  4.4 D4's fix has never been seen under a live reset
  4.5 the hw hold's blank screen is white
  4.6 NEW, reported and not fixed: the audio pump sizes its queue to about one
      frame, so a frame that overshoots the target drains it (one dry event in
      23 lines at 1 fps)
  4.7 Windows and Linux, and anything above 60 fps

Two things in it are corrections rather than summary. Item 4.6 is a defect found
while checking an audio complaint and not investigated; it is recorded so it
does not evaporate. And 7 states the failure mode plainly, with all three
instances on this branch, because it is the one the next pass is most likely to
repeat: a fix verified on one shape of input, reported as the mechanism working.

Force-added, as the other three docs are: docs/ is gitignored and a new file
there is otherwise on one disk and nowhere else. Same defect the doc describes
in 4.1.
WHAT WAS WRONG. The pump's queue target was max(ratcheted, interval floor).
The two are in different units of thought. The floor is a frame-RATE quantity -
one pump interval plus a 12 ms device period, ~870 ms of audio at 1 fps. The
ratchet that learns from underruns is an ABSOLUTE byte budget: +1024 bytes per
dry, ceiling TARGET_MAX = 10 KB = 116 ms. So at any frame rate whose interval
exceeds 116 ms - below about 8.6 fps - the floor won unconditionally and every
underrun the pump learned from was discarded on the next pump.

The effect in the field: the queue held exactly one interval plus 12 ms, i.e. a
12 ms tolerance for the next frame being slower. At 1 fps that is 1.4% of the
interval, and ordinary frame-to-frame variance walks through it. The pump had a
safety net for underruns and could not deploy it exactly where underruns happen.

WHAT IS TRUE NOW. portAudioPaceEffectiveTarget() (port/src/audio_pace.c) returns
max(configured target, interval floor) + learned, where `learned` is the ratchet
above the configured target. The headroom lands on top of the floor instead of
competing with it. Ordinary frame rates are unchanged - there the configured
target is the base and the learned term is what moves - and the raise is bounded
by TARGET_MAX - TARGET (76 ms), so the backlog cannot grow without limit.

WHY NO EXISTING TEST SAW IT. Every simulation in port/tests/audio_pace.c pumped
at a CONSTANT interval, and a pump that measures the elapsed interval and refills
for it cannot starve on an even cadence - it sizes for exactly the interval it
just lost. That includes the single-digit-fps test, which asserts dry == 0 at
8, 5, 2 and 1 fps and is correct at those rates only because their intervals are
identical every pump. The new simulate_jitter drains the elapsed interval BEFORE
the refill, so an interval longer than the one before it starves the queue.

VERIFIED WITHOUT A DEVICE. 1 fps, every fifth pump 70 ms longer, 120 s of
simulated time, both policies driven from the same ratchet so only the combining
rule differs:

    legacy  max(ratcheted, floor):  27 dry events, peak queue  952 ms
    now     floor + learned:         5 dry events, peak queue 1011 ms

Five and not zero because the ratchet learns 11.6 ms per underrun and the first
spikes are surprises - the mechanism working as designed, not suppressed. The
cost is ~59 ms of extra latency at 1 fps, against a documented preference for a
backlog over a gap at single-digit frame rates (audio-pump-history.md 7,
console_help.txt).

VERIFIED LIVE (Apple Software Renderer, Dam, 1 fps, cl_audio_log 1): after a
`dry` at t=43.5 s the target climbed 1041 -> 1053 -> 1064 ms, one 1024-byte step
per pump. Under the old rule it stayed pinned at interval + 12 indefinitely.

ctest 9/9; build clean apart from the pre-existing __DATA,__common warning. The
user's midas.ini is unchanged (md5 6581028b...) and Data/Saves was not touched.

NOT FIXED. A spike larger than the whole learned ceiling still empties the queue;
one was observed at the transition into Dam. No bounded ratchet covers an
unbounded spike, and covering it would mean holding an unbounded backlog.

NOT VERIFIED. Real hardware. Every number here is a software renderer, and
whether a 76 ms cushion is audibly enough is not something this machine can say.

DOCS. audio-pump-history.md gains 9 (the finding, both measurements, the cost,
and what is left), and its two statements of the old rule are corrected there and
in 8's item 2. audio_pace.h documents the combining rule and why it is a sum. The
same doc's test-count line said 16 cases and was already stale; it is 22 now.
docs/port/audio-pump-state-of-play.md 4.6 moves from "reported, not fixed" to
fixed with the numbers.
Two investigative artifacts from the last session were thrown away or nearly so,
because they were written into build_macos/ - which .gitignore:274 ignores. The
measurement behind NEXT_AGENT_V4.md section 2.1 had to be reconstructed as a
result. Both are now in the tree, in the place their kind belongs.

port/tests/ (asserts) - port/tests/audio_pace.c gains
test_harness_order_decides_whether_a_starve_is_visible. simulate() refills to
the target and THEN lets the device drain, so the queue is always topped up
before it can empty: starvation is unobservable at any frame rate, under any
jitter, with any target rule. The pump does the opposite - it runs after the
interval elapsed. The new case drives both orders with the same policy and the
same jitter (simulate_jitter gains a refill_first switch), and asserts
refill-first dry == 0 while the game's order starves. Keep it after every
harness pumps in the game's order: it is the evidence for why the order matters
and it fails loudly if refill-first returns as an optimisation.

Measured while writing it: 1 fps, every fifth pump 940 ms instead of 870, 120
pumps, legacy max(ratcheted, floor) rule in both arms - refill-then-drain dry=0,
drain-then-refill dry=24.

scripts/port/ (a human reads the output):

  audio_run.sh        time-bounded live run. Backs up and restores midas.ini,
                      compares eeprom.bin's md5, refuses to start beside another
                      copy, stops as soon as -w matches -n times, reports the GL
                      renderer. Every one of those was a rule rediscovered by
                      hand each session.
  audio_pump_log.py   reads a captured audio_pump.log: starves, queue-vs-target,
                      target trend.

The analyser took three wrong verdicts before it was honest, and its header now
records all three, because the next person to improve it should know they were
already tried: a target rising across a frame-rate collapse is the interval
floor moving, not the ratchet working; `dry` is a per-window counter and the
ratchet steps on the next pump, not the same one; and a 1 Hz sample cannot
distinguish "headroom was added" from "the frame rate settled lower". It now
prints the evidence and states that limit, pointing at the simulation that can
settle it. A script that guesses is worse than no script.

Distinguishing the two logs it was checked against: the pre-fix capture reads
970 -> 920 after its first starve, the post-fix one 1028 -> 1064, and the script
reports the second as consistent with the ratchet adding headroom without
claiming it proves so.

DOCS. NEXT_AGENT_V4.md gains section 7 (where a harness belongs: port/tests/ if
it asserts, scripts/port/ if a human reads it, build_macos/ for nothing), a
pointer to both scripts, and ground rule 5 - any script that produced a number a
commit message cites must be committed. docs/port/audio-pump-state-of-play.md
records the same convention under its verification section.

ctest 9/9; the standalone build (cc -std=c99 -Iport/include port/tests/audio_pace.c
port/src/audio_pace.c) passes. User's midas.ini unchanged (6581028b...) and
Data/Saves untouched across the verification run.
Nothing behavioural. A sweep of the pages the recent commits touched, looking
for statements those commits falsified - the failure this branch has repeated
five times, so it is worth doing before a handoff rather than after.

FALSIFIED BY THE D5 FIX. tools/console_help.txt described `credit a of b` as
"a here is the default cap of 8". That was true when written and stopped being
true when the cap was made to yield to a hardware-pacing hold: a line can now
read `credit 12 of 57`. It now says a is the cap that applied, normally 8. The
paragraph below it already explained the yield, so the page contradicted itself
two paragraphs apart.

COUNTS. The branch is 15 commits, not 14 (NEXT_AGENT_V4.md) or 12
(audio-pump-state-of-play.md). audio_pace.c is 23 test cases, not 22
(audio-pump-history.md twice) or 16 (the same document's comparison table, which
had been stale since before this session). All are now derived from the tree:
git rev-list --count 3a1ab62..HEAD, and grep -c '^static void test_'.

HANDOFF ACCURACY. NEXT_AGENT_V4.md was written before f04d421 landed, so two of
its instructions described work that now exists:
  - section 2.2 step 1 said simulate_jitter "already does it this way", with a
    line cite of :711. It now has both orders behind a refill_first switch, at
    :758, and the step says so - what remains is changing simulate() itself.
  - section 2.4 told the next agent to build the order comparison from the
    shipped functions "with a flag". That flag is committed and asserted by
    test_harness_order_decides_whether_a_starve_is_visible; the step now says to
    re-derive the numbers rather than rebuild the harness.
  simulate() is untouched, so its line cites (:435, :465, :514) still hold and
  were verified rather than assumed.

DOC MAP. audio-pump-state-of-play.md listed six documents as five and omitted
audio-pump-history.md, which is where the pump's defect history lives. Added,
with the note that counts and commit totals rot on every commit and belong
derived from git rather than copied forward.

ctest 9/9; build clean apart from the pre-existing __DATA,__common warning.
No code. Three things, all from auditing the branch's own history before a push.

THE COUNT, AGAIN. af78f4a was committed under the subject "the counts and the
cap claim the last commit left stale" and it wrote "15 commits, tip f04d421" -
into the commit that added it. The branch was 16 and the tip was af78f4a. Both
documents now carry the command next to the number (git rev-list --count
3a1ab62..HEAD) and NEXT_AGENT_V4.md section 0 says to re-derive rather than
trust the line. This is the fifth time on this branch that a number was copied
forward instead of derived, and the second time in one day by this session; the
new section 8 also records that its own first draft got a related count wrong
(65 citations, measured before the section's prose added more).

FOLDING: NO. NEXT_AGENT_V4.md gains section 8, answering the question asked
directly. The evidence, measured rather than argued:

  - all 16 commits were checked out in a separate worktree and built and tested
    individually: 16 of 16 build with zero errors and pass ctest 9/9. No broken
    bisect step, so no mechanical reason to fold.
  - the tracked docs cite commit SHAs 66 times at af78f4a, plus three in code.
    Rewriting any commit changes its SHA and every descendant's, so folding one
    commit turns dozens of references into dangling ones - the exact defect the
    handoff's Task B exists to fix.
  - four commits contain content a later commit on the same branch corrects
    (bbc5c99's false tickhz claim, 6d02aa1's fld-vs-model-held comparison,
    17bf80b's "default cap of 8", and af78f4a's count). That is the honest case
    for folding; it loses because nothing is broken, because the project's own
    doctrine is that the record of a wrong conclusion is worth more than a tidy
    history, and because what must be true is the tip, not every intermediate.

  The window is noted rather than used: origin/audio-pump-wallclock_gen4 is still
  at 3a1ab62, so all 16 exist only locally and a rebase would work today.

WHAT TO DO INSTEAD. docs/port/audio-pump-state-of-play.md now opens with a
five-phase commit map (the numbers / documenting them / the audit and its
corrections / the two deferred items / what that exposed), so a reviewer reads
four units instead of sixteen. That is the reviewability folding would have
bought, at no cost to the 66 citations.

Verified: ctest 9/9 (no code changed). The scratch worktree and its build logs
are removed; the user's midas.ini is unchanged.
Provoked by a fair question: "are we not doing any squashing, or will it just
check whether folding a couple would be nicer?" Section 8 was written as a
debate - it set out "the honest case for folding" at length and then refuted it,
under the heading "Should this history be folded? No - and the window is now",
sitting among numbered sections whose every neighbour is a task. Read cold, that
is an open question with arguments on both sides, in a brief whose job is to say
what to do next.

The decision does not change: no squashing, no reordering, no rewriting past
commit messages. What changed is the presentation.

  - NEXT_AGENT_V4.md section 8 is now a decision record: "There is nothing to do
    here - do not spend time on it, and do not re-litigate it", two facts a
    reader would otherwise hunt for, a pointer to the full reasoning, and one
    explicit reopen condition. The reopen condition is narrow and checkable -
    the branch gets squashed at merge anyway, or a commit is found that does not
    build - which is also the answer to "will it just check if folding would be
    nicer": no, and "it would look nicer" is named as not a reason.
  - The full argument moved to docs/port/audio-pump-state-of-play.md under "Why
    the history is not folded", where the branch's history already lives, with
    the measured evidence and the honest case for folding intact. That includes
    the three commits that contain content a later commit corrects.

Also fixed while there, since the same question exposed them:

  - The commit map I added in 4e731d9 was inserted BETWEEN the sentence
    introducing the two long documents and the bullets listing them, orphaning
    the bullets. Structure restored.
  - The count is no longer written down in either document. This branch has now
    miscounted itself five times, twice in one day, and every fix of the number
    became the next stale number. Both pages say to run
    git rev-list --count 3a1ab62..HEAD instead, and the state-of-play page says
    why it is deliberately absent.
  - Section 0 claimed "all 16 commits exist only here" and "the window closes the
    moment it is pushed" - the latter is an invitation dressed as a warning, and
    it is now gone. The commit list is marked as stopping at af78f4a so it does
    not silently go stale either.

ctest 9/9 (documentation only).
The closed-loop simulation every audio case is built on refilled the queue to the
target and *then* let the device drain it. portAudioPump() does the opposite: it
is called after the interval has elapsed, the device has already played, and the
depth it measures is the post-drain one. In the harness the queue was topped up
before it could be emptied, so starvation was unobservable at any frame rate
under any jitter - which is why the pump's ratchet defect (df881c9) survived
every passing case and was found by ear.

Measured with this harness, one scenario (1 fps, every fifth pump 940 ms instead
of 870, 120 s = 137 pumps, the pre-df881c9 rule in both arms):

  refill-then-drain (the old harness): dry=0
  drain-then-refill (the pump):        dry=27   <- one per spike

The shipped rule on the same scenario: dry=5 (the first spikes teach the ratchet,
then its 76 ms ceiling covers the 70 ms overrun). Both numbers are printed by the
test, so a later session can reproduce them without this message.

What changed:

- simulate() drains first, measures, then refills (now simulateScenario()).
- The second harness is gone. simulate_jitter() had the right order and the
  constant-interval simulate() had the wrong one, so the file had two physics
  for one pump. Cadence, per-pump jitter, spikes, drift, the policy rule and the
  order are fields of one SimScenario; `refill_first` survives only as the
  order-sensitivity evidence.
- New case: 60 fps with +/-10% jitter must stay dry==0, and the generator's bound
  is pinned so "bounded" is not an assumption about a hash. The low-frame-rate
  spike case is the old jitter case, now driving the shared core.
- min_bytes/max_bytes now mean what the pump SAW and what it LEFT. Four
  assertions moved with that, none of them relaxed:
  * 20 fps: `min_bytes > TARGET/2` held only because the harness refilled first.
    In the pump's order a healthy run opens with the interval floor's cushion
    (~one device period), so that is what is asserted.
  * the dry-start case excludes the priming pump: a freshly opened device is
    empty and the ratchet's own contract says that is not an underrun.
  * rendered-vs-consumed was bounded by a frame of slack, which hid a real effect
    at low frame rates - a run that ends with a deeper queue has rendered more
    than it played, and that is not a rate error. Replaced by the balance
    equation (simUnaccounted): nothing dropped, nothing duplicated.
  Constant-interval cases still hold dry==0 at 60, 300, 20, 8, 5, 2 and 1 fps -
  verified, not assumed.

Checked that the harness has teeth by mutating the shipped rule back to
max(ratcheted, floor) in port/src/audio_pace.c: the pure-function case fails,
and with that case disabled test_jitter_at_low_frame_rate still fails
(fixed.dry == legacy.dry). Source reverted; only the two test files are here.

Inventory of the other timing simulations ("this is the only one" has been wrong
on this branch before):

- port/tests/audio_pace.c - one model now. The manual loops in
  test_simulation_survives_a_stall_between_pumps, the test_clock_* cases and the
  drain-watchdog cases are single-concern loops that do not model a cadence.
- port/tests/tick_stats.c - no loop, so "interval" there means the stat WINDOW.
  Audit F5's band rested on four measured readings; healthy_window_band() sweeps
  every window length the stat pass can close on (500-1000 ms) and pins the
  arithmetic: nearest-whole-field counts give 59.1-61.0, and one field of
  boundary skew gives 58.1-62.0 - so a reading inside the manual's 59.3-62.9
  band needs no defect to explain it.
- scripts/port/audio_pump_log.py and audio_run.sh - readers, not models; their
  headers already state what a 1 Hz sample cannot separate.
- src/game/frametiming.c - no harness; decided explicitly below.

frametiming.c decision: no harness in this pass, recorded rather than left as an
unmentioned gap. What is tested is the policy it calls (portStatsStepCap and the
field arithmetic in tick_stats.c); what a harness would need is an osGetCount
stub, definitions for 42 externs from other modules, and - the reason it is real
work - one process per configuration, because the file caches configuration in a
dozen process-global statics fed by 30 getenv reads. A live run proves the
composition (D5's `credit 8 of 52` -> `credit 12 of 57`) and cannot prove any
single branch of it. The shape for a later pass is written down in
docs/port/audio-pump-state-of-play.md 4.8.

Verified: cmake --build build_macos (only the pre-existing __DATA,__common
alignment warning) and ctest 9/9; standalone cc -std=c99 -Wall -Wextra clean.
…bols

Three cited things did not exist and four citations were stale. Both are fixed.

- docs/port/n64-hw-pacing.md is written, assembled from frametiming.c and the
  design record rather than researched: the nine render-model coefficients (two
  of them fitted to zero, and fillx is a surcharge not a fourth bucket), the
  6-field render cap and the 30-field total, the cart-DMA and inflate rates, the
  stage-load CPU terms, the fit's provenance (hwfit.py is not in the tree), the
  near-points coefficient that is 67.7% of setup CPU and unproven, and what the
  hold does and does not mean.
- docs/port/tick-rate-binding.md is NOT written. The binding is
  tick-and-field-accounting.md 13 and video.c cites that section. A second
  document would have restated sections 2 and 3 and then drifted from them, which
  is the failure the documentation map exists to prevent.
- The design record's 11 stays where it is, by decision recorded in both
  documents: three of its four subsections are about the tick and the ini rather
  than the RDP model, and moving them would renumber references that are already
  cited.

Citation policy, adopted and applied: cite the symbol or the section, and use a
line number only where the line is the point. video.c and frametiming.c move
constantly; four citations into video.c were stale within two commits and one of
them described a branch F3-A had deleted. So:

- audit: videoPersistDerivedTick's early return is recorded as gone rather than
  renumbered, and points at design-record 11.3 and state-of-play 2; the missing
  documents are marked resolved, in 2 and in 6's "what is still open";
- design record: the tickhz parse is cited by key instead of by line, 10's
  "neither file exists" is corrected, and 11's "belongs in n64-hw-pacing.md when
  that file is written" now records the decision instead;
- state-of-play: 4.1 states the outcome instead of the gap, 5 carries the policy,
  4.8 records the frametiming.c harness decision from the companion commit, and
  the documentation map lists the new document;
- the three code comments now name their document's section. video.c's edit is
  line-neutral, so it does not shift the line citations the keybind documents
  still carry - those were already stale by about 30 lines from 87af349 and are
  left alone as their own tracking documents. Reported, not quietly renumbered.

Found by reading, and not repeated in the new document: portTickHwPace()'s
comment says "the model's fixed cost alone is two fields". The constant term is
13.17 ms, under one field, so the sentence is only true of the fixed cost plus an
ordinary menu draw. The document states the verifiable half and says why it does
not carry the other.

Verified: git ls-files docs/port/ lists n64-hw-pacing.md (it needed git add -f;
the directory is gitignored); build clean, only the pre-existing
__DATA,__common alignment warning; ctest 9/9.
The branch is `tick-field-accounting` now. It was `audio-pump-wallclock_gen4`,
which named the subject it started from; about 60% of the change is the frame and
tick accounting in frametiming.c, tick_stats, video.c, hitchlog and bondview2,
and the design record it is all built around is
docs/port/tick-and-field-accounting.md. The pump work rides along because its
ratchet defect was found by the harness this branch fixed, and it cannot be split
out: four citations into code (port/tests/audio_pace.c, two;
scripts/port/audio_pump_log.py, two) and dozens in the docs name commit df881c9
by SHA, and a cherry-pick would change it.

A rename, not a rebase: same commits, same SHAs, so every citation into this
branch's history still resolves. origin/audio-pump-wallclock_gen4 is untouched
and still at 3a1ab62, so anything open against it is unaffected.

Updated: the entry-point page and the audit header, which are live references.
The four NEXT_AGENT*.md handoffs still say the old name and are left as written -
they are dated snapshots of what the branch was called when they were written,
and rewriting them would make the record agree with the present instead of with
the day. The state-of-play header says so, so a reader arriving from a handoff
is not left guessing.

Verified: build clean (only the pre-existing __DATA,__common alignment warning),
ctest 9/9. Documentation only, plus one comment-free rename in prose.
…udit found

A pre-push reference audit over this branch found one hard failure, a unit error in
the document this branch added, and a set of claims that a later commit made
false. None of it changes code; all of it would have been read as authoritative.

HARD FAILURE - two citations resolved to nothing.

tps-the-lie.md cited `ecfbe07` and `853163b` as the commits that removed TPS from
the overlay and the log. Neither is reachable from this branch: they are
pre-rewrite twins of 85fa9f1 and 2f8a6d0 (same subjects, same file sets, different
trees), left behind when the branch's early history was amended. A reviewer
following either got "unknown revision" - the exact defect the branch's own
Task B was about. Corrected to the reachable commits, with a note saying they read
wrong for a while, because a silently fixed citation is the same failure as a
silently wrong one.

A UNIT ERROR IN MY OWN DOCUMENT.

n64-hw-pacing.md's coefficient table labelled tris and chrs as microseconds per
unit. They are milliseconds per unit: the fill and byte terms carry a * 0.001 on
the count, the last two do not, so sHwModel[7] = 0.004372 ms/tri is 4.372 us and
sHwModel[8] = 0.152045 ms/char is 152 us. Read as microseconds the triangle term
looks 4.4 ns per triangle and vanishes, which is roughly what a reader would have
concluded about the model's triangle cost. Both rows corrected, and the two
conventions are now called out explicitly, since one formula carrying both is the
trap that produced the error.

Also softened: "each load site notes its compressed size". portHwChargeLoad takes
a compressed flag and passes 0 for uncompressed reads (the bg header, the point
table), so the sentence now says what it notes and which reads are DMA-only.

CLAIMS A LATER COMMIT MADE FALSE.

- "audit 8.2" is cited 4 times and does not exist: the audit has sections 1-6 and
  an appendix. The gap it means is NEXT_AGENT_V3.md 8.2, which is now what those
  four sites say.
- state-of-play said commits after af78f4a "carry no code". 13fedc5 is the
  largest test change on the branch. The phase map has a sixth phase and says so.
- the audit's F1 paragraph cited frametiming.c:1178-1183 and :962-970, which
  matched the tree it audited (2f8a6d0) and stopped matching when cb98c39 added
  41 lines above them. Replaced with symbols, per the policy adopted in the
  previous commit, and marked as having been wrong on arrival.
- the audit's verdict still read "Not safe to push as-is - two majors block it".
  Both blockers were fixed (F1 by cb98c39, F3 by 87af349) and section 6 records
  it. The verdict is kept verbatim with a dated note above it: a verdict that gets
  rewritten once it comes true is not a verdict.
- final_report_PR.md said the branch had been pushed. It had not - the push was
  cancelled. It also carried 20 commits (now 21) and 38-against-main (now 39),
  and claimed the two deferred commits were the only behaviour changes when its
  own section 3 lists five. All four corrected.

Verified: every 7-40 char hex string in docs/port/*.md, port/tests/*.c and
scripts/port/*.py now either resolves to a commit that is an ancestor of HEAD or
is not a SHA at all (an md5 prefix, 0x10000000, an image-size constant). Section
references checked: design-record 13 and state-of-play 4.8 exist, no "audit 8"
remains. Build clean (only the pre-existing __DATA,__common alignment warning),
ctest 9/9.
CLI.md at the repo root: how to launch, how to get hardware pacing, the two
command-line flags, the startup variables worth knowing, and the console. It
answers a question the tree made hard to answer, because the answer is
counter-intuitive:

**There is no environment variable for hw mode.** GE_TICKHZ is parsed with
strtol, and strtol("hw") is 0 - so GE_TICKHZ=hw silently selects an UNCAPPED tick
rate, a different mode with different behaviour, and the only hint is the log line
that says UNCAPPED. hw pacing is reachable three ways and none of them is that:

  midas.ini   [display] maxfps=-1            (the reliable one)
  console     cl_maxfps -1                   (live, persisted to the ini)
  env         GE_CONCMD="cl_maxfps -1"       (runs at the first rendered frame)

maxfps=-1 IS tickzh=hw: videoCoupleTickToMaxFps calls the tickhz setter with "hw"
through the same path the ini uses. The doc says so, with the call chain, so the
next reader does not have to rediscover it.

Contents: quick start; the three hw routes plus the trap and what the mode costs
(the 8.7 s Dam hold, audio latency at low frame rates, active only inside a
stage); the two flags (--rom-file, --lang) and drag-and-drop, with the note that
there is no --help and unknown flags are ignored; the six-step ROM search order;
the startup variables grouped by purpose rather than listed alphabetically; the
console's cl_ verbs; and what a run writes, including that changing maxfps
rewrites midas.ini - so GE_NOINISAVE=1 or a backup, which is the same rule the
audio harnesses follow.

Every claim is read from source; provenance is a section at the end. Nothing was
launched while writing it, because a launch writes midas.ini, so the one claim
that needs a run - that GE_CONCMD="cl_maxfps -1" engages hw pacing from the first
rendered frame - is marked derived-not-observed.

Second change, on the same subject. The console's `harness` command prints a
generated table of every GE_* knob, and that table was 20 entries behind the tree:
290 of 310. Two of the missing ones were GE_HWLOAD and GE_HWLOAD_SCALE, so the
in-game listing could not show a reader the knobs that control hw-pacing load
charges - the exact question a hw-mode investigation asks. Regenerated.

The cause was the maintenance method: the header said "paste the grep output
between the braces", which is a step a person skips. There is now
scripts/port/regen_console_env.py, which rewrites the array in place, sorts
LC_ALL=C so the diff shows only additions (a locale sort reorders GUNSND_OVERLAP
and GUNSNDRATE and manufactures churn), and is idempotent - verified by running it
twice and getting a byte-identical file. The header points at it and records why.

Verified: regenerated table compiles; build clean apart from the pre-existing
__DATA,__common alignment warning; ctest 9/9; script idempotent.
@JayBeest JayBeest changed the title stats: replace TPS with fps/tk/f/s/f/t, and fix what it was hiding stats: replace TPS with fps / tk/ f/s / f/t, and fix what it was hiding Oct 25, 2026
@JayBeest JayBeest changed the title stats: replace TPS with fps / tk/ f/s / f/t, and fix what it was hiding stats: replace TPS with: fps - tk - f/s - f/t, and fix what it was hiding Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants