← back to zen80

Z80N: documentation vs. ZEsarUX cross-check

Findings from cross-checking this project's Z80N implementation (see z80n.go) against a second, independent, real-world source once actual uncertainty was flagged in the code -- not a routine audit of every opcode, only the ones where the SpecNext wiki's own documentation left genuine doubt.

Source checked: github.com/chernandezba/zesarux, the ED-prefix opcode table in src/cpus/z80_codpred.c. GPLv3, mature (initial commit 2022-03-04), TBBlue/Next-aware, actively maintained. Function names are decimal-indexed by opcode second byte (instruccion_ed_39 = ED $27).

Primary documentation source: the SpecNext wiki's Extended Z80 instruction set page, including its dated "A note on some Z80-N specific observations" section, which corrects its own main instruction table on two points (2021-09-16 and 2025-01-25 entries).

Resolution policy (as directed): the newest documented behaviour takes priority; older claims are considered superseded unless independently corroborated by a source at least as current. Where two current, independently-arrived-at sources disagree, both are recorded here rather than one being silently discarded.

Confirmed: implementation already matched ZEsarUX

TEST $im8 (ED 27)

Implemented as: C:=0, N:=0, H:=1, PV:=parity(A&n), Z/S standard (equivalent to a real AND A,n, without writing the result to A).

ZEsarUX's own comment: "same as AND N but without affecting A" (mismo que AND N pero sin afectar A). Its code does a single overwriting assignment, Z80_FLAGS = FLAG_H | sz53p_table[temp_a], which -- because it replaces the whole flags byte rather than OR-ing into it -- implicitly zeroes C and N. Exact match.

This also resolves an internal inconsistency in the wiki's own page: its detailed instruction table lists TEST's C and H columns as S ("standard"), which doesn't match a real AND's well-established C=0,H=1 pattern and contradicts the page's own summary line ("Change flags as AND A"). ZEsarUX confirms the summary line was right and the detailed table entry was the error.

OUTINB (ED 90)

Implemented by reusing this codebase's own already-verified outi() flag computation, with the B-- step removed (per "OUTINB = OUTI but B is not decremented"). The wiki marks every flag ? (unconfirmed) for this specific opcode.

ZEsarUX implements real, specific flags for OUTINB -- and they match, flag for flag: H/C via an 8-bit-truncation overflow check (equivalent to this codebase's full-width threshold check, just a different idiom), Z/S/X/Y from B's current (never-decremented) value via the same sz53 lookup pattern outi() already uses, PV from (sum&7)^B parity, N from the copied byte's bit 7. Both implementations also read the low byte (L) after HL's own increment, not before -- an easy detail to get backwards, and both agree on it.

LDPIRX (ED B7)

Implemented as: flags unaffected. The wiki's 2025-01-25 correction note names LDIX/LDDX/LDIRX/LDDRX explicitly as now affecting flags, and conspicuously omits LDPIRX -- read at the time as meaning the correction doesn't extend to it, not as an oversight.

ZEsarUX's own comment states outright: "LDPIRX does not affect flags." Direct confirmation, not just an absence of contrary evidence.

BSLA DE,B (ED 28) -- boundary behaviour, not a flag question

Not a prior doubt, but worth recording: this codebase's implementation (z.SetDE(z.DE() << amt), plain Go) and ZEsarUX's C implementation handle amounts >= 16 differently in source form while agreeing in result. ZEsarUX needs an explicit guard -- if (shift_amount >= 16) DE = 0; -- because C's behaviour for a shift count at or beyond the operand's own width is undefined by the C standard; different compilers could legally do different things. Go's language spec defines this case exactly (result is 0), so the single-line Go form gets the same answer for free, with no special case needed. Confirmed empirically for the boundary case B=31 in z80n_adversarial_test.go.

Genuine, recorded disagreement -- implementation unchanged

ADD HL,A / ADD DE,A / ADD BC,A (ED 31/32/33) -- carry flag

Wiki: two dated findings. 2021-09-16 established these do NOT preserve carry the way classic ADD HL,rr does (main table marks it ?). 2025-01-25, "Testing 3.02.x" (an explicit, versioned, real- hardware-tested claim): refined to "most probably always reset." Implemented here as C:=0 on that later finding.

ZEsarUX: HL += reg_a; -- no flags touched at all, comment reads "(no flags set)". This is a genuine, direct contradiction of the wiki's 2025 finding, not silence on the question.

Resolution: kept the wiki's 2025-01-25 behaviour (C:=0). Git-blamed the exact ZEsarUX line: unchanged since the file's very first commit, 2022-03-04 -- close to three years before the wiki's dated hardware test. Read as ZEsarUX simply predating the correction rather than a competing current claim. Recorded here because it is a real disagreement between two real implementations, not because the resolution was in doubt.

Genuine correction made as a result of this cross-check

LDIX / LDDX -- undocumented X (bit 3) / Y (bit 5) flags

Original implementation: reused this codebase's own ldi() formula wholesale -- n := val + z.A, then X := n&8, Y := (n&2)<<4 -- on the assumption that the wiki's "affects flags similarly to LDI" note meant identically to LDI, formula included.

The gap: the wiki's 2025-01-25 note only asserts LDIX/LDDX/LDIRX/ LDDRX now affect flags "similarly to LDI, LDD, LDIR and LDDR" -- it does not commit to the exact X/Y derivation. The val+A formula was this project's own extrapolation into that gap, not something either documentation source actually claimed.

ZEsarUX's actual implementation (instruccion_ed_164 for LDIX, instruccion_ed_172 for LDDX, both unchanged since the same first commit): derives X/Y from the raw byte read, with no +A at all -- if (byte_leido & 8) FLAG_3; / if (byte_leido & 2) FLAG_5;. Same pattern, consistently, in both instructions.

Resolution: corrected both z80nLdix() and z80nLddx() to match ZEsarUX exactly (val&0x08 -> X, val&0x02 -> Y, no +A). This wasn't a source-preference call the way the ADD-carry case was -- there was no competing dated claim to weigh against ZEsarUX, just this project's own unfounded assumption, which ZEsarUX's real, independently-arrived-at implementation corrected. LDIRX/LDDRX inherit the fix automatically, since both call z80nLdix()/z80nLddx() directly rather than duplicating their logic.

New test coverage added in z80n_semantics_test.go (TestZ80N_Ldix/flags_from_ZEsarUX_cross_check) isolates X and Y independently against two byte values chosen so neither flag's correctness could hide behind the other (0x0B: both source bits set; 0x04: neither), plus a PV check on both sides of the BC==0 boundary.

Not yet cross-checked

Everything else in z80n.go (SWAPNIB, MIRROR A, the remaining barrel-shift/rotate opcodes, MUL D,E, the ADD rr,$im16 forms, PIXELDN, PIXELAD, SETAE) was implemented directly from the wiki with no documented internal contradiction or unresolved gap at the time, so no ZEsarUX cross-check was done for these specifically. Worth revisiting the same way if a similar doubt surfaces for any of them.

Step 3's remaining opcodes (PUSH $im16, both NEXTREG forms, JP (C)) are now implemented, also directly from the wiki with no documented internal contradiction: PUSH $im16's formula (SP-=2; SP*:=nn, with the operand itself uniquely big-endian in the instruction stream) and NEXTREG's two-port-write formula are both stated plainly with no dated hardware-testing note to reconcile against anything else. JP (C)'s formula (PC:=PC&$C000+IN(C)<<6) is likewise stated plainly, though its flag effects are marked "?" across the board with no alternative source offering anything more specific -- left unchanged rather than guessed, the same conservative choice made elsewhere in this file for genuinely undocumented cases. No ZEsarUX cross-check was done for any of the four, since none had the kind of two-source disagreement that prompted one for ADD HL/DE/BC,A.

A further, independent check available later

A real FPGA core's Verilog/VHDL source (if obtained) would be a stronger source than either of the above for genuinely disputed points like the ADD HL/DE/BC,A carry question -- both the wiki and ZEsarUX are someone's account of hardware behaviour, however careful; the core's own source is the specification made real. Flagged as a reasonable next step if the ADD-carry disagreement, or anything else found later, needs settling beyond "which account is newer."