Friday, May 8, 2026

Reverse engineering Halcyon's laserdisc player command sending routines inside the Z80 ROM

When I reverse engineer laserdisc arcade game ROMs, one of my favorite things to do is find/disassemble the routines that send commands to the laserdisc players. Every laserdisc game will have these somewhere in the ROM so it's a "comfortable" place to start reverse engineering because the commands for the player are already known.
 
Due to the way Dragon's Lair was written (and even Thayer's Quest!), I assumed Halcyon would have a similar style. But after a first pass at the Z80 disassembly, I could not find these routines anywhere. To make matters worse, Halcyon uses a different laserdisc player than the rest of the laserdisc games, the Pioneer LD-700. So while I had written an interpreter for this command set (in order to get Dexter to work with Halcyon), I still had no idea how the "RDI engineering team" would approach implementing it.
 
My guess was that they would write a function to take in a command byte (such as 0x4A) and then have a common set of routines that sent this function serially across the single wire. What I actually found astonished me.
 
Within the ROM, I found tables like this:

ROM:9165 unk_9165:       db    4                 ; DATA XREF: ROM:8A31↑o
ROM:9165                                         ; ROM:8F07↑o
ROM:9165                                         ; see 8A31
ROM:9166                 db    2
ROM:9167                 db    2
ROM:9168                 db    1
ROM:9169                 db    1
ROM:916A                 db    1
ROM:916B                 db    2
ROM:916C                 db    2
ROM:916D                 db    3
ROM:916E                 db    1
ROM:916F                 db    2
ROM:9170                 db    4
ROM:9171                 db    3
ROM:9172                 db    2
ROM:9173                 db    1
ROM:9174                 db    1
ROM:9175                 db    1
ROM:9176                 db    0
I was pretty sure that this represented laserdisc commands, but just by eyeballing it, I couldn't see how. I eventually had to emulate the Halcyon in a WIP verion of Daphne v2.0 just enough to step through the ROM and observe the program flow. This required me to basically emulate the entire COP421L microcontroller too, as well as wire up most of the Z80 ports, so it was exceedingly NON-TRIVIAL to get this far.
 
I found routines that I became increasingly confident were related to laserdisc player I/O. I found arrays full of pointers to more arrays like the above example. But what confused me was I found two distinct tables of arrays, which lead me to suspect that the ROM was designed to support two different kinds of laserdisc players.
 
Using Daphne's debugger, I stepped into the routine that sent a command to the LD-700. I've included a partially annotated version below:

ROM:8DF3 SendCmdToLD700:                         ; CODE XREF: ROM:8A34↑p
ROM:8DF3                                         ; ROM:8A37↑j ...
ROM:8DF3                 di                      ; HL points to a null-terminated array containing small integers like 4, 3, 2, etc.
ROM:8DF3                                         ;
ROM:8DF3                                         ; This may send a command to the LDP because interrupts get disabled (and the LDP commands are time sensitive).
ROM:8DF4                 ld      a, (Port20Cache) ; Caches value stored at port 20.  see 441
ROM:8DF7                 and     0FEh            ; clear LDP_EXT_CTRL' bit
ROM:8DF9                 ld      d, a            ; D contains port20cache with bit0 clear
ROM:8DFA                 push    hl              ; store HL (which is pointing to the array of small integers)
ROM:8DFB                 res     0, d            ; may be redundant since bit 0 seemed to be already clear, but whatever
ROM:8DFD                 ld      bc, 332         ; ~8ms delay for "leader down" ?
ROM:8E00                 call    SendDToPort20hLoopOnBC
ROM:8E03                 set     0, d            ; set/raise LDP_EXT_CTRL'
ROM:8E05                 ld      bc, 158         ; ~4ms delay for "leader up"
ROM:8E08                 bit     7, (hl)         ; kill some time
ROM:8E0A                 bit     7, (hl)
ROM:8E0C                 bit     7, (hl)
ROM:8E0E                 bit     7, (hl)
ROM:8E10                 bit     7, (hl)
ROM:8E12                 call    SendDToPort20hLoopOnBC
ROM:8E15                 ld      a, r
ROM:8E17
ROM:8E17 LdpArrayLoop:                           ; CODE XREF: ROM:8E35↓j
ROM:8E17                 ld      b, (hl)         ; grab the next item in the null-terminated array
ROM:8E18                 xor     a               ; A = 0
ROM:8E19                 or      b               ; A = B, test to see if we've hit the end of the array (NULL)
ROM:8E1A                 inc     hl              ; arrayPtr++
ROM:8E1B                 jr      z, OnLdpArrayExhausted ; come here when we've hit the end of the array
ROM:8E1D                 call    ProcessSmallIntForLDP
ROM:8E20                 set     0, d
ROM:8E22                 ld      bc, 38          ; convert the last sent bit from a '0' to a '1' by holding the line an extra 1ms longer
ROM:8E25                 call    SendDToPort20hLoopOnBC
ROM:8E28                 ld      a, r
ROM:8E2A                 ld      a, r
ROM:8E2C                 ld      a, r
ROM:8E2E                 ld      a, r
ROM:8E30                 ld      a, r
ROM:8E32                 ld      a, r
ROM:8E34                 or      a
ROM:8E35                 jr      LdpArrayLoop    ; grab the next item in the null-terminated array
ROM:8E37 ; ---------------------------------------------------------------------------
ROM:8E37
ROM:8E37 OnLdpArrayExhausted:                    ; CODE XREF: ROM:8E1B↑j
ROM:8E37                 set     0, d            ; come here when we've hit the end of the array
ROM:8E39                 ld      bc, 759         ; 20ms delay after a command is finished, holding line high?
ROM:8E3C                 call    SendDToPort20hLoopOnBC
ROM:8E3F                 bit     7, (hl)
ROM:8E41                 ld      a, r
ROM:8E43                 ld      a, r
ROM:8E45                 ld      a, r
ROM:8E47                 ld      a, r
ROM:8E49                 ld      a, r
ROM:8E4B                 ld      a, r
ROM:8E4D                 or      a
ROM:8E4E                 pop     hl
ROM:8E4F                 ei                      ; re-enable interrupts, the timing sensitive stuff is finished
ROM:8E50                 ld      a, (Port20Cache) ; Caches value stored at port 20.  see 441
ROM:8E53                 set     0, a            ; raise LD_EXT_CTRL'
ROM:8E53                                         ; (may be redundant, as this may have already been raised)
ROM:8E55                 ld      (Port20Cache), a ; Caches value stored at port 20.  see 441
ROM:8E58                 out     (20h), a
ROM:8E5A                 jp      ConditionalLdpReturn
ROM:8E5D
ROM:8E5D ProcessSmallIntForLDP:                  ; CODE XREF: ROM:8E1D↑p
ROM:8E5D                                         ; ROM:8E7F↓j
ROM:8E5D                 push    bc              ; when this function is called, A and B may both contain the mystery small integers (4, 3, 2, etc)
ROM:8E5E                 ld      bc, 20          ; ~0.5ms pulse width.
ROM:8E61                 res     0, d
ROM:8E63                 call    SendDToPort20hLoopOnBC
ROM:8E66                 set     0, d
ROM:8E68                 ld      bc, 18
ROM:8E6B                 bit     7, (hl)         ; kill some cycles
ROM:8E6D                 bit     7, (hl)
ROM:8E6F                 bit     7, (hl)
ROM:8E71                 bit     7, (hl)
ROM:8E73                 bit     7, (hl)
ROM:8E75                 call    SendDToPort20hLoopOnBC
ROM:8E78                 pop     bc
ROM:8E79                 bit     7, (hl)         ; kill cycles
ROM:8E7B                 ld      a, 0
ROM:8E7D                 ld      a, 0
ROM:8E7F                 djnz    ProcessSmallIntForLDP
ROM:8E81                 bit     7, (hl)
ROM:8E83                 bit     7, (hl)
ROM:8E85                 bit     7, (hl)
ROM:8E87                 ld      a, r
ROM:8E89                 ret
ROM:8E8A
ROM:8E8A SendDToPort20hLoopOnBC:                 ; CODE XREF: ROM:8E00↑p
ROM:8E8A                                         ; ROM:8E12↑p ...
ROM:8E8A                 ld      a, d            ; this is almost assuredly used as part of the LDP communication
ROM:8E8B                 out     (20h), a
ROM:8E8D                 rl      a               ; these opcodes may just be designed to cause delay
ROM:8E8F                 rl      a
ROM:8E91                 rl      a
ROM:8E93                 ld      a, d
ROM:8E94                 ld      a, d
ROM:8E95                 and     0FEh            ; isolate LD_EXT_ACK
ROM:8E97                 in      a, (40h)        ; read LD_EXT_ACK (but apparentely ignore the result?)
ROM:8E99                 ld      a, r
ROM:8E9B                 dec     bc
ROM:8E9C                 ld      a, c
ROM:8E9D                 or      b
ROM:8E9E                 jr      nz, SendDToPort20hLoopOnBC ; loop until BC is 0
ROM:8EA0                 ret
After stepping through the loop, it became clear to me what those small integers (4,3,2,1) referred to and I finally cracked the code.
 
Here is my comment for the 4Ah instruction for the LD-700.

ROM:9177 LD700Cmd4AEnableAudio_Idx1A:db    4     ; DATA XREF: ROM:8F09↑o
ROM:9177                                         ; Each of these integers represents a group of bits, where all of the bits are 0 except the last bit.
ROM:9177                                         ;
ROM:9177                                         ; So the 0xA8 command that the LD-700 expects to receive first will be encoded here as:
ROM:9177                                         ; 4
ROM:9177                                         ; 2
ROM:9177                                         ; 2
ROM:9177                                         ;
ROM:9177                                         ; The 4 means three 0-bits followed by one 1-bit (8 if bits are reversed)
ROM:9177                                         ; The 2 means one 0-bit followed by one 1-bit (so a pair of 2's is 0x0A if bits are reversed)
ROM:9178                 db    2                 ; 0x0A (01 01 LSB first)
ROM:9179                 db    2
ROM:917A                 db    1                 ; The required 0x57 the LD-700 expects is encoded as
ROM:917A                                         ; 1
ROM:917A                                         ; 1
ROM:917A                                         ; 1
ROM:917A                                         ; 2
ROM:917A                                         ; 2
ROM:917A                                         ; N
ROM:917A                                         ;
ROM:917A                                         ; which in binary ends up being 11101010 (0x57 with bits reversed)
ROM:917A                                         ;
ROM:917A                                         ; The final value is a 0-bit so it has to be merged with the upcoming byte (3).
ROM:917B                 db    1
ROM:917C                 db    1
ROM:917D                 db    2
ROM:917E                 db    2
ROM:917F                 db    3                 ; final 0-bit of the 0x57, followed by 01.
ROM:917F                                         ;
ROM:917F                                         ; The final bytes end up being 0x4A 0xB5 (0x4A ^ 0xFF)
ROM:9180                 db    2                 ; 01
ROM:9181                 db    3                 ; 001
ROM:9182                 db    2                 ; 01
ROM:9183                 db    2                 ; 01
ROM:9184                 db    2                 ; 01
ROM:9185                 db    1                 ; 1
ROM:9186                 db    2                 ; 01
ROM:9187                 db    1                 ; 1 (trailing)
ROM:9188                 db    0
I applied this same logic to the unknown alternate laserdisc player set of commands and ended up with a full **PR-8210** command set!

So apparently the Halcyon was designed (at least at first) to work with the PR-8210 or some IR compatible player. Since the Halcyon detects when the tray is ejected or not, I doubt they actually used the PR-8210 in production. But the code is left in the ROM for posterity. Really cool!
 
I was really shocked that they encoded the commands in the ROM this way instead of having a nice function encode dynamically. But in retrospect, it kind of makes sense. Since some of the bits of one byte need to combined with bits from the next byte, whipping up this algorithm in Z80 assembly language may have been pretty tricky. So the developer(s), likely being under a time crunch and realizing they had some extra space in the ROM, probably just said "Let's just store the commands already encoded in the ROM to simplify our task!"
 
The actual story will likely remain a mystery forever... *ominous music*