PC-98 driver families — how each one is re-hosted
This document records, per sound-driver family, how hoot’s PC-98 (pc98dos) sets
deliver a song to their driver and how the hootrip harness reproduces that so the
driver plays and we can log its OPN/OPNA register writes to S98/VGM.
It focuses on the families brought up in the 2026-07-24 “remaining families” campaign, with enough of the shared model to make the fixes legible.
1. The shared model
A pc98dos set is a real MS-DOS re-host, not a memory blob:
- Materialize every
<rom>file into a virtual DOS CWD (real.COM/.EXE/ data files live there). - Bind rom → DOS handle: hoot presents each
<rom offset="N">FILE</rom>on DOS file handleN. In the harness (bind_rom_handles):type="file"→ the file’s content is readable on handleN.type="conin"→ the filename text (ASCIIZ basename) is on handleN.offset="-1"→ materialized on disk only (open-by-name works), not handle-bound.
- Load device drivers (
<rom type="device">*.SYS/.DRV/.EXE) CONFIG.SYS-style: run their INIT so they install their API interrupt. - Run the shell chain (
<rom type="shell">in order). The last command is usually a tiny hoot glue stub (xxxx_98.COM) that installs one of hoot’s externalCommand vectors, INT 7Eh or INT 7Fh, and then idles. - Trigger playback: the harness sets virtual ports and invokes the stub’s INT 7Eh/7Fh. The stub loads the selected song and starts the driver, whose timer ISR (OPN chip timer on IRQ3 = INT 0Bh, the PC-98 PIT on INT 08h, a slave IRQ like INT 14h, or the CRT VSYNC on IRQ2 = INT 0Ah) then sequences the music. We record every OPN register write during this phase.
Virtual trigger ports (modelled in pc98/io.rs)
| Port | Name | Meaning |
|---|---|---|
| 0x7E0 | EXT_CMD |
command byte: 0 = play, 2 = stop |
| 0x7E2 | EXT_SONG |
song word the harness sets before the trigger |
| 0x7E4 | EXT_PARAM |
extra selection parameter |
| 0x7E8 | EXT_STATE |
stub handshake (0x81 = ready) |
Song selection (selected_song) and delivery (bind_trigger_song)
selected_song(title_code) picks the song file by matching offset against the
title code — a file rom (whole code, then low byte), then a conin rom at the
low byte (excluding engine names ending .EXE/.COM/.DRV/.SYS, so a low byte
that collides with an engine handle can never select the engine as the song).
bind_trigger_song then presents it in the convention the driver expects. The
central discovery of this campaign is that three unrelated drivers share one
bug: they open the song file by name themselves, but hoot’s stubs were handing
them the file content. They are handled by the opens_by_name branch, which
puts the filename text (not content) on handle 0.
2. Families
cplay98 (~87 games) — Madou Monogatari, Puyo Puyo, …
- Chain:
fplay2(driverFPLAY2.COM) +cplay98(stub, INT 7Eh). - Songs: multi-song
.datbanks (Song1.dat…), listed asconinroms at offsets 5–9. Title code0xSS00LL: low byteLLpicks the bank, byte 2SSis the 0-based in-bank song index. - How it plays: the stub reads a filename from handle 0 and calls FPLAY2’s
INT 7Fh AH=9, which doesint21 AX=3D00(open by name), reads and parses the whole.datitself, thenAH=0 AL=SSplays the in-bank index. - Bug:
selected_songonly matchedfileroms, so theconinsong banks returned nothing → handle 0 empty → the driver opened""→ nothing loaded. - Fix:
conin-rom fallback inselected_song;opens_by_nameputs the filename on handle 0; port 0x7E4 = byte 2 (the in-bank index). - Result: 7/8 sampled OPN games audible (−4…−12 dBFS); BEEP variants correctly silent.
artdi_98 (~52 games) — A-Train, Atlas II, Lunatic Dawn, …
Combined driver+stub ARTDI_98.COM (byte-identical across the family) overlay-
loads a per-game engine EXE (named on conin handle 5, AH=4B03) and hooks INT 7Fh.
Two song layouts:
- Separate
.NTLfiles (A-Train): one file per song at offsets 0x10+; title low byte = the file offset. The engine is VSYNC-paced — it hooks INT 0Ah (IRQ2), unmasks only IRQ2, and both calibrates on and sequences off the CRT vertical retrace. It hung in setup because the harness had no VSYNC source.- Fix: added a general ~60 Hz VSYNC/IRQ2 → INT 0Ah source (see §3).
- Packed
NTL.PAC(Atlas II, Lunatic Dawn, How Many Robot 2): all songs in one pack bound on a handle; title0x[HH][SS]= pack handleHH+ in-pack indexSS. ARTDI’s INT 7Fh handler takes a packed branch when the high byte of port 0x7E2 is nonzero: it seeks that handle and indexesSSitself via the pack’s leading LE32 size table (offset(SS) = N*4 + Σ sizes[0..SS)).- Fix:
bind_trigger_songself-identifies a packed title (a.PACfilerom at offset = title byte 1) and presents the full0x[HH][SS]word on 0x7E2.
- Fix:
- Result: 8/8 sampled audible; both layouts covered; A-Train unchanged.
mbmusp (~19 games) — Estate, Ce’st la vie, Coming Heart, …
- Chain:
MUSDRV(“Music driver 1.01b, YM2608”) +MBMUSP(stub, INT 7Fh). - How it plays:
INT 68h AH=1= open song file by name, read, deobfuscate (bswap/rol/not/ror — a raw buffer cannot be played), then start OPN Timer A and self-clock. The stub hands AH=1 a memory buffer, so its open failed → the timer was never started (song began then froze after ~0.35 s). - Two-part fix:
- Open-by-name: filename text on handle 0 (the stub copies it into its
buffer and passes it, so the open now finds the materialized
.MSB). - IRQ vector jumper: MUSDRV picks its ISR vector from OPN SSG reg 0x0E
bits 7-6; the default
0x00makes it hook INT 0Bh (master IRQ3), but its ISR EOIs/unmasks the slave PIC — so the master IRQ3 stayed masked and 0 IRQs were delivered. Presetting reg 0x0E = 0xC0 makes it hook INT 14h (slave IRQ12), consistent with its own EOI. (preset_muse_irq_jumper.)
- Open-by-name: filename text on handle 0 (the stub copies it into its
buffer and passes it, so the open now finds the materialized
- Result: 7/8 sampled audible (−6…−22 dBFS).
mdrv_98 acidplan family (~10+ games) — Charm, Present Duo, Metal Mover, …
- Chain:
MDRV(driverMDRV.COM) + a-t<timbre>.TDTload +MDRV_98/MDDRV_98stub. - Hypothesis inverted: residency detection and the
-ttimbre load both work in sequence (a--tracein isolation was misleading, and exit 255 for a resident-toption is normal; the driver also ships an embedded default timbre bank). The real bug was again open-by-name: residentINT D2h AL=2= “load song file by name”; the stub passed the KMD content, the open failed, and AL=1 parsed an empty buffer (4 key-ons then silence). - Fix: add the stub prefix
mdrv_9/mddrv_9toopens_by_name→ filename on handle 0. Scoped deliberately to the underscore stub, notmdrv98, to leave the separate MDRV98+mlp_hoot family (below) on its content path. - Result: 6/8 sampled audible.
MDR external-voice (7 sets) — Zeta, Ginga Tetsudou, J.League, …
MDR.EXEis a DOS char device. The small build (~7.5 KB) keeps FM timbres in external.VOIfiles; the big build (~25 KB, Wolf Pack) embeds them.- Title
0x[VH]00[SH]: byte 2VH= the DOS handle of the.VOItimbre bank, low byteSH= the song.SEQhandle. The stub reads the voice handle number from port 0x7E4, loads that handle into the driver (INT 2Fh/40h AX=879B BX=1/2), then plays the song from handle 0. Without the voice the FM operators are never programmed → notes at reset attenuation → silent. - Fix:
bind_trigger_songself-identifies (a.VOIrom at offset = byte 2) and sets port 0x7E4 = byte 2. Wolf Pack (embedded, byte 2 = 0) is untouched. - Result: the external-voice sets render (Zeta −18, Ginga Tetsudou −21).
MUSE device family (~10 games) — NMUSE / MUSE2 / MUSE3 / SDD_2
.DRVchar devices +muse_98.comstub. The stub locates the resident driver via the segment word of INT 14h (0000:0052); the drivers choose their vector from OPN SSG reg 0x0E and, at the default0x00readback, hook INT 0Bh/15h — never INT 14h — so the stub reads a bogus segment, wild-jumps, and never installs.- Fix: in
load_device_drivers, when the loaded device header name containsMUSE/SDD, preset reg 0x0E = 0xC0 before INIT so all three hook INT 14h. - Result: NMUSE works (Rakuichi −6.6). MUSE2/MUSE3/SDD still spin in the stub after the vector is fixed — an additional, unresolved blocker (~6 games).
- Note: the ASCII
muse.comvariant (bpbb98) is a different driver that self-loadsMUSE.DRVfrom handle 5 and already worked; no change needed.
MDRV98 + mlp_hoot family — Markadia, Twins, Camel-Zoo, Bakutotsu, Owl-Zoo
MDRV98.COM(“MDRV2 ver 3.4E”) +mlp_hootstub (INT 7Fh → reads the song content from handle 0 → drives MDRV2 via INT F2h). This uses the content path, not open-by-name.- Not broken. It renders real music at every real song index (OPN and OPNA,
−10…−18 dBFS). The earlier “captured-but-silent” report was a sweep artifact:
title index 0 is a
演奏停止(playback-stop) pseudo-track, silent by design. The only care needed was to keep the mdrv fix off this family (themdrv_9scoping).
3. General levers (help beyond one family)
- VSYNC / IRQ2 → INT 0Ah source (
io.rstick_vsync+harness.rsdeliver_timer_irq): a ~60 Hz retrace latch delivered as INT 0Ah only when a driver has hooked INT 0Ah and unmasked IRQ2 at the master PIC. Frame-paced engines (artdi separate-file, and the infra “Family B”/VSYNCMAN needs) advance one tick per frame; theout 0x64VSYNC ack clears the latch. Drivers that pace on IRQ0/IRQ3 leave IRQ2 masked and are unaffected. selected_songconin-song fallback with an.EXE/.COM/.DRV/.SYSengine-guard.- Open-by-name delivery (filename text on handle 0) — one branch now serving cplay98 (FPLAY2/AH=9), mbmusp (MUSDRV/AH=1), and mdrv_98 (MDRV/AL=2).
- OPN SSG reg-0x0E board jumper preset — makes MUSE and mbmusp select the IRQ vector consistent with their own EOI code.
4. Measurement caveat — the index-0 stop-track
A smoke sweep that rips title index 0 false-negatives 146 pc98dos games
whose first title is a stop/SE pseudo-track ([STOP], 演奏停止, 無音, SND OFF),
which are silent by design. Real coverage is materially higher than an index-0
sweep shows. Use famtally.py, which selects the first non-stop title, or rip all
titles and treat a designed-silent track as expected. The Markadia “regression”
was entirely this artifact.
5. Verification
- Every fix is verified by rendering (libvgm
vgm2wav, peak dBFS), never by write count alone (a driver can log plausible-but-silent registers). - A 14-family known-good baseline (
baseline_ref.txt) is re-run after each change; all fixes above landed with 0 regressions and the full test suite green.
6. Still open (per-family RE remaining)
- MUSE2 / MUSE3 / SDD_2 (~6 games): stub spins after the vector jumper.
- Charm 2’s MDDRV_98 variant: some indices deliver 0 IRQs (timer/vector quirk).
- Family B “Night Seep” (VSYNCMAN + SNDDRV2): the VSYNC infra is in place, but VSYNCMAN’s frame-callback chain and song delivery need wiring.