Re:The New ROM Antics – Building the ZX Spectrum 128
by klelatti on Saturday February 03, 2024 @02:45AM
(#39238306)
> I stole copiously from The Complete Spectrum ROM Disassembly by Ian Logan and Frank O’Hara
This was an actual book, readily available at your local bookstore, that had a full, independently produced, Z80 disassembly listing (with great comments) of the original Spectrum's 16K ROM.
That the actual Sinclair team were using it as their main reference when they updated the ROM is just fantastic.
Re:The New ROM Antics – Building the ZX Spectrum 128
by peterfirefly on Saturday February 03, 2024 @09:20AM
(#39240550)
Thank you so much for this link -- I have finally satisfied my curiosity after 40 years!
I've spent some time today looking into the floating-point routines.
Some parts are quite nice, others are a bit... clumsy.
Something that surprised me was that they use 5 constants (0, 1, 0.5, pi/2, 10 -- in that order) stored in some sort of ad hoc packed format. That saves all of 7 bytes but the code to handle that takes more. Why?!
There is a routine that takes a constant index (0-4) in the A register and stores the (unpacked) floating-point constant on the calculator stack. It does that by skipping the constants before the desired one -- and that is done by performing a dummy unpack on them (by unpacking them to a ROM address).
There are low-level routines for addition, multiplication, division, abs/neg (they share most of their code), and some low-level stack manipulation.
There are special-cased comparison operations for 0 and true.
OR and AND build on those special-cased comparison operations.
Subtraction is built on neg + addition.
sin/cos/tan/arcsin/arccos/arctan/exp/ln perform argument validity checking, argument reduction, and then evaluate a Chebyshev polynomial (and clean up the result). There is a special "series evaluator" function for that + some of the routines share their argument reduction code.
The power operator is built on ln and exp.
The square root operator is built on the power operator (x^0.5).
Most of the internal floating-point code use a compact VM to represent calculations involving other, lower-level floating-point code. We enter the VM with the RST $28 instruction (a single-byte call to address 28h) and then a stream of single-byte opcodes follow, with the last one being a terminator. Each opcode encodes a call to a single floating-point operation. Some have immediate bytes: the opcode for pushing (packed) immediates to the calculator stack, the opcode for invoking the Chebyshev series evaluator (it is followed by a number of packed constants), and the opcodes for a loops and branches (followed by a single displacement byte for two of them). The loop instruction is basically a DJNZ, the unconditional branch is a "JR", and the conditional branch is a "JR true" where "true" is taken to be a non-zero value at the top of the calculator stack (it doesn't get popped).
So, it turns out that all floating-point constants in the ROM are in the packed format. That solves the mystery above.
The Spectrum uses a 5-byte floating-point format with a full byte for the exponent -- this is much easier to work with on a Z80 than the 32-bit IEEE format (because the Z80 is really not good at bit fiddling across bytes). It also encodes a 16-bit integer format into the same 5 bytes, which speeds up most parts of a typical BASIC program (+ avoids weird rounding errors). The floating-point package handles both formats -- the outermost routines "unpack" the integers to real floating-point if necessary, the innermost routines only work on real floating-point numbers.
---
There seems to be a bug in the comments for the LN function:
3717 DEFB $00 jump_true to VALID: X
3718 DEFB $04 multiply: X
It should have said:
3717 DEFB $00 jump_true to VALID: X
3718 DEFB $04
because the 4 is not a multiply opcode, it is just the displacement immediate for the jump_true opcode.
---
Most VM opcodes are treated simple enough: just use the opcode as in index into a jump table. If bit 7 is set, we have a special case: The real opcode is in bits 6-5 and there is a parameter encoded in bits 4-0. This is used for the opcodes that push a known constant (0, 1, 0.5, pi/2, 10) to the stack, it is used for the series evaluator, and for pushing and popping any of 6 internal floating-point "registers" (used for FOR-NEXT loops and various other routines). The series evaluator uses the parameter in the low nibble to indicate how many constants to use: 6, 8, or 12.
This was an actual book, readily available at your local bookstore, that had a full, independently produced, Z80 disassembly listing (with great comments) of the original Spectrum's 16K ROM.
That the actual Sinclair team were using it as their main reference when they updated the ROM is just fantastic.
Or on my bookshelf, still :)
https://archive.org/details/CompleteSpectrumROMDisassemblyTh...
Of particular note, the use of a stack based VM for floating point.
http://www.zxdesign.info/book/
http://www.ianlogan.co.uk/sinclair/books.htm
(The tool used to generate this, https://github.com/skoolkid/skoolkit, is absolutely amazing and still sees regular updates)
EDIT: it does say so in the footer.
I've spent some time today looking into the floating-point routines.
Some parts are quite nice, others are a bit... clumsy.
Something that surprised me was that they use 5 constants (0, 1, 0.5, pi/2, 10 -- in that order) stored in some sort of ad hoc packed format. That saves all of 7 bytes but the code to handle that takes more. Why?!
There is a routine that takes a constant index (0-4) in the A register and stores the (unpacked) floating-point constant on the calculator stack. It does that by skipping the constants before the desired one -- and that is done by performing a dummy unpack on them (by unpacking them to a ROM address).
See: https://skoolkit.ca/disassemblies/rom/hex/asm/341B.html
[ah, the mystery is solved later -- see below]
There are low-level routines for addition, multiplication, division, abs/neg (they share most of their code), and some low-level stack manipulation.
There are special-cased comparison operations for 0 and true.
OR and AND build on those special-cased comparison operations.
Subtraction is built on neg + addition.
sin/cos/tan/arcsin/arccos/arctan/exp/ln perform argument validity checking, argument reduction, and then evaluate a Chebyshev polynomial (and clean up the result). There is a special "series evaluator" function for that + some of the routines share their argument reduction code.
The power operator is built on ln and exp.
The square root operator is built on the power operator (x^0.5).
Most of the internal floating-point code use a compact VM to represent calculations involving other, lower-level floating-point code. We enter the VM with the RST $28 instruction (a single-byte call to address 28h) and then a stream of single-byte opcodes follow, with the last one being a terminator. Each opcode encodes a call to a single floating-point operation. Some have immediate bytes: the opcode for pushing (packed) immediates to the calculator stack, the opcode for invoking the Chebyshev series evaluator (it is followed by a number of packed constants), and the opcodes for a loops and branches (followed by a single displacement byte for two of them). The loop instruction is basically a DJNZ, the unconditional branch is a "JR", and the conditional branch is a "JR true" where "true" is taken to be a non-zero value at the top of the calculator stack (it doesn't get popped).
So, it turns out that all floating-point constants in the ROM are in the packed format. That solves the mystery above.
The Spectrum uses a 5-byte floating-point format with a full byte for the exponent -- this is much easier to work with on a Z80 than the 32-bit IEEE format (because the Z80 is really not good at bit fiddling across bytes). It also encodes a 16-bit integer format into the same 5 bytes, which speeds up most parts of a typical BASIC program (+ avoids weird rounding errors). The floating-point package handles both formats -- the outermost routines "unpack" the integers to real floating-point if necessary, the innermost routines only work on real floating-point numbers.
---
There seems to be a bug in the comments for the LN function:
https://skoolkit.ca/disassemblies/rom/hex/asm/3713.html
It says:
It should have said: because the 4 is not a multiply opcode, it is just the displacement immediate for the jump_true opcode.---
Most VM opcodes are treated simple enough: just use the opcode as in index into a jump table. If bit 7 is set, we have a special case: The real opcode is in bits 6-5 and there is a parameter encoded in bits 4-0. This is used for the opcodes that push a known constant (0, 1, 0.5, pi/2, 10) to the stack, it is used for the series evaluator, and for pushing and popping any of 6 internal floating-point "registers" (used for FOR-NEXT loops and various other routines). The series evaluator uses the parameter in the low nibble to indicate how many constants to use: 6, 8, or 12.
https://en.wikipedia.org/wiki/File:ZX_Spectrum128K.jpg
The heat sink fins really add to the aesthetic, and that looks so cool for a small home computer in 1985, who could complain.
(The black NeXT cube wasn't for another few years, and it also cost a prohibitive sum for most homes.)
I know some folks like to put small heat sinks on the ULA though.
As a child I burned myself continuously on that mofo.
I was running that Z80 hard though :)
Thanks for sharing.