I've been researching this in some depth. The results surprised me. I didn't use an original machine for this research, as I don't have one; I used a clone (one that is close enough as to allow Valkyr to work; it basically replicates the schematics but with more modern RAM chips and a few extras that don't affect this).
For this research I've used this routine that I created some years ago, that allows me to find out the bus value at the time of an interrupt:
https://codeberg.org/pgimeno/Gists/src/branch/z80-asm--im2-bus-value/bus_iorq_m1.asm
The bus value depends on the contents of memory addresses 23E0-23FF, as well as on the contents of the character set RAM addresses referenced by the characters in that range. Against all predictions, both video RAMs are active at the time of the interrupt. I don't know why; it might be due to diode D11, which is one that I've always had trouble understanding. Using a 24K pull-up resistor in one of the data lines of the CPU did not alter the outcome; that's why I presume that indeed the RAMs are active. Update: After a second look at the schematics, there's simply nothing stopping the video RAMs from being active, as they are both active all the time unless the CPU is attempting to access one of them. Video is not produced because it is blocked by the serializer that sends the pixels to the screen one by one (Z28).
Anyway, the text RAM range 23E0-23FF (hex) corresponds to video lines 248-255 (dec) of the screen, which is exactly the range in which the /INT line is active, as well as the vertical sync signal labelled FIELD on the schematics.
So, when a character in text RAM is being read, its value is placed on the CPU data bus through an array of 1K resistors (unnamed in the schematics). At the same time, that character is used as an address into the character set RAM, which also places its value on the CPU data bus through another similar array of 1K resistors.
This means that both the character code and the character set byte will be sent to the bus, and whenever a character code bit is different from the corresponding character set bit, the value in the bus will actually be halfway between a 1 and a 0. Most Z80's used in real Jupiter machines (as well as the clone I have) seem to interpret this half-way bit as a 1. Consequently, the value seen by the CPU is the logical OR of the data from the text RAM and the data from the character set RAM (if the half-way bit were seen as a 0 instead, it would be the logical AND instead of OR).
Incidentally, that's the same reason why the character set RAM is documented as write-only, except in the case of attempting to read from it, it's CPU addresses (through Z17-Z18; the text RAM is disabled at that time) rather than character codes that conflict with character set RAM data, and not all bits - just D0-D6. Bit D7 of character set RAM is always readable, but bits D0-D6 will be affected by address lines A3-A9 (normally as an OR, like in the above case). OK, enough digression.
Now, why is the value seen always 20h? It turns out that this last line is filled mostly with spaces by the ROM, as you can readily check on a machine you just booted:
: MULTIPEEK CR BEGIN DUP WHILE SWAP DUP C@ . 1+ SWAP 1- REPEAT DROP DROP CR ; 10208 32 MULTIPEEK
This prints:
32 32 32 32 32 32 32 32 32 32 32 32 32 32 32 32 32 32 32 32 32 3 2 32 32 32 32 32 32 32 51 49 255
YMMV for the last bytes.
The shape for the SPACE character has all zeros in the character set RAM, therefore when mixing these 32's with zeros via an OR operation, you get 32. You can't fully rely on this value, though - it might be different if the garbage bytes at the end are read instead of the spaces.
To always obtain a value of 0, you just have to fill that region of the text RAM (27E0-27FF) with all zeros and make sure that the character set entry for character code 0 (2800-2807) is filled with eight zeros. This will be reliable regardless of how the CPU interprets a half-way bit. Alternatively, you can obtain FF by filling 27E0-27FF and 2BF8-2BFF with FF's.
So, what should an emulator do in order to behave like the real machine? Well, that's tricky. First, remember that mask-able interrupts are triggered by level, not by edge. The Jupiter ACE interrupt line is held active for quite a long time - 1,664 CPU clock cycles, and that allows for multiple interrupts to happen while the /INT line is still low, unless the ISR takes at least that long before it re-enables interrupts. The standard ROM inserts a delay on purpose to deal with this.
The emulator should track how many CPU cycles have passed since the start of the vertical sync signal, and find out which character code and shape bytes are active in VRAM when the interrupt is actually triggered. To behave like all currently observed machines, the character code and the character shape bytes should be OR'ed, and that should be taken as the port value. The datasheet specifies that the value is read at the raising edge of the 5th cycle after the interrupt line is detected and an interrupt is possible. 5 cycles = 10 pixels, therefore the first row of the first character will never be read. This cycle count should be used to determine the character code and shape. Assuming that the variable cyc contains the CPU cycle at which the bus is read, where 0 is the start of the vertical sync signal:
bus = mem[0x23E0 + ((cyc >> 2) & 0x1F)]; bus |= mem[0x2800 + (char << 3) + (cyc / 208)]; intvec = mem[(i_reg << 8) + bus]; intvec |= mem[(i_reg << 8) + bus + 1] << 8;
I'll update this if I find more details.
UPDATE: Actually, the memory region between 9985 and 10238 inclusive is called the "PAD", mentioned in the manual, and is used by the ROM. So, depending on the contents of the PAD, the 20h might not be something you could rely on.
UPDATE 2: Some peripherals may interfere with that. For example, Lex van Sonderen's AY interface does an incomplete I/O port decoding (does not take into account the /RD signal) and thus will place values in the bus, overriding the 1K resistors. The additional condition that A1=A5=0 is easy to meet depending on the address of the code being executed when the interrupt triggers.