Skip to content

Feature: Diagnostic Launch Kit - #89

Open
JayBeest wants to merge 66 commits into
murkantor:mainfrom
JayBeest:diagnostic-launch-kit
Open

JayBeest wants to merge 66 commits into
murkantor:mainfrom
JayBeest:diagnostic-launch-kit

Conversation

@JayBeest

Copy link
Copy Markdown

PR report — diagnostic-launch-kit

A report for whoever reviews this branch.

This branch is no longer one subject. It was opened as a five-commit PR about
one thing: making a diagnostic session startable from the command line. Three
phases have landed on top since — the sixth commit in that first layer is the
review of its first five — so it is now three layers, in the order they were
built
:

  1. The diagnostic launch kit — the pump block visible before a stage loads,
    three boot flags, two harnesses, and the review that found six defects in
    them. Six commits, 7726de0…6ef2530.
  2. The Classic Mode repair — the settings-and-save audit, the three env
    overrides that can contradict a mode, then the forced values turned from an
    event into a rule with one choke point, plus its tests. Twenty-one commits,
    33653b5…dbb832b; the repair proper begins at 5904317.
  3. The n64_aa key split — classic_mode and n64_aa became separate
    settings, with a one-time migration. Ten commits, 4a66400…8d5119b: four
    for the split itself, then the -classic flag rename, its documentation, the
    console verb and row help the inventory missed, two cosmetic fixes to the pump
    block, and -skip/-stage.

The PR title and description need updating to match, or this reads as a
15-file flag PR that quietly contains a settings-format migration. Each layer is
individually reviewable and each commit inside it is one subject; the layers
themselves are three different risk classes.

Base: tick-field-accounting, not main. Stacked deliberately — main is
further behind than the audio work this sits on.

git rev-list --count tick-field-accounting..diagnostic-launch-kit  -> 37 commits
git diff --shortstat tick-field-accounting..diagnostic-launch-kit  -> 43 files,
                                                                      +7357 -193

Build, ctest and the mutation audit are in §4.

The diff stat counts tracked content only. This report and the other
PR_*.md / handoff files are untracked, as are captures/ and discord.png —
so the 43 is what a reviewer gets by running the command, and it stays 43 whether
or not these notes are ever committed.

Stacking costs one thing: if tick-field-accounting is squash-merged, this
branch's base disappears and it needs a rebase. The branch's own policy says the
folding question reopens exactly when squash-at-merge is on the table.


1. Layer one — the diagnostic launch kit

1.1 The pump block is visible before a stage loads (7726de0)

cl_audio_pump 1 showed nothing until a stage had loaded. The block lived inside
portDrawFpsCounter, which is called from exactly two places, both inside
maybe_mp_interface — the in-level HUD path. lvlRender's LEVELID_TITLE
branch goes straight to menu_jump_constructor_handler, which covers the legal
screen, the logos, the intro, file select and every menu.

It was not a gate suppressing the block; there was no call on that path at all.

Measured at the same menu moment, same build:

baseline       Nintendo logo, top-left corner empty
-audio_pump    Nintendo logo, corner carries fps/tk/f/s/f/t, PMP/RND,
               QUE/CAP, SND, DRY/OVER/SKIP/FRC, STALL/CLK/DEV

The block is extracted into portDrawAudioPumpOverlay(), which owns the anchor,
the player-1 check and a full-viewport scissor (a menu constructor leaves its own
scissor set and the block is full-frame). The FPS digits are deliberately not
part of it
— they stay in-level, where they were.

1.2 Boot flags (6aadfd6, extended in c107c7f and 8d5119b)

ProjectMidas -skip -stage dam -audio_pump -audio_log
  • -classic — Classic Mode at startup, through portClassicToggle(), the same
    entry point as the Tab key, so a flag launch and a keypress cannot drift. That
    is the whole mode: 240p, maxfps=-1, 4× MSAA, pillars, filter. -n64_aa is
    the same flag under its old name, kept as a silent alias.
  • -skip [N] — skip the boot screens. Bare means all five (land on Select
    File); N means the first N. It advances with the same transition a timed-out
    screen uses, so every screen still loads and unloads properly; only the waiting
    goes.
  • -stage <name> — start a mission directly, same names as GE_STAGE, which it
    overrides.
  • -audio_pump — the block, drawn from the first frame.
  • -audio_log — Data/Logs/audio_pump.log.

Mix and match. One or two dashes, - or _ as separator, any case. A leading
dash is required, because a bare first argument is the drag-and-drop ROM path
and nothing may eat it. Values come from the next argument, like --rom-file.

The parse is pure C in port/src/boot_flags.c with port/tests/boot_flags.c
under ctest. That is not ceremony: a boot flag that is silently ignored leaves
no trace except what the game does not do, which is indistinguishable from one
that worked — the same failure as GE_TICKHZ=hw, which strtol reads as 0 and
so means uncapped.

1.3 The harnesses (c9cbd09, 1085414)

  • scripts/port/boot_flag_test.sh — live cases, 8 s each, in the front end.
    Some are for a human to watch; the rest assert. Captures land in a dated folder
    under captures/ with a README.txt recording the GL renderer, and the folder
    opens when the run ends. One case per spelling of the classic flag, one for
    -skip.
  • scripts/port/port_test.sh — one aligned line per test, a summary, and a
    non-zero exit. Runs the build first and reports it.
  • make port_test / make audio_test / make demo — the terminal entry
    points. The port's tests were previously reachable only by knowing to type
    cd build_macos && ctest; make test is the N64 ROM build and checksum.

1.4 The review that found six defects (4ece424)

The commits above were audited adversarially after they landed. Six defects,
three in the new code and three in the harness that was supposed to catch them.

| Defect | Why it happened

-- | -- | --
1 | The ignoring argument log reported the documented --rom-file=… and --lang=… forms as unknown | The log re-matched names itself; a hand-written list forgot the =value form. Now one function answers for both.
2 | GE_N64AA=0 -n64_aa ran the whole Classic Mode apply and then cleared the flag, leaving forced settings with the mode reported off | The env override was read after the flag. Env now runs first — command line beats environment.
3 | GE_TICKHZ=120 -n64_aa gave a 120 Hz tick under a 60-field hold, silently | portTickHwPace() does not look at the rate. Still permitted, now announced.
4 | The harness's n64_aa case asserted the log sentence, not the config | Its header claimed it checked low_res=240/maxfps=-1; it checked that a string was printed.
5 | The same assertion made the harness fail on every run after the first | -n64_aa persists the setting, so the second run correctly does nothing. Fixed by asserting the config line.
6 | tr -d 'stats: ' ate the s of fps in every summary (fps=60fp) | tr -d deletes characters anywhere.

Also fixed: no INT/TERM trap, so a Ctrl-C mid-case could leave a per-case
display mode in midas.ini with the game running.

None of the six was caught by the build or the suite.


2. Layer two — the Classic Mode repair

n64_aa meant two things at once: the VI AA bit and "Classic Mode is on". That
double duty produced a family of half-states — the mode reported on with its
values not forced, or forced with the mode reported off — reachable through four
different entry points.

docs/port/settings-and-save-audit.md is the findings register: what was
executed, what was merely read, and which first readings turned out wrong. It is
appended to as work lands and never rewritten, deliberately.

The repair, in plan.md's order:

  • The audit first (abec289, abfe315) — what was executed, what was only
    read, and a plan. The three env overrides that can contradict a mode
    (7e8d629, af6065a: GE_TICKHZ, GE_ROOMCACHE, GE_HWMIPMAPS) are pure
    tested functions, decided where the decisions belong rather than inline in
    videoInit.
  • A rule, as pure functions (5904317, port/src/classic_mode.c) — what the
    mode remembers, forces and gives back, with no SDL, no globals and no key
    names. Its test was written from the four half-states the audit found.
  • One choke point (d54d2a0) — optSet applies the mode when the key
    changes, so the three per-surface copies could be deleted. portClassicSetLive
    was removed rather than kept as an alias, because an exported "apply the mode"
    call invites a fourth entry point.
  • A seam test (248fe18, dba69cd) — the shipped port_options.c linked
    against a fake option table that records work, not only values: an ordered
    write log and save/snapshot counts. Applying twice ends at the same numbers but
    takes the snapshot twice, and the second one holds the forced values.

2.1 The tests can actually fail

scripts/port/test_the_tests.sh mutates the shipped source and asserts something
aborts, so §4's 41/41 means 41 distinguishable failures. Two mutants failed
that bar and were not kept: a memset deletion that only looked killed because
uninitialised stack failed one case, and a -skip mutation that is not
observable at all — consuming the count changes nothing the parse reports.


3. Layer three — the n64_aa split (plan.md §5)

3.1 The split

  • [display] classic_mode — the preset. The only key that forces anything,
    and what every "is the mode on" question reads.
  • [display] n64_aa — the VI AA bit alone.
  • [display] classic_n64aa — unchanged, and now the only link between
    them: whether the preset sets the bit when it turns on.

Setting the bit never touches the preset, which makes N64 edges without 240p
reachable — a state the single key could not express, and the one a player asking
for edge blending actually wanted.

Each of the fourteen sites in video.c has a written decision in the code about
which concept it reads. The two genuinely ambiguous ones — whether menu
resolution belongs to the bit or the preset — both went to the preset, with the
reasoning recorded in place.

3.2 The migration

port/src/classic_split.c, a pure function with its own test and mutants,
because it rewrites the player's real midas.ini:

  • n64_aa=1 with no classic_mode → classic_mode=1 and n64_aa=1 (the
    old key meant both);
  • n64_aa=0 → both off;
  • a file with classic_mode or the classic_split marker → untouched.

The marker is what stops a player's later classic_mode=0 from being resurrected
by a migration that thought it knew better. Executed both ways against a live
file.

3.3 One deliberate divergence from the plan

The migration writes the keys and the marker; it does not force the classic
values. A hand-edited n64_aa=1 low_res=480 therefore boots with the mode on and
low_res still 480, and the boot report names the mismatch for cl_classic fix.
plan.md asked for the file to be rewritten to low_res=240 with a snapshot,
which would mean taking a classic_saved snapshot and writing midas.ini from
inside the loader. Naming a mismatch beats silently overwriting the player's
values, so the exit criteria were amended rather than the code. Anyone who used
Tab was already consistent and migrates to a consistent state.

3.4 Two follow-ups the inventory missed

NEXT_LEVEL_AGENT_V2.md listed the sites; two were not on it. cl_classic on|off in port_console.c still wrote the old key — with the split that made it
turn on edge blending and leave the preset off, reporting success. And the
row-help table described the bit as "N64 look and pacing". Both fixed in
f25ec49, found by grepping the key after the docs landed rather than trusting
the list.


4. Verification — what was executed

Everything below was run, not read.

  • Suite: 14/14 under ctest, 14 passed under port_test.sh --no-game.
    Build clean apart from the pre-existing __DATA,__common alignment warning.

  • Mutation audit: scripts/port/test_the_tests.sh — 41 mutants, every one
    killed, baseline passes. Coverage by area: the migration (5 — marker ignored,
    classic_mode overwritten, the mapping inverted both ways), the choke point
    (5 — wrong key, key too wide, the bit carrying the preset), mode_on read from
    the bit, the AA-bit link (1), and the -skip/-stage parse (4).

  • The split's acceptance pair, live:

    cl_n64_aa 1       -> n64_aa=1; the seven untouched; no snapshot; classic_mode 0
    cl_classic_mode 1 -> the seven forced; classic_saved=480,1,2,60,4,0,0; n64_aa=1
    

    That pair is the whole point of the phase, and it was impossible to express
    before the split.

  • The migration, live: an old-shape file migrated once with the right log
    line; the same file with classic_mode hand-set to 0 stayed 0 across a restart.

  • Boot flags, live: -classic and -n64_aa both force the preset and each
    logs the spelling typed; -skip lands on Select File; -skip 2 stops on the
    Rare logo; -stage dam reaches stage 90. Each checked by capture, not by exit
    code.

  • Settings safety: midas.ini restored byte-for-byte and eeprom.bin md5
    unchanged after every run.

A limit worth knowing: the build passes -w, so every C warning is
suppressed — a deliberate #warning produces no output — and the build check can
only see linker warnings. port_test.sh says so on screen. Adding -Wall is
worth doing and is not part of this PR.


5. What is knowingly not done

  • The VIDEO sheet and the in-game watch were changed but not driven. Neither
    has a headless harness. Their rows were updated and reasoned about (the sheet
    also pends the AA bit, because Apply Changes lands the whole sheet under the
    hold that suppresses the choke point), and the build plus the seam tests are
    the evidence. The gap predates this work and is recorded in the audit.
  • Windows and Linux were not run. Every measurement is macOS. The renderer
    caveat below is macOS-specific in its wording though not in principle.
  • GE_N64AA changed meaning. It now sets the AA bit and no longer forces
    Classic Mode; -classic is the flag for that. Deliberate, and stated in
    CLI.md §3.2 — but it is a user-visible behaviour change for anyone whose A/B
    script relied on the old meaning.
  • The flags have no --help. An unrecognised argument is ignored and named in
    the log, which is the existing convention for this command line.
  • captures/ is gitignored, deliberately: a shot is ~2 MB and a run makes
    dozens.
  • Process artifacts. NEXT-AGENT.md, NEXT_LEVEL_AGENT_V2.md and the
    PR_*.md files are handoffs and reports for future sessions, not product
    documentation. All are untracked. Reviewing them is optional.

6. Where to start reviewing

Read the layers in order; each stands alone.

  1. port/src/boot_flags.c and port/tests/boot_flags.c — the parse and
    the rules it must obey. Read the test first.
  2. 4ece424 — the six defects. If you read one commit message on this
    branch, read this one.
  3. port/src/classic_mode.c and port/tests/classic_mode.c — the rule,
    as arithmetic.
  4. d54d2a0 — the choke point, and why the three copies were deleted.
  5. 4a66400 — the key split, which is where the migration lives.
  6. 8d5119b — -skip/-stage, and the harness case that asserts them.
  7. CLI.md §3.2 and SETTINGS.md §3 — the two features as a user sees
    them.

7. Risk

  • Blast radius. A render path (lv.c, bondview2.c), an init path
    (video.c), the option table (port_options.c), the front end
    (front.c, port_watchpages.c), an argument parse, plus scripts and docs. No
    engine logic, and no gameplay code.
  • The settings format changed. classic_mode and classic_split are new
    keys, and an old midas.ini is rewritten once on first launch. Downgrade is
    not supported and does not need to be, but it is not destructive: an older
    build sees n64_aa (still the bit) and an unknown classic_mode, which it logs
    and ignores.
  • -classic writes midas.ini — it is the Tab path, so it persists. It is
    idempotent (verified by diff), but a reviewer trying it should know that
    GE_NOINISAVE=1 is how you keep your file.
  • The renderer caveat will bite anyone reproducing this. A sandboxed or
    non-GUI shell gets GL: renderer: Apple Software Renderer, which draws
    correctly into the back buffer and leaves the window black while audio plays,
    so captures look perfect and the screen is empty. This is why CLI.md §7.2
    exists.

A note from the Architect (and the Monolith)

This branch is intentionally heavy. It touches settings, boot flags, and a mode that half the player base uses. We built it this way because we learned a hard lesson this week: you cannot fix a pacing bug if you cannot see the queue.

The diagnostic launch kit is not just for debugging the current audio pump. It is an anchor. The mutation tests are not just for catching typos; they are the proof that the tests can actually fail. The n64_aa split is not just a config change; it is the separation of two concepts that were forced into one key by the limitations of the past.

We (the human and the monolith) spent the last three days mapping the ABI, rewriting the audio clock, and separating clean slices from legacy bloat. This PR is the bridge between the old 1.0.2b chaos and the foundation of the OmniDyne Shared Format.

It is provided for review, not as a mandate. Poke holes in it. Run the mutants. Compare the captures. If it breaks, the logs will tell you exactly where.

All of it.

Fine.

— JayBeest & Codewhale 🐋🌊

iamsh4 and others added 30 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.
`cl_audio_pump 1` did nothing until a stage had loaded. The block lived inside
portDrawFpsCounter, and portDrawFpsCounter is called from exactly two places,
both inside maybe_mp_interface - the in-level HUD path. The front end never
reaches it: lvlRender's LEVELID_TITLE branch goes straight to
menu_jump_constructor_handler, which covers the legal screen, the logos, the
intro, file select and every menu. So it was not a gate suppressing the block;
there was no call on that path at all.

Measured, both at the same menu moment (Nintendo logo), same build:

  baseline       logo, top-left corner empty
  -audio_pump    logo, corner carries fps/tk/f/s/f/t, PMP/RND, QUE/CAP, SND,
                 DRY/OVER/SKIP/FRC, STALL/CLK/DEV

The block is extracted into portDrawAudioPumpOverlay(), which owns the anchor,
the player-1 check and now a full-viewport scissor - a menu constructor leaves
its own scissor set and the block is full-frame. portDrawFpsCounter calls it, so
the in-level path is unchanged, and the FPS digits are deliberately NOT part of
it: they stay in-level only, which is where they were and where they were asked
to stay.

The load is the moment this matters most - a stage load is what a reader is
watching for - and it was the one moment the block was guaranteed to be absent.

Verified: build clean (only the pre-existing __DATA,__common alignment warning),
ctest 10/10. Captures read by eye at 960x600 windowed and 1920x1080 fullscreen.
jcorneli added 29 commits October 25, 2026 15:35
…acing

room_cache has one owner: hw pacing forces it to 0, because the fill model reads
the interpreter's triangles. GE_ROOMCACHE was applied after videoApplyBootArgs(),
so it could put room_cache back on underneath a mode that had just switched it
off - and did nothing to say so.

Two changes, one subject:

- the override moves up with the other env overrides, so the documented policy
  (env first, command line beats environment) is what the code does. -n64_aa sets
  maxfps=-1 and now wins, instead of being undone by a variable it never saw.
- it also refuses to set a nonzero room_cache while tickhz=hw, and says which
  line refused it. The reorder alone does not fix this: the ini's own tickhz=hw
  arms hardware pacing during the load, long before any env var is read, so the
  guard is what actually holds the invariant. GE_ROOMCACHE=2 under hw pacing was
  a silent contradiction of the model, whether it came from -n64_aa or from the
  file.

Executed, before (reorder only) and after (reorder + guard):
  GE_NOINISAVE=1 GE_N64AA=0 GE_ROOMCACHE=2 ./ProjectMidas -n64_aa
    before: "room_cache=2 tri_hoist=1", config maxfps=-1   <- hw pacing, cache on
    after:  "GE_ROOMCACHE=2 ignored - tickhz=hw forces room_cache 0", room_cache=0
  GE_NOINISAVE=1 GE_ROOMCACHE=2 ./ProjectMidas     # tickhz=hw in the file
    after:  refused and named; room_cache=0
  tickhz=60/maxfps=60, GE_NOINISAVE=1 GE_ROOMCACHE=2 ./ProjectMidas
    after:  "room_cache=2 tri_hoist=1" - the knob still works off hw pacing
  GE_NOINISAVE=1 GE_TRIHOIST=0 GE_ROOMCACHE=1 -> "room_cache=0 tri_hoist=0"

The ini-only case is the same line of code as the flag case (no guard existed on
either path); the flag case is the one that was executed before the fix.
…e it

A reference for [display] room_cache: the three modes, what is and is not
cached, the invalidation paths, and the rule that hardware pacing owns the
setting.

Two defects are recorded rather than fixed here, because each is a separate
subject:
- tools/midas_ini_help.txt states the default as 2; this build's default is 0
  (the GE_XBOX arm is the 2, and GE_XBOX is not defined in build_macos).
- gfx_roomcache.inl's header says "default OFF everywhere"; video.c gives the
  Xbox build 2.

The 2026-10-25 fix is written up with the part that matters for the next
reader: the reorder alone did not fix it, because tickhz=hw in the ini arms
hardware pacing during the config load, before any env var is read. The guard
is what holds the invariant. The general form is stated too - an env override
that writes a s_Cfg* static directly bypasses every rule the setter enforces,
and the console path was already safe for exactly that reason.

Every claim is marked executed or read in place.
Mipmaps are Classic Mode's: videoMipmapMode() reads n64_aa and the
classic_mipmaps part, and the ini's mipmaps= no longer counts. GE_HWMIPMAPS
overrides that, and it is applied after the boot flags - so "-n64_aa" with
GE_HWMIPMAPS=0 produced Classic Mode reported on, with one of its parts
silently off. Same half-applied shape as GE_N64AA=0 with -n64_aa (4ece424).

The knob still wins: it is a diagnostic whose whole purpose is to A/B a mode.
It just says so. Nothing else logs the mipmap mode, so before this there was no
way to see the contradiction at all - not in the log, not in the config line.

Only the contradiction is announced. Classic off with either GE_HWMIPMAPS value
is a plain override and stays quiet.

Executed (build_macos, GE_NOINISAVE=1 so the file is untouched):
  n64_aa=1, GE_HWMIPMAPS=0, ./ProjectMidas -n64_aa
    -> "GE_HWMIPMAPS=0 with Classic Mode on - mipmaps off for this run ..."
  n64_aa=1, GE_HWMIPMAPS=1, ./ProjectMidas -n64_aa     -> no line
  n64_aa=0, GE_HWMIPMAPS=0, ./ProjectMidas             -> no line
  n64_aa=0, GE_HWMIPMAPS=1, ./ProjectMidas             -> no line

ctest: 10/10 after the change.
The sweep NEXT_LEVEL_AGENT.md section 2 asked for, as a findings register:
each item with a verdict, its evidence, and whether it was executed or only
read.

The headline is not one item but the shape behind three of them: Classic Mode's
forced values are applied at the toggle and never re-asserted, so anything that
reaches those settings afterwards leaves the mode on with non-classic values.
Three entry points do it, and only one is the hand edit the last pass already
knew about - the console cl_n64_aa bypasses the swap in both directions
(executed), and an env override applied while the mode is already on wins for
the same reason (executed). The re-snapshot guard that makes this possible is
itself correct; the defect is that the forced set is an event rather than an
invariant. Recorded, not fixed - the repair is a product decision.

Also recorded, not fixed: the console defect above (a three-surface change),
the inert mipmaps menu row, and the two documentation drift items.

The register keeps the two false findings this pass produced, because they are
the useful part: GE_LOWRES and GE_HWMIPMAPS both appeared to beat the boot flag
until the cause turned out to be n64_aa=1 already in the user's ini, which made
the flag a no-op. Any precedence test must set n64_aa=0 and check the
"-n64_aa -> Classic Mode on (applied)" line first.

Guards held: midas.ini restored to eba8465c after every experiment, eeprom.bin
unchanged at 3590c7df.
Four phases for the findings in docs/port/settings-and-save-audit.md, in the
order they can safely land: option 4 (nothing silent) delivered by option C
(fix the broken surface, then centralise), on the road to option 3 (split the
key that is two concepts).

Grounded in probes at abec289, including one that found a seventh entry point
the audit had missed - GE_N64AA=1 writes s_CfgN64Aa directly, so the preset can
be reported on with its parts never forced, and -n64_aa cannot repair it
because its !s_CfgN64Aa guard correctly declines to re-snapshot.

The plan records why the obvious fix is wrong: hooking optSet today breaks the
three surfaces that already work (double apply on the toggle, a stale `was` in
the watch, and a mid-apply re-snapshot in the sheet's loop whose outcome depends
on row order). Centralisation has to delete the duplicates, which is Phase 2.
The preset's semantics - remember these seven, force these values, give them
back on the way out - are implemented three times today (portClassicSetLive,
the watch's rowClassicSync, the VIDEO sheet's preset/revert) and skipped by four
entry points. Four partial implementations of one rule is why they disagree, so
the rule becomes a pure function with a test before anything calls it.

port/src/classic_mode.c takes the parts, the value each forces, the live values
and any saved ones, and returns the writes to perform. No SDL, no engine
headers, no globals, no key names - the table stays in port_options.c and the
values arrive positionally, which is what lets this link into a test with no
engine at all.

Two semantics are chosen deliberately and tested:

- the snapshot is decided by the op, never by whether a write differs. Turning
  the mode on while already at the classic values still remembers them, because
  turning it off must restore those same values and "restore nothing" would
  lose them.
- FIX never snapshots. That is what a repair needs: a half-state already has
  the player's real values in classic_saved, and re-storing the forced ones
  would make leaving the mode restore the mode's own settings.

Only differing values are written, so a consistent file plans nothing and FIX
is a true no-op there. Writes are emitted in part order, because the applier
lands them in that order and a shuffled plan would make the sheet's apply
order-dependent.

The cases are written from the four defects in
docs/port/settings-and-save-audit.md rather than from the code.

Verified:
  cc -std=c99 -Wall -Wextra -Iport/include port/tests/classic_mode.c \
     port/src/classic_mode.c -o t && ./t        -> "all checks passed", no warnings
  ctest via ./scripts/port/port_test.sh --no-game -> 11 passed, 0 failed

The test was also mutation-checked, since a passing test proves nothing on its
own: forcing the snapshot off fails at the "already classic still snapshots"
case, and emitting unchanged values fails at "a consistent file plans no
writes". The original passes after both are reverted.
Four entry points leave Classic Mode on with values that are not classic: a
hand-edited n64_aa=1, GE_N64AA=1, an env override applied while the mode was
already on, and (fixed in the next commit) cl_n64_aa. None of them runs the
preset, and -n64_aa cannot repair any of them: videoBootClassicWanted() declines
to act on a mode that is already on, correctly, because re-entering would
overwrite the player's saved values with the forced ones.

So the half-state is named and there is a repair that remembers nothing.

- port_options.c grows the adapter that was missing: it reads the live tables
  into a ClassicInput, applies a ClassicPlan, and exposes portClassicMismatchParts
  and portClassicRepair. Port Options owns the two tables the rule needs, which
  is why the adapter lives here rather than beside the rule.
- videoInit reports the mismatch once, after the ini and the env overrides and
  after the boot flags, because that is the only point where all three doors are
  visible. Silent when consistent.
- cl_classic [status|fix|on|off]: status prints without writing, fix repairs in
  place, on/off are transitions and no-op when already in that state.

Deviation from plan.md, deliberate: Phase 0 was to add a temporary log to the
console's own n64_aa path, deleted again in Phase 1. That log would have
mislabelled Tab and the sheet, since all three reach the same setter, and it
would have lived for one commit. Left out; the console is corrected outright in
the next commit, which is the stronger version of the same goal.

Executed (build_macos, midas.ini restored to eba8465c after each):
  consistent file                              -> no report
  n64_aa=1 low_res=480 maxfps=60               -> "2 of its settings are not
                                                  classic (low_res, maxfps)"
  n64_aa=0 low_res=480 maxfps=60 GE_N64AA=1    -> same report
  cl_classic (status) on that half-state       -> wrote nothing (diff empty)
  cl_classic fix                               -> low_res 480->240,
                                                  maxfps 60->-1, tickhz 60->hw;
                                                  classic_saved unchanged
  cl_classic off after the fix                 -> low_res back to 480,
                                                  maxfps 60, tickhz 60

That last pair is the assertion that matters: fix did not re-snapshot, because
turning the mode off restored the ORIGINAL 480 and not the 240 that fix had
just forced. If fix had re-snapshotted, 240 would have come back.

Not verified: the text cl_classic status and fix print to the console buffer.
portConPrint goes to the console, not stderr, so the wording is read from the
code rather than seen; the file effects above are the executed evidence.

ctest 11/11.
The console's cl_n64_aa 0 wrote the flag and nothing else, leaving low_res=240,
maxfps=-1 and the rest in force with the mode reported off; cl_n64_aa 1 forced
nothing and took no snapshot. The same hole served the Gameplay Options page,
which also comes through portOptSetIni.

Fixed in portOptSetIni rather than in the console, because it is the one setter
whose callers do no syncing of their own - the toggle, the watch and the VIDEO
sheet each use portOptSetValue/portOptSetIndex and handle the transition
themselves. Putting it at the setter means the next by-text caller cannot miss
it, which is exactly how this one shipped.

Only on a real change: setting n64_aa to the value it already has must not
re-enter the mode, because entering overwrites classic_saved with the forced
values and the player's originals would be gone. portClassicSetLive's own
single write is the only ini write the change makes.

Executed (build_macos, ini restored to eba8465c afterwards):
  ON->OFF: n64_aa=1 + all seven forced, classic_saved=480,1,2,60,4,0,0
           cl_n64_aa 0
           -> msaa 2->4, filtering 3point->bilinear, anisotropy 8->2,
              filter_2d 1->0, low_res 240->480, maxfps -1->60, mipmaps 1->0,
              tickhz followed maxfps; n64_aa=0
  OFF->ON: cl_n64_aa 1
           -> the same seven forced the other way, and
              classic_saved=0,2,0,-1,1,0,0 became 480,1,2,60,4,0,0
  repeated cl_n64_aa 1 while already on -> file byte-identical (no re-snapshot)

The audit's console finding is closed. ctest 11/11.
The previous commit fixed the console by making portOptSetIni run Classic
Mode's transition. Nothing tested that. port/tests/classic_mode.c pins the rule
and would have passed happily while the setter never called it - which is
exactly how the bug shipped in the first place.

This links the shipped port_options.c against a fake option table, so the real
sOpts rows, portOptFind, the optSet path and portClassicSetLive all run. Only
the six bridges out of the file are faked, and the ini writer only counts.

Cases: text ON forces and snapshots; text OFF restores; setting ON while
already ON keeps the ORIGINAL snapshot (the case that would silently destroy
the player's values); setting OFF while already OFF does not restore a stale
snapshot; another key does not transition; a part switched off stays the
player's; and portOptSetValue does not transition on its own, because the
toggle and the watch call portClassicSetLive themselves and a second entry
would re-snapshot. That last one is the contract plan.md Phase 2 inverts
deliberately, so it is written down rather than implied.

Mutation-checked, because a passing test proves nothing by itself:
  - disabling the transition call fails text_on_forces_and_snapshots at the
    first forced value;
  - removing the "only on a real change" guard fails
    repeated_on_does_not_resnapshot at the snapshot assertion.
Both reverted; the original passes.

The fake caught one error of mine while writing it: portVideoClassicSavedSet
stores CLASSIC_SAVED_MAX (7) fields, not the plan's saved_n. The fake now
mirrors that, and the comment says why.

ctest 12/12 (port_options_n64aa added).
… finding

The audit listed the console path as an open defect and had no account of the
three commits that closed it. Left alone, that is the same drift the register
complains about in other documents.

Amended rather than rewritten: the original findings stay as they were
observed, and the fix is recorded beside them with the runs that verified it.
The console section keeps its evidence and gains the note that it is fixed, in
which commit, and why the fix sits at portOptSetIni rather than in the console.

Also fixes a numbering slip of mine in the residual-defects list.
…tion

plan.md said what each phase would do but nothing about the tests it produced,
so the one thing that keeps a fix from regressing was the one thing undocumented.
Section 3.1 now names both ctest entries and what separates them:

- classic_mode pins the RULE (portClassicPlan / portClassicMismatch), sixteen
  cases written from the four defects.
- port_options_n64aa pins the SEAM (the shipped port_options.c against a fake
  option table), seven cases. It exists because the rule test would have passed
  while the setter never called the rule, which is exactly how the console bug
  shipped.

Both were mutation-checked; the plan records that as the bar, not the exception:
break the shipped code, confirm the test fails at the covering case, restore,
confirm it passes.

One assertion is written down as a contract rather than a bug:
set_value_does_not_transition asserts portOptSetValue does NOT transition,
because the toggle and the watch call portClassicSetLive themselves. Phase 2
inverts that deliberately, and the assertion must flip in the same commit -
otherwise the suite correctly refuses the work and the tempting move is to
delete the test.

That is the stated reason for the exception to the one-subject-per-commit rule:
Phase 2 lands code and test together, because splitting it leaves either a red
suite or a deleted assertion in between, and neither is a state to bisect
through. §2.5 records the rule, §4 records the waiver.

Also corrected while here, since a plan that misdescribes what landed is the
drift the audit complains about:
- the console fix landed in portOptSetIni, not by relocating the watch's helper
  (neither option A1 nor A2 exactly), and that also closed the Gameplay Options
  page;
- Phase 1's "temporary optSet log, deleted here" never existed - it was dropped
  because it would have mislabelled Tab and the sheet, which reach the same
  setter;
- ctest is 12/12, not 11/11.
…hat pin it

plan.md Phase 2. The transition moves into the option table's choke point
(optSet), and the three per-surface copies are deleted. An entry point added next
year cannot get this wrong by omission, which is the actual defect: four surfaces
applied the preset, three did not, and each had learned it separately.

Code and test in ONE commit - the documented exception to the one-subject rule.
port/tests/port_options_n64aa.c asserted the OPPOSITE contract until now, so
splitting the change would leave either a red suite or a deleted assertion in
between, and neither is a state to bisect through.

What changed:
- optSet now notices n64_aa changing and applies the mode via portClassicPlan
  (classicApplyMode -> classicInputFill/classicPlanApply), on a real change only.
  Same-value sets stay no-ops for the reason they always did: entering remembers
  the current values over classic_saved.
- portClassicSetLive is deleted rather than kept as an alias. It was the public
  "apply the mode" call each surface made for itself; keeping it exported would
  invite the fourth. Neither the watch nor the console calls it now.
- Deleted: portClassicToggle's explicit call, the watch's rowClassicSync and its
  two call sites, the console verb's explicit call, and the Phase 1 block in
  portOptSetIni (the choke point subsumes it).
- New portClassicSetHold, the mechanism for a surface that lands a batch itself.
  Reset All runs under it, and so does the VIDEO sheet's Apply Changes loop - so
  a pending n64_aa landing mid-loop can no longer apply the mode underneath the
  values about to land on top of it. That was an order dependency that happened
  to be benign; now it cannot be asked.

Test changes, in the same commit:
- set_value_does_not_transition -> set_value_transitions, inverted. It now
  fails if the toggle's setter stops applying the mode, which is the original
  bug one layer up.
- one_set_applies_once: classic_saved must hold the player's values, not the
  forced ones. This is the double-apply guard.
- the_hold_suppresses_the_transition: the sheet's mechanism, plus a check that
  the hold releases (one that leaked would silently disable the mode forever).

Mutation-checked, three ways: disabling the transition fails
set_value_transitions; making the choke point ignore the hold fails
the_hold_suppresses_the_transition; calling classicApplyMode twice fails
one_set_applies_once on the snapshot. All reverted; the original passes.

Executed end to end on build_macos, midas.ini restored to eba8465c after each:
  cl_n64_aa 1   -> seven forced, classic_saved=480,1,2,60,4,0,0 (the player's)
  -n64_aa       -> "Classic Mode on (applied)", forced, same snapshot
  cl_n64_aa 0   -> all seven restored
  cl_classic on -> forced + snapshot;  cl_classic off -> restored
  mode on + low_res=720 + cl_classic fix -> 240, classic_saved unchanged

ctest 12/12. grep -c portClassicSetLive outside port_options.c is 0 for code
(comments only).

Not verified: Tab and the watch/folder screens. They reach the same choke point
as the console and the boot flag, so the mechanism is covered; the keypress
paths themselves have no headless harness and are not claimed.
The seam test asserted values only. Two implementations can agree on every value
and still differ where it hurts: applying a transition twice ends at the same
numbers while taking the snapshot twice, and the second snapshot holds the
FORCED values instead of the player's. The fake now keeps an ordered write log,
counts saves and snapshots, and marks where a save happened, so those are
assertions instead of hopes.

New cases: all four true/false spellings; a refused set (both the text refusal
and a refusing setter) must not transition; exactly one save and one snapshot
per transition; nothing saved before the values land; n64_aa is not row zero;
[input] rows still reach the choke point; the hold, including that it releases;
and coverage for portClassicRepair, portClassicMismatchParts and
portClassicToggle, which had none.

The rule test gains the cases the first audit found surviving: TOGGLE_ON with
mode_on clear must not snapshot, FIX must not snapshot in either direction, a
reused ClassicPlan must not carry state, each NULL field array, n and saved_n
past the buffer bound, and a NULL idx_out.

Two defects the new tests found, both mine:

- portOptSetIni saved twice - once from the transition, once itself. Same values,
  double the churn. classicPlanApply no longer saves; every caller does, once.
- portClassicMismatchParts accepted `max` and ignored it, writing up to seven
  indices into whatever the caller passed. It now counts into its own buffer and
  copies only what fits, still returning the true count.

scripts/port/test_the_tests.sh runs the audit: 18 mutants, each a deliberate
break of the shipped code, each killed by a named case. It reports NO-OP when a
mutation fails to apply and GAP when one survives, so it cannot pass by accident.
All 18 killed, baseline green.

ctest 12/12. Not verified: Tab, the watch and the folder sheet still have no
headless harness; this covers the state they read, not their rendering.
The two headers written this session carried 60% and 57% comment lines - the
history of why each module exists, retold in full. That history belongs in
docs/port/settings-and-save-audit.md, which already carries it; the header should
say what the module guarantees and why the guarantee is shaped that way. Both now
sit at 34-35% and keep the load-bearing notes: the snapshot is decided by the op,
and the fake records work because two implementations can agree on values.

Two inline blocks trimmed for the same reason - the choke point in optSet and
the cl_classic verb - dropping the retelling and keeping the rule.

Counts of things that grow are removed from prose: "all ten tests" in
scripts/port/port_test.sh (the suite is twelve now and will be more), and the
hardcoded 12/12 and case counts in plan.md and the audit. A number in a sentence
is a claim with an expiry date, and three of these had already expired.

No behaviour change. ctest 12/12, mutation audit 18/18 killed.
A user-facing companion to CLI.md: where settings live, what saves when, and how
two settings that look unrelated turn out to move each other.

The audio pump is the worked example, and it earns the space. Its queue floor is
one frame interval, so maxfps and tickhz - graphics and pacing settings - set how
much audio latency you hear. Classic Mode turns hw pacing on, so entering the
preset changes how your sound feels without touching an audio setting. That chain
is invisible from the ini and is the kind of thing a player asks about.

CLI.md already owns the tooling, the console commands and the environment
variables, so this does not repeat them. Section 4 is a routing table instead -
which question, which door - plus the two rules that make a diagnostic honest:
say which renderer you got, and do not trust a save-path test run under
GE_NOINISAVE=1.

Placed at the repo root beside CLI.md rather than under docs/port/, which is
where the design records live and is largely gitignored.

Every claim read from source. Where a number is quoted it is from a real run in
this session's log, not invented. One claim was corrected before commit:
boot_flag_test.sh does not run windowed unconditionally - it has a windowed and a
fullscreen case and sets the mode per case, which is what the script's own header
says and what I had overstated.
Three GE_* knobs interact with Classic Mode or hw pacing, and all three were
verified only by launching the game and reading the log - the same shape as the
boot flags, and the same failure mode: an announcement that never fires is
indistinguishable from a run with nothing to announce. Commits 0e5f786,
89b3b50 and dd46982 shipped with live runs as their only evidence.

The decisions move into port/src/video_env.c as pure functions:

- portEnvTickClash(classic_on, tick_env_set) - takes no boot-flag argument
  deliberately, because gating on the flag is the bug 0e5f786 fixed. The mode
  being on is the whole condition, whichever door it came through.
- portEnvRoomCache(env_set, env_val, hw_pace) - returns touched/value/refused.
  Clamping happens before the hw check, so an invalid value is not reported as a
  contradiction, and asking for 0 under hw pacing is not a refusal either.
- portEnvMipmapClash(classic_on, part_on, mipmap_env_on) - only an explicit 0
  clashes; unset leaves the mode alone, 1 agrees with it.

video.c keeps the logging and the assignment. Nothing calls this module yet.

Eight mutants, all killed by scripts/port/test_the_tests.sh: ignore the mode,
ignore the env, never refuse, refuse but keep the value, no clamp, ignore the
part, treat unset as a clash, ignore what GE_ROOMCACHE said.

ctest 13/13.
Behaviour-neutral. The three conditions in videoInit become calls to
port/src/video_env.c, which the previous commit pinned with a test; the logging
and the assignments stay here, because that is the part that needs SDL and a
console and cannot be a pure function.

Nothing about what the game does changes. Verified by re-running the four live
cases these three commits were originally read from:

  GE_NOINISAVE=1 GE_TICKHZ=120 ./ProjectMidas      (n64_aa=1 in the file)
    -> "Classic Mode with GE_TICKHZ set ... holds are still priced"
  GE_NOINISAVE=1 GE_ROOMCACHE=2 ./ProjectMidas     (tickhz=hw in the file)
    -> "GE_ROOMCACHE=2 ignored - tickhz=hw forces room_cache 0", room_cache=0
  GE_NOINISAVE=1 GE_ROOMCACHE=2 ./ProjectMidas -n64_aa
    -> same refusal via the boot flag (the case 89b3b50 fixed)
  GE_NOINISAVE=1 GE_HWMIPMAPS=0 ./ProjectMidas     -> the contradiction is named
  GE_NOINISAVE=1 GE_HWMIPMAPS=1 ./ProjectMidas     -> silent, as it should be

One deliberate detail: GE_ROOMCACHE's refusal line now prints the value as
typed ("GE_ROOMCACHE=9" rather than a post-clamp "0"), because the portEnvRoomCache
result does not carry the raw text and the typed value is the useful one to see.
For the documented values 0/1/2 the line is byte-identical to before.

ctest 13/13. Not verified: that videoInit is reached in every configuration -
the wiring is exercised by the live runs above, not by a test.
Phase 3 splits n64_aa into classic_mode (the preset) and n64_aa (the VI AA bit).
It is the largest remaining change and the least testable: a dozen call sites in
front.c that no headless harness can see, a shipped ini key that has to migrate,
and one string comparison in the choke point whose key decides whether any of it
works.

So the reconnaissance is done here, while the context is loaded, and handed over
as a prompt rather than left to be rediscovered. What it contains that a fresh
session could not cheaply rebuild:

- The full inventory: 14 s_CfgN64Aa references in video.c classified by which of
  the two concepts each actually reads, 12 in front.c, 4 code sites in
  watchpages.c, plus the table, the tests and the docs. Two sites are marked
  AMBIGUOUS rather than pre-decided, because they genuinely are, and inventing an
  answer would be the failure mode this branch keeps finding.
- The migration rules, tested both ways (runs once; never runs twice), with the
  existing one-time marker pattern named as the shape to copy.
- The boot-flag question (-n64_aa now points at the wrong key) with three options
  and a stated preference, not a mandate.
- The acceptance test for the whole phase: cl_n64_aa 1 and cl_classic_mode 1 must
  behave differently.
- The failure modes of the last three sessions, including the two false findings
  caused by the user's own ini having the preset on, and the test that passed for
  the wrong reason.

Counts were re-verified before writing: 13 ctest targets, 19 commits, 26 mutants.
The V2 handoff said "19 commits / 48", measured before its own commit existed, so
both were wrong by one the moment it landed - the exact drift the same paragraph
warns against two lines later. It now names the tip as a hint and tells the
reader to run the commands, which is what the guidance said and the text did not
do.
Phase 3 of plan.md section 5, first slice. [display] n64_aa carried two
meanings at once - the VI AA bit and "Classic Mode is on" - and that double
duty is the root of the half-state family Phases 0-2 spent their time on.
This commit introduces the second static and the key, and nothing else:
every consumer still reads s_CfgN64Aa, so behaviour is unchanged until the
consumers move in the next commits.

s_CfgClassicMode is the PRESET: the seven forced values, the pillars, the
hitches, the composite filter, and every "is the mode on" question.
s_CfgN64Aa is the BIT: the VI resolve and its edge blend, and nothing more.
classic_n64aa stays the only link between them - whether the preset touches
the bit - rather than the bit being owned by the preset.

The migration is the part that touches the player's real file, so the rule is
a pure function (port/src/classic_split.c, tested and mutation-checked, the
video_env.c shape) and videoLoadConfig only feeds it what the file said:

  n64_aa=1, no classic_mode     -> classic_mode=1 AND n64_aa=1 (the old key
                                   meant both; forcing the preset on preserves
                                   what the player saw)
  n64_aa=0, no classic_mode     -> classic_mode=0, n64_aa=0
  classic_mode present          -> untouched
  classic_split marker present  -> untouched

The marker is written in the same pass that writes classic_mode, and it is
what stops a player's later classic_mode=0 from being resurrected by a
migration that thought it knew better. The two flags are recorded while the
file is parsed, exactly as tickhz records what the file said, because only a
file can answer "did this one already go through the split?".

Write-side, videoWriteDefaultIni emits both keys, so a fresh install never
needs the migration. classic_split is recognised as a known key so a migrated
file is not reported as an unknown control on every boot.

Downgrade is not supported and does not need to be, but it is not
destructive: an older build reading a newer file sees n64_aa (the bit, which
it still understands as the bit) and an unknown classic_mode, which it logs
and ignores.

Verification: build green; port_test.sh --no-game 14 passed (classic_split
new); test_the_tests.sh every mutant killed, baseline passes; midas.ini
byte-identical and eeprom.bin still 3590c7df0edb0930f4e4248826e8ab64. The
migration itself has NOT yet been executed against a live old file - that run
comes with the commit that moves the consumers, when it can be observed end
to end.
Phase 3 of plan.md section 5, second slice - the one where a mistake is silent
and every test still passes.

The choke point in optSet now watches `classic_mode`. That single string
comparison is the whole Phase 2 mechanism moving to the new key: every surface
that writes the table (Tab, the console, the watch, the VIDEO sheet, Reset All)
reaches it, and every one of them follows. The `n64_aa` row is relabelled
"N64 AA" and the new "Classic Mode" row takes its place; classicInputFill,
classicPartChanged, portClassicToggle and portOptResetDefaults now ask
classic_mode whether the mode is on.

The two keys need exactly one link, and it is the part that already existed:
classic_n64aa. classicSyncAaBit, called by the choke point on a real transition
only, sets the bit when the preset comes on and clears it when it leaves - and
does neither when the part is off, because off means the preset does not own the
bit in either direction. Nothing runs the other way: setting n64_aa never
touches classic_mode. That asymmetry is the point of the split; n64_aa=1
classic_mode=0 (N64 edges, no 240p, no forced values) is now reachable, and it
was not before.

video.c gains portClassicSetsN64Aa (the part, as a named accessor) so the index
knowledge stays with the table that owns it, with weak defaults in
libultra_shim.c and the fake, matching portClassicPart's.

Tests carry the change rather than following it:
- port/tests/port_options_n64aa.c is port/tests/port_options_classic.c, and the
  ctest target is port_options_classic; both were named for the key that no
  longer means the mode. CMakeLists.txt and test_the_tests.sh updated.
- New cases, none of which could be written before the split: the bit does not
  run the preset (no snapshot, nothing forced), the preset moves the bit, the
  part off leaves the bit alone both ways, preset-on with the bit off survives a
  same-value set, a part row is not the preset, and the mismatch count ignores
  the bit while still reading classic_mode for "is the mode on".
- The choke-point mutant that matched classic_* is now a SUPERSET of the real
  key and proves nothing, so it is replaced by the mutation that matters now -
  the choke point firing on n64_aa - plus the wide version, kept as a second
  bug, and a mode_on mutation that reads the bit. The part-row case exists
  because the wide mutant survived without it.

Two behaviour notes, stated rather than discovered:
- Turning classic_n64aa off while the mode is on now leaves the bit wherever the
  player last set it, including after leaving the mode. That follows from the
  part gating both directions; the menu shows the bit, so it is not hidden.
- cl_n64_aa / the N64 AA row no longer enter Classic Mode. That IS the split:
  documented in the docs commit, and it is the acceptance pair in the handoff.

Verification: build green; 14 passed; every mutant killed, baseline passes;
midas.ini byte-identical throughout. The live acceptance pair and the migration
run against a crafted old file are in the verification commit, not this one.
Phase 3 of plan.md section 5, third slice: the two surfaces no headless test can
reach. Both are silent on failure - a row key that no longer matches the choke
point simply stops forcing anything while every test stays green - so this is
the commit to read twice.

front.c (the VIDEO sheet), all of it preset semantics:
- the row is classic_mode, labelled "Classic Mode", and n64_aa gets its own
  "N64 AA" row above it. Above, not below, because of the sheet's own rule
  (rows Classic Mode locks go under it) and because the bit is not locked: a
  player can turn the preset's other parts on and still choose the edges.
- portSheetClassicShownOn, portSheetClassicRevert, portSheetClassicSnapshot,
  the deferred pick handler and portSheetApply's classicOff detection all ask
  classic_mode now.

The sheet needed one thing the choke point cannot give it: the bit.

APPLY CHANGES lands the whole sheet under portClassicSetHold, so the choke
point stands down by design - the sheet IS the transition, and its picks are
the plan. That means classicSyncAaBit never runs there, so
portSheetClassicPreset now pends n64_aa=1 (when the classic_n64aa part says the
mode owns the bit) and portSheetClassicRevert pends n64_aa=0 on the same terms.
Without that pick the preset would come on with the edges still off - a look it
never shipped. It also keeps the old behaviour exactly: turning Classic Mode on
used to set the bit because the bit WAS the mode.

port_watchpages.c: the video page's row becomes classic_mode + a new "N64 AA"
row above it, portWatchRowLocked and portWatchPageEntered read classic_mode,
and two comments that named the choke point's key follow it. The watch has no
apply row, so nothing else changes: portOptStep still ends in optSet.

The Nintendo 64 / Modern preset table names BOTH keys, decided rather than
assumed: classic_mode is what actually applies the mode (the choke point runs
the transition, so the preset is a preset and not eight writes), and n64_aa is
named beside it so the N64 look keeps its edges even when the player switched
the classic_n64aa part off. The table is still the vestigial machinery its own
comment calls it - it asks for bilinear where the mode forces 3-point, so a
match there was never a claim that the mode is consistent - and this commit
preserves that disagreement rather than quietly resolving it.

Verification: build green; 14 passed; midas.ini byte-identical. Neither surface
is executable headless, so their evidence is a live matrix (the two rows, Apply
Changes with the preset on and off, Reset All) recorded in the verification
commit. The row keys were checked against the choke point by reading: every
write path for a sheet or watch value goes through portOptSetIndex /
portOptSetValue / portOptSetIni, and all three end in optSet.
Phase 3 of plan.md section 5, fourth slice: video.c's own readers, with the
judgement calls written into the code rather than only here. Fourteen
references; each one is now a decision someone can check.

BIT (the render setting): videoApplyAa's `vi`. This is the one site the split
barely touches - the code and its meaning finally agree, and the comment says
which static answers which question inside the function: the bit decides whether
the resolve runs, the preset decides the 4x MSAA floor.

PRESET (the mode): videoMipmapMode, portClassicPillars, videoBootClassicWanted
(the `== 0` idempotence guard, unchanged in shape), portEnvMipmapClash's first
argument, videoRenderLines, videoClassicViOn.

The two ambiguous ones, decided:

- videoRenderLines read `s_CfgN64Aa && viy > 0`. It now reads the PRESET.
  Rendering menus at the VI's line count is a promise of Classic Mode, and a
  player asking for edge blending has not asked for menu resolution to move. The
  handoff's own fallback - keep the observable behaviour, preset on => true - is
  also met exactly, because the preset is what the old key meant to every caller
  of this question.

- videoClassicViOn read bit && part. It now reads preset && part, and survives
  as a named helper: it is the conjunction of two things that are separate keys,
  it answers "does the mode promise the full N64 resolve" (the floor in
  videoApplyAa), and naming it keeps that call site readable. The bit's own
  answer - "run the resolve at all" - lives one line above it.

portVideoN64AaOn is renamed portClassicModeOn. The old name claimed the AA was
on and returned the preset, which is how a render setting came to answer a mode
question; both callers are in this file and follow it.

Live-apply wiring: a classic_mode change now refreshes videoApplyAa and
videoApplyMipmaps, because the mipmap mode reads the preset. The n64_aa arm
stopped refreshing mipmaps - they no longer depend on it, and the choke point
writes classic_mode before the bit it syncs, so they are already right.

GE_N64AA sets the BIT and only the bit, as recommended: that is what its name
says, the preset now has its own key, and `GE_N64AA=1` no longer forcing Classic
Mode is a user-visible change documented in CLI.md rather than left for
someone's A/B script to discover. No GE_CLASSICMODE is added beside it: a preset
applied from an environment variable would have to run the transition, which
snapshots the player's values and writes midas.ini - the GE_* knobs in videoInit
are values, not actions, and -n64_aa already exists for the action and says what
it writes. That reasoning is in the code comment.

Verification: build green; 14 passed; midas.ini byte-identical. None of these
sites is reachable headless, so the evidence is the live matrix in the
verification commit.
The flag meant "start in Classic Mode" and was named after the ini key it used
to share with the AA bit. The keys are separate now, so the flag gets a name
that says what it does; -n64_aa stays as an alias, accepted by the same parse
(one more comparison, not a second parser), so no capture script, documented
command or muscle memory breaks.

PortBootFlags.n64_aa is renamed `classic` for the same reason - it is the preset,
not the bit - and gains `classic_spelling`: the argv token that set it, used by
one log line and nothing else. A -classic launch that reported itself as
"-n64_aa -> Classic Mode on" would be the same defect as a flag that silently
does nothing, which the file's own header calls out as the reason it exists. A
-n64_aa launch still logs exactly the line the docs and the audit quote.

portBootFlagKnown learns the new name: reporting the documented spelling of a
working flag as "ignored" is the exact bug that function was written to stop.

Tests (port/tests/boot_flags.c, mutation-checked): both spellings set the flag,
the spelling is recorded, given both together it is still one boolean, absent it
is NULL, and -classic_mode is NOT a flag - the end-anchored match must not read
the ini key's name as -classic plus a suffix. parse_clears_previous_state also
covers the spelling, so a reused struct cannot name the previous launch's flag.

scripts/port/boot_flag_test.sh: the `all` case now uses -classic and `n64_aa`
keeps -n64_aa, so one run of the harness exercises each spelling against the
config line. Its stale comment ("the flag writes n64_aa=1") now says what the
split made true: it writes classic_mode=1, and n64_aa=1 beside it through the
preset's classic_n64aa part.

CLI.md section 3.2 documents both names as one flag, and the GE_N64AA paragraph
from the previous commit is corrected in place: the env var sets the AA bit and
no longer forces Classic Mode, which is the flag's job.

Executed, from a file with classic_mode=0 / n64_aa=0 / low_res=480 / maxfps=60:

  ./ProjectMidas -classic   -> "ge: video: -classic -> Classic Mode on (applied)"
                               low_res=240 maxfps=-1 n64_aa=1 classic_mode=1
  ./ProjectMidas -n64_aa    -> "ge: video: -n64_aa -> Classic Mode on (applied)"
                               low_res=240 maxfps=-1 n64_aa=1 classic_mode=1

Both runs left eeprom.bin untouched and midas.ini restored afterwards. Renderer
on this machine is Apple Software Renderer (sandboxed shell, CLI.md 7.2).
Phase 3's documentation, plus the two documents that would otherwise ship a
stale line.

tools/midas_ini_help.txt is copied verbatim into the build directory, so a stale
row ships with the game. It gains classic_mode (what it owns, what it forces,
that Tab toggles it, that an old n64_aa=1 file is migrated once) and the n64_aa
row is corrected: it used to claim the pillars, which were never this key's, and
it now says plainly that it is a separate setting and that classic_n64aa is the
only thing that couples them.

SETTINGS.md section 3 gains "Two keys, not one": the preset, the bit, the part
that links them, the one-time migration, and the two console verbs by key
(cl_classic_mode / cl_n64_aa) with the acceptance pair's difference spelled out.
The section's opening line names classic_mode and -classic.

docs/port/settings-and-save-audit.md gets a Phase 3 section APPENDED - the
earlier findings are not edited, for the same reason the register has kept its
wrong first readings all along. It carries the executed matrix (the migration,
the marker, the acceptance pair, the repair, the flags, GE_N64AA) and the one
thing NOT executed: the VIDEO sheet and the watch, which have no headless
harness. That gap is named rather than papered over; Phase 3 did not create it
and did not close it.

plan.md section 5 is marked landed, with one deliberate divergence from its own
exit criteria stated in the document: the migration writes the two keys and the
marker but does NOT force the classic values, so a hand-edited
`n64_aa=1 low_res=480` boots with the mode on, low_res still 480, and the boot
report naming the mismatch for `cl_classic fix`. Forcing at load means taking a
classic_saved snapshot and writing midas.ini from inside the loader; after the
four half-states this branch hunted, naming a mismatch beats silently
overwriting the player's values. A player who was already consistent - anyone
who used Tab - migrates to a consistent state.

docs/port/room-cache.md keeps its investigation record and gains one sentence
saying the flag is spelled -classic now, the old name still works, and it sets
the preset rather than the bit.

Verification: build green (the help file is re-copied), 14 passed, mutation
audit green, midas.ini restored byte-identical and eeprom.bin still
3590c7df0edb0930f4e4248826e8ab64 after every live run.
Two sites the inventory in NEXT_LEVEL_AGENT_V2.md did not list, found by
grepping for the key after the docs commit rather than by trusting the list.

port_console.c's `cl_classic on|off` still wrote n64_aa. With the keys split
that made the verb do the one thing it must not: turn on edge blending and
leave the preset off - `cl_classic on` reporting success while nothing was
forced. It writes classic_mode now, and its status line reads classic_mode, so
"is the mode on" answers from the preset like every other surface. `fix` was
already correct (it goes through portClassicMismatchParts/Repair, which read the
preset).

port/src/port_option_help.c described the n64_aa row as "N64 look and pacing.",
which was the conflation in one help line. It now says edge blending alone, at
your own resolution, and there is a classic_mode line beside it for the new row.

Executed, from a file with classic_mode=0 / n64_aa=0 / low_res=480 / maxfps=60:

  cl_classic on   -> classic_mode=1 n64_aa=1 low_res=240 maxfps=-1
                     classic_saved=480,1,2,60,4,0,0
  cl_classic off  -> low_res=480 maxfps=60 n64_aa=0 classic_mode=0  (the
                     snapshot given back exactly)

build green, 14 passed, midas.ini restored byte-identical afterwards.

This is the second time this branch's inventory has been the thing that was
wrong rather than the code (the handoff records the first): grep for the key
before believing the list.
The cl_audio_pump block drew its dark plate from x=0 to viGetX(), so six short
rows of diagnostics painted a translucent band across a third of the picture.
Over the black front end that is invisible; over a level it is a rectangle
sitting on the HUD for no reason.

The plate now measures the widest row and pads it by two pixels, which is what
the skip hint in the same file already does. Nothing about the numbers, their
layout or the row pitch changes - only where the plate stops.

Seen rather than assumed: in-level captures with the block on, before and after,
read side by side. The block is a diagnostic overlay, so this is cosmetic and
untested beyond that look - no assertion was added, and none would be worth one.

Verified: build green, 14 passed, midas.ini and eeprom.bin restored byte-identical
after the capture runs.
The plate hugged the text. It now pads by one MEASURED character on every side -
top, left, right and bottom - so the block reads as a plate rather than as a
highlight behind the glyphs.

Measured, not a pixel count, because textMeasure carries port_text_scale: a
fixed few pixels collapses at other scales and the pad has to grow with the
glyphs. The text moves IN by the pad rather than the plate growing outward,
because the block sits at the window corner - padding outward would push the top
and left edges off the screen and clip them.

Seen in-level with the block on, over level geometry rather than the black
legal screen. Cosmetic diagnostic overlay: no assertion added, and none would be
worth one. Build green, 14 passed, midas.ini and eeprom.bin untouched.
The boot sequence holds each screen on its own timer - about 4 s for the legal
screen, 8 s for the Nintendo logo, then Rare, the eye and the GoldenEye logo -
which is most of a slow launch, and all of it dead time when the thing being
captured is a menu or an overlay.

  -skip [N]     skip the boot screens. Bare means all five, i.e. land on
                Select File; N means the first N, so -skip 2 stops on the Rare
                logo. -skip 0 does nothing.
  -stage <name> start a mission directly, the same names and numeric ids as
                GE_STAGE, which it overrides.

The chain is legal -> Nintendo -> Rareware -> eye -> GoldenEye logo -> file
select, and portBootSkipTick in front.c walks it with frontChangeMenu(next,
TRUE) - the call the real screens make on their timers and on a button press.
Nothing reaches into a screen's internals: each update_* still runs and still
tears its own state down, and the eye intro already supported jumping to the
logo mid-animation when someone mashed a button, so an early advance there is a
path the game has always had.

It drives the transition rather than faking input because the legal screen
cannot be button-skipped on a first boot: interface_menu00_legalscreen ignores
ANY_BUTTON until is_first_time_on_legal_screen clears. The budget is spent once
per arrival - MENU_SWITCH_SCREENS is the fade between screens and is left alone
- so a skip cannot spend itself on one frame's worth of ticks.

Values come from the NEXT argument, like --rom-file and --lang. A word that is
not a number after -skip leaves the flag bare and the word untouched, so a ROM
path can never be eaten; -stage with nothing after it, or followed by another
flag, asks for no stage. A bare -skip outranks a count, since it is the more
aggressive request. -skip N does not consume anything a later flag needs.

Executed, capture read by eye each time:
  -skip        -> "ge: -skip: skipping 5 boot screens", then SELECT FILE with
                  the pump block over the file-select background
  -skip 2      -> the Rareware logo, fading in
  -stage dam   -> "ge: -stage dam -> straight into the mission", stage 90,
                  level rendering
  harness      -> scripts/port/boot_flag_test.sh -s skip: ok, 3 shots

Tests: boot_flags.c gains five cases covering eight assertions (bare = all, a
count, 0 = off, count outranked by a bare -skip, a non-number is not eaten,
-stage takes a name, -stage does not swallow a flag, and the two compose), and
test_the_tests.sh gains four mutants. Five were written; the fifth was dropped
rather than kept, because it survived - consuming the count changes nothing the
parse reports, so it would have been a test that cannot fail.

Docs: CLI.md section 3.2 gains both flags with the "waiting, not the intro"
distinction and the difference from GE_STAGE; readme.md gains a user-facing
"Starting up faster" section, since this is the first flag a player would
reach for. The live harness gains a skip case that asserts the log line - the
SCREEN it lands on is the capture's job, not the log's.
@JayBeest

Copy link
Copy Markdown
Author

This is a starting point.

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