Analysis model: gpt-5.5 xhigh

The Party 91 Intro by TRSI and Surprise! Productions - Technical Dissection

Release year: 1991

Party 91 is a small MS-DOS intro released at The Party 1991 by TRSI and Surprise! Productions. The DIZ calls it an intro for TP91 and credits TRSI and Suprise! prod.; the runtime text credits Rick Dangerous and Burning Chrome as the on-site makers, with a logo by J.O.E.

This is not a later polished multi-part DOS demo. It is more interesting as a late-1991 PC scene artifact: an MZ executable with a custom tail depacker, three external data files, direct VGA register work, a compact RLE bitplane decoder, palette stepping, CRTC scanline timing, and a simple text-page display engine.

The Party 1991 result file does not list a ranked PC intro compo. It lists the party as held in Aars, Denmark in late December 1991 and gives the main demo results for the larger party context. So I treat this as a The Party 1991 PC party intro, not as a ranked PC-compo placement.

Sources:

Archive Contents

Unpacked archive:

FONT.P91        3,684 bytes
LOGO.P91        7,396 bytes
PARTY91.EXE     4,203 bytes
THANX.P91      17,689 bytes
FILE_ID.DIZ        63 bytes

Hashes used in this pass:

46673a5d84a20deb7bce6f9c2be15e53568d804ec12045863a843b9115bba293  party91.zip
2f32898e6a2516c2ee7800b1d9063569de0ea84beb0095530ca3d1bf7d6cd432  results.txt
274ff0b481ac0d67f1cd8f70f3930e32c1fe0385aa9262372ead030ec6c8197f  FILE_ID.DIZ
0a6bdfc2f1b78a59ade2ee2a0fe58c0741184a6d49286193f48b84b74120ea1b  FONT.P91
1adfe30a923b06449e923ff2838681f18b93d5f1e2d31db5bba93fb645505906  LOGO.P91
8a856c48c10bfde682f2ef080acda248256f70035eb432553f1fef270fafa280  PARTY91.EXE
87ed38d330848bc3e37ea86b682d37fa3861af48a86e4e836f3769aab90f580b  THANX.P91

PARTY91.EXE is a normal MZ executable according to file type inspection, but the load image is only the compressed wrapper plus a custom decompressor. The visible data files are:

The runtime scroller begins with a welcome line for Surprise!/TRSI and says the party release was made by Rick Dangerous and Burning Chrome in the night between December 27 and December 28, 1991. I am only quoting short identifying text here; the rest is just party context and greetings.

MZ Wrapper And Packed Entry

The executable has a 32-byte MZ header:

header paragraphs: 0x0002
relocations:       0
file size:         4,203 bytes
load image size:   4,171 bytes
initial CS:IP:     00ef:000e
initial SS:SP:     053f:0080
min alloc:         0x046a paragraphs
max alloc:         0xffff paragraphs

The initial CS:IP maps to load-image offset 0x0efe, file offset 0x0f1e. The entry is not the intro proper. It is a short stub that copies the packed stream upward, then far-returns into relocated decompressor code:

0efe  push es
0eff  push cs
0f00  pop ds
0f01  mov cx,[000c]
0f05  mov si,cx
0f07  dec si
0f08  mov di,si
0f0a  mov bx,ds
0f0c  add bx,[000a]
0f10  mov es,bx
0f12  std
0f13  rep movsb
0f15  push bx
0f16  mov ax,002b
0f19  push ax
0f1a  lret

The useful detail is the direction flag. The stub copies backward from the end of the packed stream so the decompressor can move itself and its input into a higher paragraph area without trampling bytes it still needs to read. The far return then changes both segment and offset in one cheap real-mode operation.

The following decompressor is a bit-coded LZ variant:

The packed entry is small, but it explains why the file is not directly disassemblable as the final intro. The final runtime is reconstructed from a DOSBox-X save after the unpacked code has reached its own setup path.

Runtime Capture Map

I used a DOSBox-X save state after PARTY91.EXE had unpacked and started. In that save, the program name is PARTY91, the emulated machine is VGA, and the PSP begins at physical 0x81c8. The carved runtime image used below starts at physical 0x82c8; all offsets in this section are relative to that carved image.

High-level runtime map:

0000  small palette/helper area
0020  attribute-register / tiny palette helper
0900  decoded font/glyph target area
0914  CRTC wave phase and text counters
1e60  character-to-glyph offset table
1f64  palette table A
2024  palette table B
20e4  palette table C
21a4  transition palette buffer
24a4  transition palette buffer
27a4  transition palette buffer
2864  transition palette buffer
2b64  text page count and page index
2b6a  40x6 text page stream
47c6  DOS memory-block shrink helper
47da  DOS paragraph allocation helper
4860  DOS free-segment helper
486b  ASCIIZ file loader
4933  RLE bitplane-to-chunky image decoder
49d3  retrace-synced DAC upload helper
49f0  palette interpolation helper
4a0b  CRTC scanline split/waver
4a9b  palette pulse/fade helper
4d74  8x8 glyph writer
4e10  file names and segment slots
4e34  main runtime entry after unpack
4e80  mode 13h and CRTC setup
4ee6  logo decode
4f0c  font decode
4f34  palette fade toward table B
4f59  palette fade toward table C
4f93  main text/display loop
5094  keypress exit and closing transitions

The code/data layout is compact and old-school: there is no separate engine layer. Segment slots, file names, palettes, text pages, state words, and code all sit in one real-mode code segment, with external data loaded into allocated paragraph blocks.

DOS Memory And File Loading

The memory shrink routine at 47c6 resizes the original DOS block so the program can allocate clean work areas after itself:

47c6  mov bx,ss
47c8  mov ax,es
47ca  sub bx,ax
47cc  mov ax,sp
47ce  mov cl,4
47d0  shr ax,cl
47d2  add bx,ax
47d4  inc bx
47d5  mov ah,4ah
47d7  int 21h
47d9  ret

The calculation is the usual real-mode executable shrink:

paragraphs from PSP/owner block to SS
+ SP rounded down to paragraphs
+ one extra paragraph
= new DOS block size

The allocator at 47da wraps DOS int 21h / AH=48h. On success it stores the returned segment through CS:[DI]. On failure it prints a short "not enough memory" style message and exits.

47da  mov ah,48h
47dc  int 21h
47de  jae 47ef
      ; print failure text and terminate
47ef  mov cs:[di],ax
47f2  ret

The file loader at 486b is similarly direct. Its inputs are:

DX = pointer to ASCIIZ filename in CS
DI = pointer to the segment slot where the allocation result is stored

It performs:

open file with int 21h / AH=3Dh, AL=92h
seek to end with int 21h / AH=42h, AL=2
use AX as byte length
seek back to start
allocate (length >> 4) + 1 paragraphs
read length bytes with int 21h / AH=3Fh into the allocated segment
close file with int 21h / AH=3Eh
store allocated segment in CS:[DI]

The open failure path mutates the terminating zero byte of the filename into a DOS $ terminator, prints a small complaint naming the missing file, and exits. That is a very 1991 way to avoid keeping duplicate filename strings around.

External Asset Decode Calls

The main entry at 4e34 loads the three asset files and allocates one extra screen-sized block:

4e5c  mov di,4e19h
4e5f  mov dx,4e10h
4e62  call 486b        ; logo.p91

4e65  mov di,4e24h
4e68  mov dx,4e1bh
4e6b  call 486b        ; font.p91

4e6e  mov di,4e30h
4e71  mov dx,4e26h
4e74  call 486b        ; thanx.p91

4e77  mov di,4e32h
4e7a  mov bx,0fa5h
4e7d  call 47da        ; offscreen full page block

The sizes match the later decoder calls:

The single image decoder at 4933 is parameterized enough to handle all three. That reuse is the tightest part of the intro.

RLE Bitplane-To-Chunky Decoder

The decoder at 4933 converts a compressed bitplane stream into 8-bit chunky pixels. It is not just an RLE expander; it expands bitplanes by setting or clearing one bit in each destination byte.

Inputs:

DS:SI = compressed source stream
ES:DI = destination
AL    = starting destination bit index
CX    = row count
BP    = destination row width in pixels
BX    = extra row advance in pixels
DX    = number of bitplanes to apply

The setup converts pixel widths into byte counts and builds the first bit mask:

4933  push ax
4934  mov ax,bp
4936  and ax,0007h
4939  shr bp,3
493c  cmp ax,0
493f  je 4944
4941  mov ax,1
4944  add bp,ax        ; ceil(width / 8)
4946  shr bx,3         ; extra x advance, pixels to bytes
4949  pop ax
494a  push cx
494b  mov cl,al
494d  mov al,1
494f  shl al,cl        ; AL = bit to set for this plane
4951  mov ah,al
4953  not ah           ; AH = mask to clear that bit

Then it enters a three-level loop:

for every output row:
  save row counters
  for every bitplane requested by DX:
    decode enough RLE packets to fill the row byte count
    advance the destination bit mask to the next plane
  move DI to the next destination row

The RLE packet byte at DS:SI decides between a literal run and a repeat run:

495d  xor cx,cx
495f  mov cl,[si]
4961  inc si
4962  cmp cl,7fh
4965  ja 4989

Low packet bytes are literals. The count is packet + 1; each following source byte supplies eight pixels for the current destination bitplane:

4967  inc cx           ; literal byte count = packet + 1
4968  sub bp,cx
496a  push bx
496b  mov bl,[si]
496d  inc si
496e  push cx
496f  mov cx,8
4972  rcl bl,1
4974  jb 497c
4976  and es:[di],ah   ; source bit 0: clear this plane bit
4979  jmp 497f
497c  or es:[di],al    ; source bit 1: set this plane bit
497f  inc di
4980  loop 4972
4982  pop cx
4983  loop 496b
4985  pop bx
4986  jmp 49b1

The core is the rcl bl,1 plus and/or es:[di]. One compressed byte turns into eight chunky pixels, but only touches a single bitplane in each pixel byte. After all planes have run, those bits combine into normal 8-bit VGA color indices.

High packet bytes are repeats. The count is 0x101 - packet; one source byte is replayed that many times:

4989  sub cx,0101h
498d  neg cx           ; repeat byte count = 0x101 - packet
498f  sub bp,cx
4991  push bx
4992  mov bl,[si]
4994  mov bh,bl
4996  inc si
4997  push cx
4998  mov cx,8
499b  rcl bl,1
499d  jb 49a5
499f  and es:[di],ah
49a2  jmp 49a8
49a5  or es:[di],al
49a8  inc di
49a9  loop 499b
49ab  mov bl,bh
49ad  pop cx
49ae  loop 4997
49b0  pop bx

The repeat path keeps a copy of the repeated source byte in BH, restores it into BL for each repeated output byte, and runs the same eight-pixel bit test.

The row/plane tail restores the destination pointer and advances the bit masks:

49b1  cmp bp,0
49b4  jne 495d         ; more packets needed for this row/plane
49b6  pop bp
49b7  pop di
49b8  pop cx
49b9  shl al,1
49bb  stc
49bc  rcl ah,1
49be  loop 495a        ; next bitplane

AL moves from bit 0 upward. AH is kept as the inverse clear mask, so it has to rotate in a set bit as the clear position shifts. That is why the code uses stc before rcl ah,1.

After all planes for the row are decoded, the destination advances by the visible row stride and the extra byte advance:

49c0  push bp
49c1  push bx
49c2  shl bp,3
49c5  shl bx,3
49c8  add di,bp
49ca  add di,bx
49cc  pop bx
49cd  pop bp
49ce  pop ax
49cf  pop cx
49d0  loop 4956        ; next row
49d2  ret

Because BP and BX were byte counts after setup, the shifts rebuild pixel units before adding to DI. For a 320-pixel row, this lands at the next VGA scanline as expected.

Mode 13h And CRTC Setup

The display setup starts in BIOS mode 13h, then edits CRTC registers directly:

4e80  mov ax,0013h
4e83  int 10h

4e85  mov dx,03d4h
4e88  mov ah,0c8h
4e8a  mov al,18h
4e8c  out dx,ax

4e8d  mov al,07h
4e8f  out dx,al
4e90  inc dx
4e91  in al,dx
4e92  and al,0efh
4e94  out dx,al

4e96  mov al,09h
4e98  out dx,al
4e99  inc dx
4e9a  in al,dx
4e9b  and al,0bfh
4e9d  out dx,al

The register writes are not a full custom mode set; they are tweaks on top of mode 13h. Register 18h is the line compare register. Registers 07h and 09h contain overflow/double-scan-related bits, so clearing those bits helps prepare the later split/wave behavior.

The code also sets the CRTC display start offset to 0x1f40:

4e9e  mov bx,1f40h
4ea1  mov dx,03d4h
4ea4  mov ah,bh
4ea6  mov al,0ch
4ea8  out dx,ax
4ea9  inc al
4eab  mov ah,bl
4ead  out dx,ax

Then it unlocks write protection by clearing bit 7 of CRTC register 11h:

4eae  mov dx,03d4h
4eb1  mov al,11h
4eb3  out dx,al
4eb4  inc dx
4eb5  in al,dx
4eb6  and al,7fh
4eb8  out dx,al

The intro is therefore still a 320x200 256-color program in spirit, but it uses CRTC start, line compare, and per-scanline register changes to create motion that is cheaper than redrawing the whole frame.

Initial Draw Sequence

After the VGA setup, the code uploads an initial 256-entry palette from 1f64:

4eb9  push cs
4eba  pop ds
4ebb  mov si,1f64h
4ebe  mov di,0
4ec1  mov cx,0100h
4ec4  call 49d3

The logo is decoded straight into VGA memory. The destination DI=7bc0h is an offset inside segment A000h; the call requests 56 rows, 320 pixels per row, and six bitplanes:

4ecc  push cs:[4e19h]  ; LOGO.P91 segment
4ed1  pop ds
4ed2  mov si,0
4ed5  mov di,7bc0h
4ed8  mov cx,38h
4edb  mov al,0
4edd  mov bp,0140h
4ee0  mov bx,0
4ee3  mov dx,6
4ee6  call 4933

THANX.P91 is decoded into the separately allocated screen-sized page:

4ee9  push cs:[4e30h]  ; THANX.P91 segment
4eee  pop ds
4eef  mov si,0
4ef2  mov es,cs:[4e32h]
4ef7  mov di,0
4efa  mov cx,00c8h
4efd  mov al,0
4eff  mov bp,0140h
4f02  mov bx,0
4f05  mov dx,8
4f08  call 4933

FONT.P91 is decoded into the code segment at 0920:

4f0c  push cs:[4e24h]  ; FONT.P91 segment
4f11  pop ds
4f12  mov si,0
4f15  push cs
4f16  pop es
4f17  mov di,0920h
4f1a  mov cx,11h
4f1d  mov al,0
4f1f  mov bp,0140h
4f22  mov bx,0
4f25  mov dx,8
4f28  call 4933

The FONT.P91 parameters look odd at first because the font only needs 8x8 glyphs, but the destination stride is still 320 bytes. The glyph writer later uses the atlas as if each glyph row is laid out in a 320-byte-wide sheet.

DAC Upload And Palette Interpolation

Palette upload at 49d3 waits for a full retrace edge, sets the starting DAC index, and streams CX * 3 bytes:

49d3  mov dx,03dah
49d6  in al,dx
49d7  test al,08h
49d9  jne 49d6
49db  in al,dx
49dc  test al,08h
49de  je 49db

49e0  mov dx,03c8h
49e3  mov ax,di
49e5  out dx,al
49e6  inc dx
49e7  mov ax,cx
49e9  shl cx,1
49eb  add cx,ax
49ed  rep outsb
49ef  ret

This is a classic clean-DAC write: wait until outside retrace, then wait until inside retrace, then stream RGB bytes while the beam is in the safer interval.

The interpolation helper at 49f0 nudges one palette toward another by a fraction:

49f0  mov ax,cx
49f2  shl cx,1
49f4  add cx,ax        ; CX = entries * 3
49f6  mov al,[di]      ; target component
49f8  cbw
49f9  mov dl,[si]      ; current component
49fb  mov dh,0
49fd  sub ax,dx
49ff  idiv bl
4a01  add dx,ax
4a03  mov [si],dl
4a05  inc si
4a06  inc di
4a07  loop 49f6
4a09  ret

In plain terms:

delta = target - current
current += delta / divisor

The main code uses it in two nested fade stages:

4f34  mov cx,0010h
4f37  push cx
4f38  mov cx,0040h
4f3b  mov si,1f64h
4f3e  mov di,2024h
4f41  mov bl,10h
4f43  call 49f0
4f46  mov si,1f64h
4f49  mov di,0
4f4c  mov cx,0040h
4f4f  call 49d3
4f52  pop cx
4f53  loop 4f37

4f59  mov cx,0040h
4f5c  push cx
4f5d  mov cx,0040h
4f60  mov si,2024h
4f63  mov di,20e4h
4f66  mov bl,40h
4f68  call 49f0
4f6b  mov si,2024h
4f6e  mov di,0
4f71  mov cx,0040h
4f74  call 49d3
4f77  pop cx
4f78  loop 4f5c

Only 64 entries are faded in these stages. Later code manually pokes entries 0xfd and 0xfe for pulse colors used by the scanline/text effect.

CRTC Scanline Waver

The routine at 4a0b is the most hardware-specific part. It waits for vertical retrace, writes a baseline CRTC offset value, then performs a 200-iteration scanline loop. Inside that loop it waits on Input Status 1 bit 0, then writes CRTC register 04h using a rolling table indexed by phase.

Entry:

4a0b  mov bp,0001h
4a0e  mov dx,03dah
4a11  in al,dx
4a12  test al,08h
4a14  je 4a11
4a16  in al,dx
4a17  test al,08h
4a19  jne 4a16

4a1b  mov dx,03d4h
4a1e  mov ax,0013h
4a21  out dx,ax        ; CRTC index 13h, value 0

4a22  mov bx,004ah
4a25  add bx,cs:[0914h]
4a2a  mov cx,00c8h

Per-scanline body:

4a2d  mov dx,03dah
4a30  in al,dx
4a31  test al,01h
4a33  je 4a30

4a35  mov dx,03d4h
4a38  mov al,04h
4a3a  out dx,al
4a3b  inc dx
4a3c  mov al,54h
4a3e  mov ah,cs:[bx]
4a41  add al,ah
4a43  out dx,al

The index base is 0x4a + phase, and phase is stored at CS:[0914]. Each scanline writes:

CRTC[04h] = 0x54 + table[0x4a + phase + local_step]

The code then advances through the table and occasionally writes CRTC register 13h from DS:SI:

4a4d  inc bx
4a4e  inc bx
4a4f  add si,bp
4a51  cmp si,07d0h
4a55  jb 4a64

4a57  mov dx,03d4h
4a5a  mov al,13h
4a5c  out dx,al
4a5d  inc dx
4a5e  lodsb
4a5f  out dx,al
4a60  neg bp
4a62  xor si,si

CRTC register 13h is the logical line offset. Changing it mid-frame changes how the next scanlines step through VGA memory. Combined with changes to register 04h, this gives the display a raster-split/wobble feel while the CPU does very little framebuffer work.

At the end, the phase advances by two and wraps at 0x07d0:

4a6c  mov ax,cs:[0914h]
4a70  inc ax
4a71  inc ax
4a72  cmp ax,07d0h
4a75  jne 4a7a
4a77  mov ax,0
4a7a  mov cs:[0914h],ax
4a7e  ret

That slow phase drift is why the split shape moves even if the text page itself is not redrawn every frame.

Palette Pulse Helper

The helper at 4a9b is a long unrolled palette pulse routine. Its repeated shape is:

wait for display-status timing
compute an intensity from a countdown
write DAC index 0 or 0xfe
write three RGB components
loop for a small fixed count

One representative block:

4b44  mov cx,0010h
4b47  mov ah,cl
4b49  dec ah
4b4b  shl ah,1
4b4d  shl ah,1
4b4f  add ah,14h

4b52  mov dx,03c8h
4b55  mov al,0feh
4b57  out dx,al
4b58  inc dx
4b59  mov al,02h
4b5b  out dx,al
4b5c  mov al,ah
4b5e  out dx,al
4b5f  mov al,02h
4b61  out dx,al
...
4b75  loop 4b47

The intro uses palette animation as a cheap brightness effect. Instead of changing many pixels, it changes a few DAC entries that the already-rendered pixels reference.

Glyph Writer

The glyph writer at 4d74 draws an 8x8 character to VGA memory. Inputs:

AL = character code
CX = x position in pixels
DX = y position in pixels

It first maps the character code to a glyph offset through the table at 1e60:

4d74  mov di,0a000h
4d77  mov es,di
4d79  cmp al,20h
4d7b  jne 4d7f
4d7d  mov al,61h
4d7f  sub al,28h
4d81  mov bx,1e60h
4d84  xor ah,ah
4d86  shl ax,1
4d88  add bx,ax
4d8a  mov si,cs:[bx]
4d8d  add si,0920h

Space is special-cased before the 0x28 base subtraction. The font table appears to pack only the characters needed by the intro text; the remap keeps space inside that private table.

Destination offset is y * 320 + x, implemented with shifts:

4d91  shl dx,1
4d93  shl dx,1
4d95  shl dx,1
4d97  shl dx,1
4d99  shl dx,1
4d9b  shl dx,1         ; DX = y * 64
4d9d  mov di,dx
4d9f  shl dx,1
4da1  shl dx,1         ; DX = y * 256
4da3  add di,dx        ; DI = y * 320
4da5  add di,cx        ; DI = y * 320 + x

The actual copy is unrolled: copy eight bytes, then add 0x138 to source and destination. Since 8 + 0x138 = 0x140, each row advances one 320-byte stride.

4da7  mov cx,8
4daa  rep movsb
4dac  add di,0138h
4db0  add si,0138h

4db4  mov cx,8
4db7  rep movsb
4db9  add di,0138h
4dbd  add si,0138h

      ; repeated until eight glyph rows have been copied

This is a fast enough character blitter for the intro because it writes only one glyph cell at a time. There is no need to redraw a complete text plane on each frame.

Text Page State Machine

The text system is a 40-column by 6-row page renderer. The data begins at 2b6a; the state words just before it are:

0914  CRTC wave phase
0916  signed step for the split/text pointer
0918  bounce index
091a  blank/hold countdown
091c  current column, 0..39
091e  current row, 0..5
2b64  page count, value 5
2b66  current page
2b68  hold delay
2b6a  text pages, 40 * 6 * 5 bytes

The main display loop starts at 4f93. It updates the pointer used by the CRTC routine, calls the scanline waver, calls the palette pulse, and only then decides whether to draw the next glyph:

4f93  push cs
4f94  pop ds
4f95  xor si,si
4f97  add si,cs:[0918h]
4f9c  add si,cs:[0916h]
4fa1  mov cs:[0918h],si
4fa6  cmp si,0
4fa9  jg 4fb4
4fab  neg word cs:[0916h]
4fb0  jmp 4fc3
4fb4  cmp si,07d0h
4fb8  jl 4fc3
4fba  neg word cs:[0916h]

4fc3  mov di,si
4fc5  cmp word cs:[091ah],0
4fcb  je 4fd2
4fcd  xor si,si
4fcf  dec word cs:[091ah]

4fd2  call 4a0b
4fd5  call 4a9b

0918 bounces between 0 and 0x07d0, with 0916 changing sign at the ends. That value feeds the split routine. 091a can force a temporary blank pointer while counting down.

If the hold delay at 2b68 is nonzero, the loop skips text drawing:

4fd8  cmp word cs:[2b68h],0
4fde  je 4fe1
4fe0  dec word cs:[2b68h]
      ; skip the new-glyph path

When it does draw, it computes:

text_offset = 2b6a + row * 40 + column + page * 240
x = column * 8
y = row * 12 + 16

The row multiply by 40 uses shifts:

4fe1  mov ax,cs:[091eh]  ; row
4fe5  shl ax,1           ; *2
4fe7  shl ax,1           ; *4
4fe9  shl ax,1           ; *8
4feb  mov bx,ax
4fed  shl ax,1           ; *16
4fef  shl ax,1           ; *32
4ff1  add bx,ax          ; row * 40
4ff3  add bx,cs:[091ch]  ; + column
4ff8  add bx,2b6ah       ; + page stream base

The page multiply by 240 is also shift-only:

4ffd  mov cx,cs:[2b66h]  ; page
5001  shl cx,1           ; *2
5003  shl cx,1           ; *4
5005  shl cx,1           ; *8
5007  shl cx,1           ; *16
5009  mov dx,cx          ; page * 16
500b  shl cx,1           ; *32
500d  shl cx,1           ; *64
500f  shl cx,1           ; *128
5011  shl cx,1           ; *256
5013  sub cx,dx          ; page * 240
5015  add bx,cx

The x/y coordinate math:

5017  mov ax,cs:[091ch]  ; column
501b  shl ax,1
501d  shl ax,1
501f  shl ax,1           ; x = column * 8
5021  mov cx,ax

5023  mov ax,cs:[091eh]  ; row
5027  shl ax,1
5029  shl ax,1           ; row * 4
502b  mov dx,ax
502d  shl ax,1           ; row * 8
502f  add dx,ax          ; row * 12
5031  add dx,0010h       ; y = row * 12 + 16

5034  mov al,cs:[bx]
5037  call 4d74

Then the cursor advances through columns, rows, and pages:

503a  inc word cs:[091ch]
503f  cmp word cs:[091ch],0028h
5045  jne 5089

5047  mov word cs:[091ch],0
504e  inc word cs:[091eh]
5053  cmp word cs:[091eh],0006h
5059  jne 5089

505b  mov word cs:[091eh],0
5062  mov word cs:[091ch],0
5069  mov word cs:[2b68h],012ch
5070  inc word cs:[2b66h]
5075  mov ax,cs:[2b64h]
5079  cmp word cs:[2b66h],ax
507e  jne 5089
5080  mov word cs:[2b66h],0

So the text does not scroll continuously. It types/reveals one glyph at a time across a 40x6 page, waits for 0x012c frames after a page, then advances to the next page, wrapping after five pages.

Keyboard polling is DOS function AH=0Bh:

5089  mov ah,0bh
508b  int 21h
508d  or al,al
508f  jne 5094
5091  jmp 4f96

No key: loop forever. Any key: enter the closing sequence.

Closing Sequence

On keypress, the intro consumes the key, clears the first 32,000 bytes of VGA memory, performs several palette transitions, copies the predecoded thanks page from the allocated block to A000:0000, then fades again:

5094  mov ah,08h
5096  int 21h

5098  mov ax,0a000h
509b  mov es,ax
509d  xor di,di
509f  mov cx,7d00h
50a2  xor al,al
50a4  rep stosb

The full-page copy:

5108  mov ax,cs:[4e32h]
510c  mov ds,ax
510e  mov cx,0fa00h
5111  xor si,si
5113  mov di,si
5115  rep movsb

0xfa00 is 64,000 bytes, exactly one 320x200 mode 13h screen. That is why THANX.P91 was decoded into a separate block during startup: the exit can show the final image with one linear copy instead of doing a decompression pass under the final fade.

Finally it returns to text mode and exits:

mov ax,0003h
int 10h
mov ax,4c00h
int 21h

What Each Part Does

The intro divides cleanly into these functional parts:

The core technical identity is not any single visual. It is the combination of old real-mode DOS file discipline with direct VGA timing and small, reused inner loops. For 1991 PC demo work, that is the point: it sits between simple text-mode intros and the more structured multi-effect engines that would become common in the 1992-1994 PC scene.