# Cross-title audio pass — 2026-10-27 (V4)

Scope: **Fatal Fury 3** (new) and the existing **Garou: Mark of the Wolves**,
**KOF '96**, **KOF '97**, **KOF 2000** and **Metal Slug 3** ports.

## What was already in place

The previous passes had fixed two real defects in all five existing ports:

* the Z80 M1 bank window used the wrong base/mask and read the wrong music
  data;
* the sample pump did not advance the Z80 at the frame boundary, so a 68000
  that stopped polling the latches could starve the sequencer.

Those corrections remain. The five shipped projects were checked to contain
them before this pass.

## Defect 1 — YM2610 timer status was tied to IRQ enable

`src/audio.c` set a timer's status bit only if the timer's IRQ enable bit was
set:

```c
if (s_ym.ctrl & 0x04) s_ym.status |= 1;
```

On a YM2610 the overflow flag latches whether or not the IRQ is enabled;
`$27` bits 2/3 only gate the IRQ line. A driver that polls status with IRQs
masked would never see the tick. The code now latches status unconditionally
and masks only the Z80 IRQ line:

```c
s_z.int_pending = ((s_ym.status & 1) && (s_ym.ctrl & 0x04)) ||
                  ((s_ym.status & 2) && (s_ym.ctrl & 0x08));
```

This is generic and affects the shared runtime, not a game.

## Defect 2 — SDL queue ordering, partial frames and underrun recovery

`src/window_sdl.c` had three output-path issues:

1. It measured the device queue **before** draining the converted stream, so
   a trim decision was always one frame late.
2. `SDL_AudioStreamGet` could be asked for a byte count that was not a whole
   device frame; a partial frame in the queue shifts stereo alignment.
3. Nothing recovered a device queue that ran dry between frames. SDL then
   plays silence until the next producer frame, which is a click/dropout
   under scheduler jitter or a busy compositor.

The new path drains the stream in whole frames, queues first and then trims
(oldest excess above 250 ms down to 60 ms), and if an already-started queue
is below 8 ms it re-cushions to 60 ms once. Real samples are never dropped.
`NG_AUDIO_TRACE=1` now also prints an underrun count.

## Measured results

| Game | Capture | Result |
|---|---|---|
| Fatal Fury 3 | 12,000 frames / 202.74 s, `tests/play.txt` | peak 28,075; RMS 3668/3600; no clipped samples; no >=3 s silent run after 10 s |
| Garou | existing 19,000-frame soak | rebuilt; audio-check PASS; no music-sized gap |
| KOF '96 | existing 12,000-frame soak | rebuilt; audio-check PASS; continuous from select |
| KOF '97 | existing 19,000-frame soak | rebuilt; audio-check PASS; HOW-TO-PLAY gap is content |
| KOF 2000 | existing 19,000-frame soak | rebuilt; audio-check PASS; round/continue gaps are content |
| Metal Slug 3 | existing 12,000-frame capture | rebuilt; audio-check PASS; mission-load gaps are content |

The quoted Fatal Fury 3 figures come from the WAV written after synthesis and
before the SDL device layer, so they measure the emulated sound chain. The
SDL changes were tested separately on a device queue with
`NG_AUDIO_TRACE=1`; they cannot be measured by `--wav`.

## Not tested

* Subjective listening on any device.
* Physical Steam Deck/SteamOS speakers, Bluetooth, Gaming Mode.
* Physical gamepads.
* Two-player and every music track in every game.

The conclusions are waveform/timeline and queue-statistics measurements, not
listening tests.
