Fixes for nsstool, furtool and the nullsound driver #166
Description
This is actually a reply to this discussion: #164
But since this this touches so much more it deserves its own issue.
I've updated the devkit version I run on my machine so I'm running the latest now.
A friend told me to try out Claude Code, so I did. Damn, this thing is pretty powerful. I had it make a version of furnace called furnace-cli. And to it I can now feed MML files/strings. This opened up a new possibility of testing, paired with the MAME feature of sound recordings and comparisons.
So it created a bunch of tests. One or two of these may be hallucinations or bullshit that will never happened either way, but I don't know which is which. And the lists aren't too big. Maybe there are some good points in here? I'm too dumb to understand what much of this means 😅
Here's the report it made for me:
furtool.py, nsstool.py and nullsound: proposed changes
These are against ngdevkit 0.5+202610171524, upstream master 5106cbd.
Changes to implement
furtool.py / nsstool.py
- Accept songs without samples. A Furnace song (v240+) with no samples has no
sample element (type 6). furtool.py and nsstool.py then fail withKeyError: 6,
which affects every FM- or SSG-only song. Treat the missing element as an empty list. - Use the FM defaults when an instrument has no FM block. Furnace leaves out an
instrument's FM feature block when it equals the defaults, for example a new,
untouched FM instrument. furtool.py then fails onNone(AttributeError: ... 'name').
Fill in Furnace's default FM values. - Handle the phase-reset macro on SSG instruments. Furnace offers it (macro code
14), and furtool.py fails withIndexErrorbecause its macro-name list stops at 8.
Either support it or give a clear "unsupported macro" error.
nullsound
- Mask or bounds-check the command byte.
snd_process_pending_commandsuses the
byte from the 68k to indexcmd_jmptable, which has 128 entries, without masking it.
A command from 0x80 to 0xFF jumps into whatever code follows the table, instead of
being ignored like other unused commands. A one-instruction fix (and #0x7f, or a
compare that routes tosnd_command_unused).
Found by reading the code; not yet reproduced in MAME. - Check the RAM layout in the game's own link, not only the library's. The
page alignment of the state blocks is fixed padding (align.inc), which assumes
nullsound'sDATAstarts at 0xF800.- If a game's Z80 code with
DATAis linked beforenullsound.lib, the blocks
shift. 16 bytes moves_state_fm2to 0xFB15–0xFC0F, across a page, and the 8-bit
pointer arithmetic on it then reads and writes the wrong bytes, silently. check-bounds.pyruns only on the library's own link (linkcheck.map).- Possible fixes: document that game
DATAmust come after the library, ship
check-bounds.py for games to run on their own link map, or place the aligned
blocks in an absolute area so their position doesn't depend on link order.
- If a game's Z80 code with
Low priority, or probably fine as it is
- Stack space. The driver's variables end at 0xFF96 and the stack starts at
0xFFFD, which leaves about 103 bytes. A game's variables linked after nullsound go
into that space, and nothing checks it. This is probably fine, but the real
worst-case depth hasn't been measured. Worth a look only if a measurement shows it's
tight. - Padding uses RAM. 821 of the 1942 bytes of
DATAare alignment padding. This
is a known trade-off for fast pointer arithmetic, and matters only if a game runs out
of Z80 RAM. - The command queue doesn't check for overflow. The 64-entry ring buffer in
snd_push_pending_commandwould lose commands if 64 arrived before the main loop
ran. That's unlikely in practice: the 68k waits for an acknowledgement, and the
main loop empties the queue quickly. - Interrupts after a driver reset. Commands 2 and 3 restart the driver with
interrupts off, and they come back on only with a song'stimer_tempoopcode. This
is probably by design (see the old open PR Enable interrupts before jumping to PLAYER_START #112). - Open PR Fix stream volume commands ignored at boot #160 ("Fix stream volume commands ignored at boot") is still unmerged,
so the bug it fixes is in the released driver. - The SSG macro names in furtool.py are off by one place. It calls code 5
"phaseReset", but code 5 is ex1 (phase reset is 14). Code 5 never appears in a
YM2610 SSG instrument, so it's cosmetic. - The MVS/AES clock is taken from the system name ("MVS" or "AES" in it), not from
the chip flagclockSel. Furnace's presets set both together, so this only matters
if someone renames the system. - Messages and wording.
- Operator macros (
O1–O4) only give an "unexpected feature" warning. - CSM songs (system 0xc2) fail with "invalid chip".
- nsstool.py rejects a first note without an instrument; that's intended, but
could be documented.
- Operator macros (
- Tempo changes. Furnace's
F0xx(set BPM) isn't supported by nsstool.py. This is
a feature request. - Small cleanups in nullsound.
snd_command_unusedreturns withretnbut is called from the main loop
(harmless, sinceretnacts asretthere).- There is a dead
retnafterjp snd_push_pending_commandin the NMI handler. - The
nullsound_idstring still says v0.2.0.
Activity
sasja-san commentedon Oct 19, 2026
I had all these Claude credits burning a hole in my pocket. So I thought
So here are the changes it proposes. As usual, clanker code can be a bit unreliable. But I'm runnig my AI the second most expensive level (without shoveling in a bunch of extra money).
Here I'm assuming anyone maintaining this project is like me: They want it to be really good, but they know that generated code can get a bit hairy at times. And you lose your familiarity, because the changes didn't pass through oneself. So instead of just blindly making commits, here's a list of proposed changes. For a caring human to look at 😉
Proposed changes to furtool.py and nullsound
These changes are against ngdevkit master 5106cbd ("Fix various parsing errors for
Furnace v240+", 2026-10-17). They come from testing Furnace v255 (dev255) modules
through furtool.py/nsstool.py, and the driver in MAME 0.285 with SNK's BIOS. Each
change was verified on its own; the results are given with each one.
KeyError: 6for every song without samples'NoneType' object has no attribute 'name')IndexError,KeyError: 'pitch')cmd_jmptable, run RAM as code1. furtool.py: songs without samples, instruments or patterns
A Furnace module (v240+) only lists the element types it has. A song without
samples, for example any FM- or SSG-only song, has no element 0x06.
read_module()then raises
KeyError: 6in furtool.py and nsstool.py. Instruments (0x04) andpatterns (0x07) are read the same way.
Tested: an FM-only song and SSG-only songs, all without samples. Before the
change, furtool.py and nsstool.py fail with
KeyError: 6; after it, both convertthe songs.
2. furtool.py: no SSG volume adjustment when the chip has no flags
5106cbd handles a FLAG element that points to offset 0, which is how Furnace saves
a chip without flags. But in that case neither branch runs, so
HALF_SSG_VOLisn'tset. A chip without flags uses Furnace's defaults, including the half SSG volume,
the same as when there is no element 0x02 at all.
Tested: an SSG instrument with volume macro
15 12 9.0x0e 0x0b 0x08, with the usual "Volume of SSGchannel is halved" message.
ssgVol=256it still gives0x0f 0x0c 0x09.3. furtool.py: FM instruments that equal Furnace's default
Furnace writes an instrument's FM feature only when the FM data differs from its
default (
DivInstrument::putInsData2). A new, untouched FM instrument therefore hasno
FMfeature.insstaysNoneandread_instrument()fails withAttributeError: 'NoneType' object has no attribute 'name'.The fix uses Furnace's default values, from
DivInstrumentFM()insrc/engine/instrument.h. It also moves the detune conversion into a function thatboth paths use:
Tested: an untouched FM instrument now converts. Its output is byte-for-byte the
same as for that instrument with the FM feature present (forced by changing only a
field furtool.py doesn't read,
block).4. furtool.py: SSG macros that nullsound doesn't play
read_ssg_macro()names macro codes 0–8 only, and the name for code 5 is wrong.Furnace's codes (
DivMacroType) are:denominator);
That causes two crashes on macros that Furnace's SSG instrument editor offers:
IndexError: list index out of range.KeyError: 'pitch',because only codes in
code_load_bitcan be played.The change lists all of Furnace's codes, and skips the ones nullsound doesn't play
with a warning:
The existing warning for keys without an NSS offset printed a stale loop variable
instead of the key it was about:
Tested:
-vshows "SSG macro not supported by nullsound, ignored: …".difference in its output is change 2's volume adjustment, since the test module
had no chip flags.
5. nullsound: commands 0x80–0xFF jump past the command table
snd_process_pending_commands(entrypoint.s) uses the command byte from the 68kdirectly as an index into
cmd_jmptable, whichinit_unused_cmd_jmptablefills to128 entries. A command from 0x80 to 0xFF jumps to whatever follows the table.
Unknown commands are now ignored, as unused entries below 0x80 already are.
Reproduced in MAME 0.285 with a game whose
cmd_jmptableis the last thing in itscode (at 0x2BB4). A Lua autoboot script wrote command 0x80 to the sound latch at
0x320000after the eyecatcher, and the Z80 was traced (-debug -debugger none,trace):0xF800 (
command_fifo), where the command byte itself becomesadd a,b. Theseinclude
diandadd hl,hl.rst $30, which lands on aret.The
dileaves interrupts off, which stops the timer that drives music. Whathappens depends on what's in RAM at that moment.
With the change: commands 0x80, 0x81, 0x90, 0xC0, 0xFE and 0xFF were each sent,
then a valid command. Execution never left the driver code, and the queue
(
command_fifo_current == command_fifo_pending) kept going. Commands 0x05, 0x06 and0x7F behave as before. A 16.2 s recording of the boot jingle (
-wavwrite) isidentical, sample for sample.
6. nullsound: check the RAM layout in a game's own link
nullsound's state blocks must each stay within one 256-byte page, because they're
indexed with 8-bit arithmetic. The alignment is fixed padding (
align.inc). It'sright when nullsound's
DATAstarts at 0xF800, whichcheck-bounds.pyverifies, butonly for the library's own link (
linkcheck.map).A game's link can break it in two ways, and nothing reports either:
DATA, linked first) move theblocks. With just 16 bytes in front,
_state_fm2spans 0xFB15–0xFC0F, across apage, and the driver silently reads and writes the wrong bytes.
alone leaves 103 bytes between the end of
DATA(0xFF96) and the top of thestack (0xFFFD). In MAME I measured 42 bytes of stack used during the jingle with
a burst of commands, by filling the free RAM with a pattern.
There's also a trap in the current script: on a map made without
-w, the narrowformat cuts the symbol names, nothing matches, and the check passes having checked
nothing.
The proposal makes the script useful for games:
-m -w);--stack N) stay free between theend of
DATAand the stack;nullsound-check-bounds.py, so a game can run it on its ownlink map.
check-bounds.py:Makefile.in:Tested on these link maps:
linkcheck.map(library build)DATA)DATAlinked after nullsound.libDATAlinked before nullsound.librange "_state_fm2" crosses 256 bytes boundaries: [FB15, FC0F]; link nullsound.lib before any object with area DATADATAlinked after nullsound.libonly 55 bytes between the end of DATA (0xffc6) and the stack (0xfffd); nullsound needs 64--stack 40-wno nullsound state ranges found; link with sdld -m -w …A longer-term option would be to make the layout independent of link order, for
example by placing the aligned blocks in an area of their own at a fixed address.
That would also free most of the 821 bytes of padding in
DATA(out of 1942).dciabrin commentedon Oct 19, 2026
There's a lot to unpack here, half of which is misleadingingly characterized as bugs...
Generally speaking as a maintainer, I do not like having a single bucket of fixes because it's unbounded work and it's hard to have a sense of progress with such reports.
That plus the fact that AI-generated reviews even out of good will generally flood me as a maintainer, so please that the time to understand the issue if it impacts you before filing a bug, and be prepared to answer questions in case I challenge the code that is proposed.
For now on, I'm keeping it as low prio because I need that a few issues are effectively valid.
sasja-san commentedon Oct 19, 2026
Very reasonable stance.
I won't bother you with any more reports like this, then. I was suspecting Claude was venturing off into hallucination land with some of this 😉