Difference between revisions of "7800 HSC Technical Reference"
(Created page with "= Atari 7800 High Score Cartridge (HSC) Technical Reference & Integration Guide = The '''Atari 7800 High Score Cartridge (HSC)''' is an expansion peripheral that provides non...") |
|||
| (9 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
| − | = Atari 7800 High Score Cartridge (HSC) Technical Reference | + | = Atari 7800 High Score Cartridge (HSC) Technical Reference = |
| − | The '''Atari 7800 High Score Cartridge (HSC)''' is an expansion peripheral that provides | + | The '''Atari 7800 High Score Cartridge (HSC)''' is an expansion peripheral that provides battery-backed RAM (NVRAM) and a standard high score service for Atari 7800 games. It uses a pass-through design: the HSC plugs into the 7800 cartridge slot, and the game cartridge plugs into the top of the HSC. |
| + | |||
| + | This page covers both the game-facing interface and the internals of the ROM (<code>highscre.bin</code>, 4096 bytes, MD5 <code>c8a73288ab97226c52602204ab894286</code>). Internal addresses below refer to that ROM. The 2026 community disassembly ([[7800 HSC CART]]) reassembles byte-identical with DASM, and its labels are used here. | ||
== System Architecture & Memory Map == | == System Architecture & Memory Map == | ||
| Line 10: | Line 12: | ||
! Address Range !! Size !! Device !! Description | ! Address Range !! Size !! Device !! Description | ||
|- | |- | ||
| − | | <code>$1000–$17FF</code> || 2 KB || Battery- | + | | <code>$1000–$17FF</code> || 2 KB || Battery-backed SRAM || Owner name, a 69-slot directory and 69 score tables. |
| + | |- | ||
| + | | <code>$3000–$3FFF</code> || 4 KB || HSC ROM || API, screen builders, font, strings and jump vectors. | ||
| + | |} | ||
| + | |||
| + | The ROM has no 6502 reset or interrupt vectors of its own. It only runs when a game calls into it. | ||
| + | |||
| + | === ROM Layout === | ||
| + | |||
| + | {| class="wikitable" | ||
| + | ! Range !! Contents | ||
| + | |- | ||
| + | | <code>$3000–$38FF</code> || Code (vectors' bodies, initialization, ranking, display-list builders, text entry, menu) | ||
| + | |- | ||
| + | | <code>$3900–$3F2B</code> || Font: one 44-byte glyph row per page (<code>$3F00</code> = top row … <code>$3900</code> = bottom row). Offsets <code>$2C–$FF</code> of each page hold more code and data | ||
| + | |- | ||
| + | | <code>$3D2C</code> || Palette values for P4C2…P0C2 (<code>$9D $0A $96 $98 $FA</code>, stored in reverse) | ||
|- | |- | ||
| − | | <code>$ | + | | <code>$3D31–$3DCB</code> || Encoded DLL streams for each screen (3 bytes per zone; the middle byte is ignored and replaced by page N+1) |
| + | |- | ||
| + | | <code>$3E2C–$3EC6</code> || Display-list templates for the table, personalization and menu screens | ||
| + | |- | ||
| + | | <code>$3EC7–$3EDE</code> || Powers of ten, 10,000,000 down to 1, as 24-bit little-endian values | ||
| + | |- | ||
| + | | <code>$3F2C–$3FC3</code> || Strings: <code>CONGRATULATIONS PLAYER 1</code>, <code>CARTRIDGE</code>/<code>PERSONALIZE</code>/<code>YOUR ATARI HIGH SCORE</code>, and the menu text (<code>WHAT WOULD YOU LIKE TO CLEAR?</code>, <code>THIS GAME</code>, <code>PERSONALIZATION</code>, <code>ALL GAMES</code>, <code>NOTHING</code>, <code>ARE YOU SURE?</code>, <code>YES</code>; "NO" is the first two letters of NOTHING) | ||
| + | |- | ||
| + | | <code>$3FEB–$3FFF</code> || Seven <code>JMP</code> vectors | ||
|} | |} | ||
=== Zero-Page Footprint ($80–$BC) === | === Zero-Page Footprint ($80–$BC) === | ||
| − | The HSC uses zero | + | The HSC uses zero page <code>$80</code>–<code>$BC</code> (61 bytes) while a call runs. |
| − | * ''' | + | * '''Preservation:''' on entry, <code>HSCInitRoutine</code> (<code>$3483</code>) copies <code>$80–$BC</code> to the caller's page N at <code>+$13..+$4F</code>, in reverse order (<code>$BC</code> → <code>+$13</code> … <code>$80</code> → <code>+$4F</code>). <code>RestoreVarsAndRegsAndExit</code> (<code>$3C46</code>) copies it back. |
| − | + | * '''$94/$95:''' these hold the HSC's pointer to page N during the call, so their original values are parked in NVRAM <code>$1000</code>/<code>$1001</code> before the backup and restored last. | |
| − | * '''$94/$95 | + | * The first 18 bytes, <code>$80–$91</code>, are a working copy of the argument block (<code>+$00..+$11</code>). |
| + | |||
| + | Game code that runs during a call (the sound hook, a custom controller driver, a DLI handler) sees the HSC's values in <code>$80–$BC</code>, so it must not write there. | ||
=== Dual-Page RAM Requirement (512 Contiguous Bytes) === | === Dual-Page RAM Requirement (512 Contiguous Bytes) === | ||
| − | + | Every call needs a page-aligned 512-byte RAM work area: '''page N''' (passed in <code>Y</code>) and '''page N+1'''. | |
<pre> | <pre> | ||
| − | + | Page N: | |
| − | + | +$00-$11 Argument block (inputs) +$12 HSCSTAT result byte | |
| − | + | +$13-$4F Zero-page backup ($BC..$80) | |
| − | +- | + | +$50-$8B Formatted table: 5 rows x 12 bytes (3 initials + 9 score characters) |
| − | + | +$8C-$A5 Game name (26) +$A6-$BF Difficulty name (26) | |
| − | +- | + | +$C0-$D8 "CONGRATULATIONS PLAYER n" +$DA-$F9 Owner name (32) |
| − | + | (the personalization and menu screens reuse +$8C onwards for their own strings) | |
| − | +- | ||
| − | |||
| − | +-- | ||
| − | |||
| − | |||
| − | Page N+1 | + | Page N+1: |
| − | +- | + | +$00-$86 Display lists (screen-specific templates, patched at run time) |
| − | + | +$87-... Display List List (DPPL = $87, DPPH = page N+1) | |
| − | |||
| − | |||
| − | |||
</pre> | </pre> | ||
<div style="background: #fff3cd; border-left: 5px solid #ffeeba; padding: 12px; margin: 12px 0;"> | <div style="background: #fff3cd; border-left: 5px solid #ffeeba; padding: 12px; margin: 12px 0;"> | ||
| − | ''' | + | '''Reserve both pages.''' Display calls build their DLs and DLL in page N+1 and point MARIA there. A game that reserves only 256 bytes gets the following page overwritten. HSCSTAT also overwrites page N <code>+$50..+$8B</code>. |
| − | |||
</div> | </div> | ||
| − | == Cartridge Detection | + | == Cartridge Detection == |
| − | + | Check the two signature bytes, which are the bottom rows of glyphs 'A' and 'E', before calling any vector: | |
<pre lang="asm"> | <pre lang="asm"> | ||
| − | + | DetectHSC: ; carry set = HSC present | |
| − | + | lda $3900 | |
| − | ; | + | cmp #$C6 |
| − | + | bne .no | |
| − | + | lda $3904 | |
| − | + | cmp #$FE | |
| − | + | bne .no | |
| − | + | sec | |
| − | + | rts | |
| − | + | .no clc | |
| − | + | rts | |
| − | |||
| − | |||
| − | . | ||
| − | |||
| − | |||
</pre> | </pre> | ||
| − | + | Variants seen in games: <code>lda #$C6 / cmp $3900</code> (Asteroids Deluxe), <code>lda $3900 / eor #$C6</code> (current 7800basic), and <code>lda $1002 / cmp #$68</code> (old 7800basic, which checks the NVRAM magic instead). | |
| − | + | == Public Vector Table & API == | |
{| class="wikitable" | {| class="wikitable" | ||
| − | ! Vector | + | ! Vector !! Name !! Target !! Function ID (<code>$1007</code>) !! Description !! Result in A |
|- | |- | ||
| − | | <code>$3FEB</code> || <code> | + | | <code>$3FEB</code> || HSCSETRS || <code>$39A9</code> || — || Sets CTRL <code>$4B</code>, BACKGRND <code>$00</code>, P0C2–P4C2, CHARBASE <code>$39</code>. No argument block, no hold || undefined |
|- | |- | ||
| − | | <code>$3FEE</code> || <code> | + | | <code>$3FEE</code> || HSCCPYFM || <code>$3000</code> || <code>$EF</code> || Copies the 25-byte table to the buffer pointed to by arg <code>+$0F/$10</code> || <code>$00</code>; <code>$EF</code> if no table (nothing copied) |
|- | |- | ||
| − | | <code>$3FF1</code> || <code> | + | | <code>$3FF1</code> || HSCCPYTO || <code>$3011</code> || <code>$04</code> || Copies 25 bytes from arg <code>+$0F/$10</code> into the table, creating it if needed || <code>$01</code> |
|- | |- | ||
| − | | <code>$3FF4</code> || <code> | + | | <code>$3FF4</code> || HSCENTER2 || <code>$3127</code> || <code>$01</code> || Like HSCENTER but takes the initials from arg <code>+$0F..+$11</code>. '''Not headless''': it still shows the congratulations table || <code>$01</code> if inserted, <code>$00</code> if not |
|- | |- | ||
| − | | <code>$3FF7</code> || <code> | + | | <code>$3FF7</code> || HSCSTAT || <code>$3022</code> || <code>$02</code> || Query: rank, top score, number of tables. Never creates a table or updates LRU ages || <code>$00</code> |
|- | |- | ||
| − | | <code>$3FFA</code> || <code> | + | | <code>$3FFA</code> || HSCATRCT || <code>$30DA</code> || <code>$FF</code> (then <code>$FD</code>) || Shows one game/difficulty table (it doesn't cycle through difficulties) || <code>$00</code>; <code>$FF</code> if no table (returns at once) |
|- | |- | ||
| − | | <code>$3FFD</code> || <code> | + | | <code>$3FFD</code> || HSCENTER || <code>$312B</code> || <code>$00</code> || Ranks the score; if it qualifies, inserts it and runs interactive initials entry || <code>$01</code> if inserted (also after a cancel), <code>$00</code> if not |
|} | |} | ||
| − | + | === Calling Conventions === | |
| − | + | * '''Y:''' high byte of page N. | |
| + | * '''X:''' ignored. <code>HSCInitRoutine</code> always uses offset <code>$00</code>. | ||
| + | * '''On return:''' I flag set (<code>SEI</code> is never undone), D clear, stack balanced. X/Y undefined. | ||
| + | * '''Return path:''' <code>HSCInitRoutine</code> slips two bytes, <code>$3C</code> and <code>$2B</code>, under its own return address. When the vector's body finishes with <code>RTS</code>, control goes to <code>$3C2C</code>, the common display hold. That code pushes the result, runs the hold, and leaves through <code>RestoreVarsAndRegsAndExit</code>, which pulls the result into A and returns to the game. So every vector except HSCSETRS ends in the hold, including HSCSTAT and the copy calls. | ||
| − | + | === Common Entry Processing (<code>HSCInitRoutine</code>, $3483) === | |
| − | |||
| − | |||
| − | |||
| − | |||
| − | = | + | # Store the function ID at <code>$1007</code>, then <code>SEI</code>, <code>CLD</code>. |
| + | # Display vectors (IDs other than <code>$02</code>, <code>$EF</code> and <code>$04</code>): CTRL = <code>$7F</code> (DMA off), AUDV0 = AUDV1 = 0. | ||
| + | # Park <code>$94/$95</code> in <code>$1000/$1001</code> and back up the zero page. | ||
| + | # Read SWCHB into <code>$BC</code> and write <code>$FF</code> to SWCHB, which drives any output bits high and forces one-button joystick mode. The saved value is written back on exit. | ||
| + | # Copy argument bytes <code>+$00..+$11</code> to <code>$80..$91</code>. Player index = <code>+$02 & 3</code> → <code>$A9</code>. | ||
| + | # If the name-pointer high byte '''and''' the logo-pointer high byte are both 0, overwrite <code>$80–$8E</code> with the defaults at <code>$39EF</code>: <code>$80 $01 $F1 $01 $79 $E9 $00 $1A $4C $00 $00 $00 $FF $3C $0A</code>. That's ID <code>$80/$01</code>, difficulty <code>$F0</code>, player 2, joystick, name at <code>$E979</code>, difficulty at <code>$1A00</code>, score at <code>$004C</code>, no logo, sound hook <code>$3CFF</code> (an <code>RTS</code>), 10-second hold. The purpose of this set is unknown. | ||
| + | # Do the stack trick described above. | ||
| + | # Display vectors: load the palette and CHARBASE (<code>HSCSETRS2</code>, <code>$39AD</code>). | ||
| + | # Check the NVRAM magic (<code>$3512</code>). If it fails, check again (one retry), and if it still fails, format (<code>$3535</code>). Formatting writes the magic, sets every slot's difficulty and age to <code>$7F</code> (<code>$3FCF</code>), and then, unless the call is HSCCPYFM or HSCCPYTO, runs personalization (<code>$3C95</code>) and turns DMA off. | ||
| + | # Convert the game's BCD score to binary (<code>$3A81</code>, see [[#Score conversion|Score conversion]]). This runs for '''every''' vector. | ||
| + | # Look up the directory (<code>$3572</code>) and set <code>$A6/$A7</code> to the table address. | ||
| − | + | == User Argument Block (Page N) == | |
{| class="wikitable" | {| class="wikitable" | ||
| − | ! Offset !! Size !! Field | + | ! Offset !! Size !! ZP copy !! Field !! Description |
|- | |- | ||
| − | | <code>+$00</code> || 1 || <code> | + | | <code>+$00</code> || 1 || <code>$80</code> || Game ID 1 || '''Bit 7 set:''' the game passes its score ÷ 10. The table shows an extra trailing 0 and the top score returned by HSCSTAT is in the game's units |
|- | |- | ||
| − | | <code>+$01</code> || 1 || <code> | + | | <code>+$01</code> || 1 || <code>$81</code> || Game ID 2 || |
|- | |- | ||
| − | | <code>+$02</code> || 1 || <code> | + | | <code>+$02</code> || 1 || <code>$82</code> || Difficulty / player || Bits 7–2: difficulty (one table per value). Bits 1–0: player index. 0 = left port (SWCHA high nibble, INPT4); 1–3 = right port (SWCHA low nibble, INPT5). Shown as "PLAYER index+1" |
|- | |- | ||
| − | | <code>+$03</code> || 1 || <code> | + | | <code>+$03</code> || 1 || <code>$83</code> || Controller type || <code>$02</code> = custom driver at the name pointer. Any other value = joystick (<code>$00</code> and <code>$01</code> behave the same) |
|- | |- | ||
| − | | <code>+$04</code> || 2 || <code> | + | | <code>+$04</code> || 2 || <code>$84</code> || Game name pointer || '''Exactly 26''' characters, read raw and drawn centered (pad with <code>$1D</code>). With type <code>$02</code>, also the driver entry point. '''Overwritten by HSCSTAT''' |
|- | |- | ||
| − | | <code>+$06</code> || 2 || <code> | + | | <code>+$06</code> || 2 || <code>$86</code> || Difficulty name pointer || 26 characters. High byte 0 = no difficulty line (the 26 bytes are still copied from <code>$00xx</code>, see bugs) |
|- | |- | ||
| − | | <code>+$08</code> || 2 || <code> | + | | <code>+$08</code> || 2 || <code>$88</code> || Score pointer || 4-byte packed BCD, big-endian (<code>1,234,560</code> = <code>$01 $23 $45 $60</code>). Read on every call, and written to by the clamp |
|- | |- | ||
| − | | <code>+$0A</code> || 2 || <code> | + | | <code>+$0A</code> || 2 || <code>$8A</code> || Logo DLL pointer || High byte 0 = show the game name as text |
|- | |- | ||
| − | | <code>+$0C</code> || 2 || <code> | + | | <code>+$0C</code> || 2 || <code>$8C</code> || Sound hook || Called once per frame during the hold and during initials entry. Must be valid |
|- | |- | ||
| − | | <code>+$0E</code> || 1 || <code> | + | | <code>+$0E</code> || 1 || <code>$8E</code> || Hold time || Seconds (60-frame units) of display hold before returning. 0 = none |
|- | |- | ||
| − | | <code>+$0F</code> || | + | | <code>+$0F</code> || 2 || <code>$8F</code> || Buffer pointer / initials || HSCCPYFM/HSCCPYTO: 25-byte buffer pointer. HSCENTER2: initials 1 and 2 |
|- | |- | ||
| − | | <code>+$ | + | | <code>+$11</code> || 1 || <code>$91</code> || Initial 3 || HSCENTER2 only |
| + | |- | ||
| + | | <code>+$12</code> || 1 || — || (result) || Written by HSCSTAT | ||
|} | |} | ||
| − | === | + | === HSCSTAT Results === |
| − | + | {| class="wikitable" | |
| − | + | ! Offset !! Result | |
| + | |- | ||
| + | | <code>+$04</code> || Number of directory slots with this game's ID 1 and ID 2 and difficulty ≠ <code>$7F</code>, i.e. how many difficulty tables exist (0–69) | ||
| + | |- | ||
| + | | <code>+$05</code> || Rank: <code>$00</code> = 1st place … <code>$04</code> = 5th, <code>$FF</code> = doesn't qualify | ||
| + | |- | ||
| + | | <code>+$0F..+$12</code> || Current 1st-place score as 4-byte packed BCD, big-endian, in the game's units | ||
| + | |} | ||
| + | |||
| + | * A score of 0 never qualifies (<code>IsScoreNonZero</code>, <code>$3DDC</code>). | ||
| + | * A score '''equal''' to an existing entry ranks above it. | ||
| + | * If the game/difficulty has no table, <code>+$05</code> is <code>$FF</code> for any score and <code>+$0F..+$12</code> are 0. So a game that calls HSCENTER only when HSCSTAT reports a qualifying rank can never create its first table. | ||
| + | * How the top score is produced: HSCSTAT formats the table as text (<code>FormatAllScoresForDisplay</code>, <code>$3769</code>), then packs the eight digit characters of row 1 (<code>$30A9</code>) back into BCD, treating spaces as 0. | ||
| − | + | === Sound Hook === | |
| − | |||
| − | + | The hook is reached by <code>JSR $36DB</code>, which does <code>JMP (SOUNDPTR)</code>, so the hook returns with <code>RTS</code>. It may clobber A, X and Y. It's called during the hold (but not while paused) and during initials entry. It isn't called during personalization or the menu. | |
| − | |||
| − | |||
| − | |||
| − | + | === Custom Controller Driver ($03 = $02) === | |
| − | |||
| − | + | Reached by <code>JMP ($84)</code> (<code>$39A6</code>) from a subroutine call, with X = player index. It must return: | |
| − | + | * A bits 7–4 = right, left, down, up (0 = pushed) | |
| − | + | * Y bit 7 = fire (0 = pressed) | |
| − | + | Driver input is used for text entry, the hold's exit check and fire-release waits. The hold's raw checks (INPT4 and any SWCHA bit) are skipped for type <code>$02</code>. The screen saver (<code>$392C</code>) and the maintenance menu still read the real ports. Because the name pointer doubles as the driver address, a driver game should supply a logo, or its code bytes get drawn as the game name. | |
| − | === | + | === Logo DLL === |
| − | The HSC | + | The decoder (<code>$36DE</code>) copies the game's 3-byte DLL entries verbatim to the start of the DLL. It keeps a running total of zone heights (<code>(byte0 & $0F) + 1</code>) and stops after the entry that brings the total to at least '''65 lines''' (<code>$41</code>, when a difficulty name is present) or '''79 lines''' (<code>$4F</code>, when it isn't). |
| + | |||
| + | DLI handling: | ||
| + | * If the '''first''' logo entry has its DLI bit set, <code>$B9</code> is set to <code>$40</code>. | ||
| + | * <code>SetupDLL</code> (<code>$3715</code>) shifts <code>$B9</code> left for every HSC entry flagged with a DLI. The first such entry keeps its DLI bit; all later ones have it stripped. | ||
| + | * Without a logo DLI, every HSC DLI bit is stripped. | ||
| + | |||
| + | The one kept DLI lets the game's NMI handler restore the HSC's CHARBASE and palette after a logo that changed them. | ||
| + | |||
| + | == Data Representations == | ||
| + | |||
| + | === Character Set & Font === | ||
| + | |||
| + | The HSC uses its own character codes, often called '''Watkins encoding'''. Letters are <code>'X' - 'A'</code>. | ||
{| class="wikitable" style="text-align: center;" | {| class="wikitable" style="text-align: center;" | ||
| − | ! | + | ! Code !! Glyph !! Code !! Glyph !! Code !! Glyph |
|- | |- | ||
| − | | <code>$ | + | | <code>$00–$19</code> || '''A'''–'''Z''' || <code>$1D</code> || (space) || <code>$21–$2A</code> || '''0'''–'''9''' |
|- | |- | ||
| − | | <code>$ | + | | <code>$1A</code> || '''_''' (underscore: full-width bar on the bottom row) || <code>$1E</code> || ''' ' ''' (apostrophe) || <code>$2B</code> || '''?''' |
|- | |- | ||
| − | | <code>$ | + | | <code>$1B</code> || '''.''' (period) || <code>$1F</code> || '''█''' (solid block: END in text entry) || || |
| + | |- | ||
| + | | <code>$1C</code> || '''-''' (hyphen) || <code>$20</code> || '''←''' (left arrow: BACKSPACE in text entry) || || | ||
| + | |} | ||
| + | |||
| + | There's '''no colon'''. | ||
| + | |||
| + | Glyphs are 8×7 pixels, drawn in 320A mode with CHARBASE <code>$39</code>. Row r of glyph c (r = 0 at the top) is at <code>$3900 + (6−r)×$100 + c</code>. | ||
| + | |||
| + | DASM macro: | ||
| + | <pre lang="asm"> | ||
| + | mac WATKINS | ||
| + | dc.b {1}-"A" | ||
| + | endm | ||
| + | </pre> | ||
| + | |||
| + | === Packed Initials (15 bits) === | ||
| + | |||
| + | <pre> | ||
| + | Byte 0: [ C1.4 | C1.3 | C1.2 | C0.4 | C0.3 | C0.2 | C0.1 | C0.0 ] | ||
| + | Byte 1: [ C1.1 | C1.0 | 0 | C2.4 | C2.3 | C2.2 | C2.1 | C2.0 ] | ||
| + | |||
| + | byte0 = ((c1 >> 2) << 5) | c0 c0 = byte0 & $1F | ||
| + | byte1 = ((c1 & 3) << 6) | c2 c1 = ((byte0 >> 5) << 2) | (byte1 >> 6) | ||
| + | c2 = byte1 & $1F | ||
| + | </pre> | ||
| + | |||
| + | * Packing: <code>EncodeAndSaveInitialsToTable</code>, <code>$31D7</code>. Unpacking: <code>$3AD6</code>. | ||
| + | * Empty entries hold <code>___</code> (three underscores, <code>$1A $1A $1A</code>), packed as <code>$DA $9A</code>. | ||
| + | * Other examples: <code>ABC</code> = <code>$00 $42</code>, <code>XYZ</code> = <code>$D7 $19</code>. | ||
| + | |||
| + | === Score conversion === | ||
| + | |||
| + | Games pass 8-digit packed BCD. NVRAM holds a '''24-bit binary''' value, little-endian. | ||
| + | |||
| + | # '''Clamp''' (<code>$3A83</code>): the score is compared byte by byte with <code>$16 $77 $72 $15</code> (16,777,215). If it's larger, the maximum is '''written back into the game's score''' and used instead. The comparison is signed, see [[#Bugs and quirks in the ROM|bugs]]. | ||
| + | # '''Convert''' (<code>$3AA8</code>): for each nibble, the value is multiplied by 10 (<code>$3A5E</code>, a full 24-bit ×10) and the nibble is added. The result goes in <code>$AD</code> (LSB), <code>$AE</code>, <code>$AF</code> (MSB). The nibble add has a carry bug, see [[#Bugs and quirks in the ROM|bugs]]. | ||
| + | # '''Display''' (<code>$37BC</code>): the value is turned into decimal by repeated subtraction of the powers of ten at <code>$3EC7</code>, giving a 9-character field: | ||
| + | #* ID1 bit 7 clear: a leading space, then 8 digits with leading zeros blanked. The last digit always shows. | ||
| + | #* ID1 bit 7 set: 8 digits with leading zeros blanked, then a final <code>0</code>. | ||
| + | |||
| + | == Cartridge NVRAM Layout ($1000–$17FF) == | ||
| + | |||
| + | {| class="wikitable" | ||
| + | ! Address !! Size !! Disassembly label !! Contents | ||
|- | |- | ||
| − | | <code>$ | + | | <code>$1000–$1001</code> || 2 || <code>CartRAMSaveZ94/Z95</code> || Caller's <code>$94/$95</code> during a call |
|- | |- | ||
| − | | <code>$ | + | | <code>$1002–$1006</code> || 5 || <code>CartRAMMagic1..5</code> || <code>$68 $83 $AA $55 $9C</code> = formatted |
|- | |- | ||
| − | | <code>$ | + | | <code>$1007</code> || 1 || <code>CartRAMEntryID</code> || Function ID of the call in progress (<code>$FD</code>/<code>$FE</code>/<code>$FF</code> during attract-style displays) |
|- | |- | ||
| − | | <code>$ | + | | <code>$1008–$1027</code> || 32 || <code>CartRAMOwnerName</code> || Owner name. Bytes after the length are leftover; the END code <code>$1F</code> usually follows the last character |
|- | |- | ||
| − | | <code>$ | + | | <code>$1028</code> || 1 || <code>CartRAMOwnerNameLength</code> || 1–32 |
|- | |- | ||
| − | | <code>$ | + | | <code>$1029–$106D</code> || 69 || <code>CartRAMGameID1Table</code> || Game ID 1 per slot |
|- | |- | ||
| − | | <code>$ | + | | <code>$106E–$10B2</code> || 69 || <code>CartRAMGameID2Table</code> || Game ID 2 per slot |
|- | |- | ||
| − | | <code>$ | + | | <code>$10B3–$10F7</code> || 69 || <code>CartRAMGameDifficultyTable</code> || Difficulty (<code>& $FC</code>) per slot. <code>$7F</code> = free |
|- | |- | ||
| − | | <code>$ | + | | <code>$10F8–$113C</code> || 69 || <code>CartRAMGameAgeTable</code> || LRU age per slot. <code>$7F</code> = free |
|- | |- | ||
| − | | <code>$ | + | | <code>$113D–$17F9</code> || 1725 || — || 69 tables × 25 bytes; slot s at <code>$113D + 25·s</code> |
|- | |- | ||
| − | | <code>$ | + | | <code>$17FA–$17FE</code> || 5 || <code>CartRAMSavedEntry</code> || Copy of the displaced 5th-place entry during HSCENTER, used to undo a cancel |
|- | |- | ||
| − | | <code>$ | + | | <code>$17FF</code> || 1 || — || Unused |
|} | |} | ||
| − | + | Formatting only writes the magic and the <code>$7F</code> markers. IDs, tables and the owner name keep whatever was there. | |
| − | < | + | |
| − | + | === Score Tables === | |
| − | + | ||
| − | + | Each table holds 5 entries of 5 bytes, 1st place first: packed initials (2 bytes), then the score as 24-bit little-endian binary (3 bytes). A new table is 5 × (<code>$DA $9A $00 $00 $00</code>). | |
| − | </ | + | |
| + | === Directory Lookup & LRU === | ||
| + | |||
| + | '''Lookup''' (<code>$3572</code>): | ||
| + | * Scans slots 68 down to 0 for a slot whose ID 1, ID 2 and difficulty (<code>arg+$02 & $FC</code>) all match. A free slot can't match, because <code>$7F</code> has its low bits set. | ||
| + | * While scanning, it tracks the slot with the largest age (signed compare, strictly greater). That slot is the eviction victim: free slots (<code>$7F</code>) win, and on ties the highest slot number wins. | ||
| + | |||
| + | '''Touch''' (<code>$35A1</code>), done by every call except HSCSTAT: | ||
| + | * Let old = the slot's age. Set its age to 0. | ||
| + | * Then increment every age that is less than old (signed), the touched slot included. | ||
| + | * So the most recently used slot ends with age '''1''', not 0. In-use ages stay distinct from 1 upward, and free slots stay <code>$7F</code>. | ||
| + | |||
| + | '''Not found''' (<code>$35E0</code>): | ||
| + | * Negative function IDs (HSCATRCT <code>$FF</code>, HSCCPYFM <code>$EF</code>) drop the call's return address, store the ID in the hold timer, and return the ID straight away, with no hold. DMA was already turned off and audio muted for HSCATRCT. | ||
| + | * HSCSTAT reports "no table". | ||
| + | * Everything else creates a table (<code>$35F4</code>) in the victim slot, so when all 69 slots are in use the least recently used game is evicted. | ||
| + | |||
| + | == Interactive Behavior == | ||
| + | |||
| + | === Display Hold ($3C2C) === | ||
| + | |||
| + | * The hold counts down <code>arg+$0E</code> seconds. Each second is 60 passes, and each pass waits one vertical blank, checks input, and calls the sound hook. | ||
| + | * The input check (<code>$3C63</code>) ends the hold on any of: | ||
| + | ** P1 fire, or any SWCHA bit (both of these are skipped for driver games) | ||
| + | ** RESET or SELECT | ||
| + | ** the selected controller (joystick or driver) | ||
| + | * '''Pause''' (<code>$3B51</code>), triggered by SWCHB bit 3 low on two reads two frames apart: | ||
| + | ** Wait for release, mute audio, and stop calling the hook. | ||
| + | ** While paused, the exit inputs above still work. Another PAUSE press resumes the countdown. | ||
| + | ** Holding PAUSE and pressing SELECT (<code>$3B71</code>) opens the maintenance menu. | ||
| + | ** After about 233×256 frames (roughly 16 minutes) paused, the screen saver (<code>$392C</code>) turns DMA off until any input. | ||
| + | |||
| + | === Initials Entry ($3242) === | ||
| + | |||
| + | '''Setup.''' The new score has already been inserted: lower entries shifted down, and the old 5th place saved at <code>$17FA</code>. The inserted row still shows the displaced entry's initials. | ||
| + | |||
| + | '''The cursor''' is a separate one-character DL object on the row. It blinks every 30 frames by toggling palette bit 2 (P0 ↔ P4). It starts at position 0, showing the character already there. | ||
| + | |||
| + | '''Stepping through characters.''' The stick is sampled every 10 frames: | ||
| + | * '''Up/Right''' = next: A…Z, _, ., -, space, ', █, ←, then A. At position 0 the ← is skipped. | ||
| + | * '''Down/Left''' = previous, the same cycle backwards. | ||
| + | * Pressing Next on <code>_</code> as the first move at a position jumps straight to 'A'. | ||
| + | * Position 3 offers only █ (END) and ← (BACKSPACE). | ||
| + | |||
| + | '''Fire''' is checked every frame and acts after release: | ||
| + | * On a character: accept it and move right. | ||
| + | * On ←: move left. The ← glyph stays drawn in the position left behind. | ||
| + | * On █: finish. | ||
| + | * RESET or SELECT finishes at the current position. | ||
| + | |||
| + | '''Finishing:''' | ||
| + | * At position 0, finishing cancels: the table is shifted back and the old 5th place restored from <code>$17FA</code>. The routine still falls through into the packing code, which rewrites the restored entry's initials unchanged. | ||
| + | * At positions 1–2, the remaining initials are filled with spaces. | ||
| + | |||
| + | There's no time-out. A screen saver kicks in after about 15 minutes (233×233 frames) without input. | ||
| − | === | + | === Personalization ($3C95) === |
| − | + | Shown on first use and from the menu. The screen reads PERSONALIZE / YOUR ATARI HIGH SCORE / CARTRIDGE, and an entry line underneath stays centered as it grows. Entry rules: | |
| + | * Up to 32 characters, same character cycle as the initials, no digits. | ||
| + | * It always uses the left joystick and has no console abort. | ||
| + | * Each new position starts on the previous character. | ||
| + | * █ (END) finishes. An empty name becomes one space. | ||
| + | * Before entry starts, the owner name in NVRAM is set to <code>A T A R I</code> (length 9) in case power is lost mid-entry. | ||
| − | + | === Maintenance Menu ($3B7F) === | |
| − | |||
| − | |||
| − | |||
| − | + | The menu reads WHAT WOULD YOU LIKE TO CLEAR?, with four choices: THIS GAME, ALL GAMES, PERSONALIZATION, NOTHING. The selection starts on NOTHING, and Up/Down move it, wrapping around. Fire brings up ARE YOU SURE? YES NO: Left/Right choose, starting on NO, and NO returns to the menu. | |
| − | |||
| − | |||
| − | + | What each choice does after YES (dispatcher <code>$39C8</code>): | |
| − | < | + | * '''THIS GAME''': frees the current game/difficulty slot only (<code>$3FC4</code>). The IDs and score bytes stay behind. |
| − | + | * '''ALL GAMES''': frees all 69 slots. The owner name is kept. | |
| − | + | * '''PERSONALIZATION''': runs the name entry. | |
| − | + | * '''NOTHING''': leaves the HSC, returning the original result to the game. | |
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | + | The first three return to the menu afterwards. The menu has no time-out, no console exit and no sound hook, and it always uses the left joystick. | |
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | + | === Screen Layout & Colors === | |
| − | + | Colors: BACKGRND <code>$00</code>, P0C2 <code>$FA</code> (headers, cursor), P1C2 <code>$98</code> (scores and initials of rows 2–5), P2C2 <code>$96</code> (rank digits 2–5), P3C2 <code>$0A</code> (game, difficulty and owner names), P4C2 <code>$9D</code> (1st-place row, cursor blink). | |
| − | + | The table screen, top to bottom: | |
| + | # '''Header:''' 10+16+16 blank lines, the game name (26 characters at x = 56 in 320 mode), then 16 blank lines. A game logo replaces this. | ||
| + | # '''Difficulty''' (optional): 7 blank lines, then the difficulty name. | ||
| + | # '''Entry layout only:''' 16 blank lines, <code>CONGRATULATIONS</code>, 7 blank lines, <code>PLAYER n</code>. | ||
| + | # '''Table:''' 16 blank lines, the owner name (centered), then five rows. Each row is a rank digit at x = 100, the 9-character score at x = 116 and the initials at x = 196, separated by 7-line gaps. | ||
| + | # Blank 7-line zones pad the DLL to at least 243 lines. | ||
| − | + | The community "PAL hack" (<code>PALHACK</code> in the disassembly) only lengthens the gap zones. | |
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | == | + | == Bugs and Quirks in the HSC ROM == |
{| class="wikitable" | {| class="wikitable" | ||
| − | ! | + | ! # !! Where !! Behavior !! Effect |
|- | |- | ||
| − | | <code>$ | + | | 1 || <code>$3ABB</code>, <code>$3ACC</code> || '''Carry lost in BCD→binary conversion.''' After each nibble is added to the low byte, the carry goes into the middle byte with <code>ADC #$00</code> but never on into the high byte (<code>$AF</code>). || Whenever the running value crosses a multiple of 65,536 during a nibble add, 65,536 is lost. 65,536 → 0, 131,072 → 65,536, '''655,360 → 0''', which can then never rank. 14 multiples of 10 below 2,000,000 are affected. |
|- | |- | ||
| − | | <code>$ | + | | 2 || <code>$3A8B–$3A91</code> || '''Signed clamp compare.''' <code>CMP</code> then <code>BMI/BPL</code> tests the sign of (limit − score) instead of the carry. || First byte <code>$97–$99</code> (scores ≥ 97,000,000) isn't clamped, and neither is <code>$16 $77 $72</code> followed by <code>$96–$99</code>. Those values wrap modulo 2^24. |
|- | |- | ||
| − | | <code>$ | + | | 3 || <code>$3A93</code> || The clamp writes the maximum back into the game's score bytes, on every vector, including HSCATRCT and the copy calls. || A game showing its score after an HSC call shows 16,777,215. |
|- | |- | ||
| − | | <code>$ | + | | 4 || <code>$31D7</code> || HSCENTER2 initials aren't masked to 5 bits. || A value above <code>$1F</code> in initial 1 or 3 corrupts initial 2's packed bits. |
|- | |- | ||
| − | | <code>$ | + | | 5 || <code>$36AB</code> || The 26-byte difficulty-name copy runs even when its pointer's high byte is 0. || Reads zero page and TIA/MARIA addresses. Harmless on stock hardware. |
|- | |- | ||
| − | | <code>$ | + | | 6 || <code>$3C2D</code> || The hold timer is decremented and tested with <code>BMI</code>. || Hold values <code>$81–$FF</code> behave like 0; <code>$80</code> holds for 128 s. |
|- | |- | ||
| − | | <code>$ | + | | 7 || <code>$3318</code> || BACKSPACE stores the ← glyph in the position it leaves. || The ← stays on screen, and moving right again starts on ←. |
|- | |- | ||
| − | | <code>$ | + | | 8 || <code>$392C</code> || The screen-saver wake-up reads the raw ports. || Driver-based controllers can't wake the screen. |
|- | |- | ||
| − | | <code>$ | + | | 9 || <code>$392C</code> || The screen saver ends with SWACNT = SWBCNT = 0. || A game's two-button-mode setup is lost after a long idle period. |
|- | |- | ||
| − | | <code>$ | + | | 10 || <code>$3022</code> || HSCSTAT reports <code>$FF</code> when no table exists, even for scores HSCENTER would accept. || Games must call HSCENTER for any nonzero score to get their first table. |
|- | |- | ||
| − | | <code>$ | + | | 11 || <code>$35A1</code> || The touch loop also increments the slot it just zeroed. || The most recently used age is 1, not 0. |
|- | |- | ||
| − | | <code>$ | + | | 12 || <code>$3C95</code> || First-use formatting during HSCSTAT runs the personalization screen and returns with DMA off. || Games that call HSCSTAT at boot show a naming screen on a fresh cart. |
| + | |- | ||
| + | | 13 || <code>$3242</code> || Fire isn't required to be released before entry starts. || A button still held from gameplay accepts the first initial on release. | ||
| + | |- | ||
| + | | 14 || <code>$36DE</code>, <code>$3715</code> || <code>$B9</code> is both the logo-DLI flag and the screen-saver timer. || Harmless; worth knowing when reading the code. | ||
| + | |- | ||
| + | | 15 || <code>$3F6E</code> || A stray letter 'A' between two strings is never referenced. || None. | ||
|} | |} | ||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
== 7800 Assembly Integration Example == | == 7800 Assembly Integration Example == | ||
| Line 335: | Line 427: | ||
<pre lang="asm"> | <pre lang="asm"> | ||
| − | |||
| − | |||
| − | |||
HscRomTemplate: | HscRomTemplate: | ||
| − | .byte $8B, $23 ; | + | .byte $8B, $23 ; game ID 1 and 2 |
| − | .byte $00 ; | + | .byte $00 ; difficulty << 2 | player (filled in at run time) |
| − | .byte $00 ; | + | .byte $00 ; controller type: joystick |
| − | .word HscTitleText ; | + | .word HscTitleText ; 26-character game name |
| − | .word HscDiffText ; | + | .word HscDiffText ; 26-character difficulty name (or high byte 0) |
| − | .word GameScoreBcd ; | + | .word GameScoreBcd ; 4-byte packed BCD score in RAM |
| − | .word 0 ; | + | .word 0 ; no logo: show the name as text |
| − | .word GameSoundCallback ; | + | .word GameSoundCallback ; must be valid; point at an RTS if unused |
| − | .byte $ | + | .byte $05 ; hold time in seconds |
| − | HscTitleText: | + | HscTitleText: ; exactly 26 characters, padded to center "MY GAME" |
| + | .byte $1D,$1D,$1D,$1D,$1D,$1D,$1D,$1D,$1D,$1D | ||
WATKINS "M" | WATKINS "M" | ||
WATKINS "Y" | WATKINS "Y" | ||
| − | .byte $1D | + | .byte $1D |
WATKINS "G" | WATKINS "G" | ||
WATKINS "A" | WATKINS "A" | ||
WATKINS "M" | WATKINS "M" | ||
WATKINS "E" | WATKINS "E" | ||
| − | .byte $1D | + | .byte $1D,$1D,$1D,$1D,$1D,$1D,$1D,$1D,$1D |
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
</pre> | </pre> | ||
| − | === Attract Mode | + | === Attract Mode === |
<pre lang="asm"> | <pre lang="asm"> | ||
| − | |||
| − | |||
| − | |||
ShowHighScores: | ShowHighScores: | ||
| − | + | jsr DetectHSC | |
| − | + | bcc .done | |
| + | jsr CopyArgs ; copy the 15-byte template to $2500 (page N = $25) | ||
| + | lda CurrentDifficulty | ||
| + | asl | ||
| + | asl | ||
| + | sta $2502 ; player index 0 | ||
| + | ldy #$25 | ||
| + | jsr $3FFA ; HSCATRCT: A = $FF if this difficulty has no table | ||
| + | jsr RestoreGameScreen ; DMA, DPP, CHARBASE, palettes, controller ports | ||
| + | .done | ||
| + | rts | ||
| + | </pre> | ||
| − | + | === Game Over === | |
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | + | Don't gate HSCENTER on HSCSTAT: HSCSTAT reports <code>$FF</code> until a table exists (bug 10). HSCENTER does its own ranking and simply shows the table when the score doesn't qualify. | |
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | . | ||
| − | |||
| − | |||
| − | |||
| − | |||
<pre lang="asm"> | <pre lang="asm"> | ||
| − | |||
| − | |||
| − | |||
SubmitScore: | SubmitScore: | ||
| − | + | jsr DetectHSC | |
| − | + | bcc .done | |
| − | + | jsr CopyArgs | |
| − | + | lda CurrentDifficulty | |
| − | + | asl | |
| − | + | asl | |
| − | + | ora CurrentPlayer ; 0 or 1 | |
| − | + | sta $2502 | |
| − | + | ldy #$25 | |
| − | + | jsr $3FFD ; HSCENTER: A = $01 if inserted | |
| − | + | jsr RestoreGameScreen | |
| − | + | .done | |
| − | + | rts | |
| − | |||
| − | |||
| − | |||
| − | ; | ||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | + | ReadTopScore: ; e.g. for an on-screen "HI" display | |
| − | + | jsr CopyArgs | |
| − | + | lda #0 | |
| − | + | sta $250E ; no hold, or the game's frame freezes | |
| + | ldy #$25 | ||
| + | jsr $3FF7 ; HSCSTAT | ||
| + | ; $250F-$2512 = top score in BCD; $2504/$2505 overwritten, so rebuild the block before the next call | ||
| + | rts | ||
</pre> | </pre> | ||
Latest revision as of 18:54, 2 October 2026
Contents
- 1 Atari 7800 High Score Cartridge (HSC) Technical Reference
Atari 7800 High Score Cartridge (HSC) Technical Reference
The Atari 7800 High Score Cartridge (HSC) is an expansion peripheral that provides battery-backed RAM (NVRAM) and a standard high score service for Atari 7800 games. It uses a pass-through design: the HSC plugs into the 7800 cartridge slot, and the game cartridge plugs into the top of the HSC.
This page covers both the game-facing interface and the internals of the ROM (highscre.bin, 4096 bytes, MD5 c8a73288ab97226c52602204ab894286). Internal addresses below refer to that ROM. The 2026 community disassembly (7800 HSC CART) reassembles byte-identical with DASM, and its labels are used here.
System Architecture & Memory Map
Hardware Memory Map
| Address Range | Size | Device | Description |
|---|---|---|---|
$1000–$17FF |
2 KB | Battery-backed SRAM | Owner name, a 69-slot directory and 69 score tables. |
$3000–$3FFF |
4 KB | HSC ROM | API, screen builders, font, strings and jump vectors. |
The ROM has no 6502 reset or interrupt vectors of its own. It only runs when a game calls into it.
ROM Layout
| Range | Contents |
|---|---|
$3000–$38FF |
Code (vectors' bodies, initialization, ranking, display-list builders, text entry, menu) |
$3900–$3F2B |
Font: one 44-byte glyph row per page ($3F00 = top row … $3900 = bottom row). Offsets $2C–$FF of each page hold more code and data
|
$3D2C |
Palette values for P4C2…P0C2 ($9D $0A $96 $98 $FA, stored in reverse)
|
$3D31–$3DCB |
Encoded DLL streams for each screen (3 bytes per zone; the middle byte is ignored and replaced by page N+1) |
$3E2C–$3EC6 |
Display-list templates for the table, personalization and menu screens |
$3EC7–$3EDE |
Powers of ten, 10,000,000 down to 1, as 24-bit little-endian values |
$3F2C–$3FC3 |
Strings: CONGRATULATIONS PLAYER 1, CARTRIDGE/PERSONALIZE/YOUR ATARI HIGH SCORE, and the menu text (WHAT WOULD YOU LIKE TO CLEAR?, THIS GAME, PERSONALIZATION, ALL GAMES, NOTHING, ARE YOU SURE?, YES; "NO" is the first two letters of NOTHING)
|
$3FEB–$3FFF |
Seven JMP vectors
|
Zero-Page Footprint ($80–$BC)
The HSC uses zero page $80–$BC (61 bytes) while a call runs.
- Preservation: on entry,
HSCInitRoutine($3483) copies$80–$BCto the caller's page N at+$13..+$4F, in reverse order ($BC→+$13…$80→+$4F).RestoreVarsAndRegsAndExit($3C46) copies it back. - $94/$95: these hold the HSC's pointer to page N during the call, so their original values are parked in NVRAM
$1000/$1001before the backup and restored last. - The first 18 bytes,
$80–$91, are a working copy of the argument block (+$00..+$11).
Game code that runs during a call (the sound hook, a custom controller driver, a DLI handler) sees the HSC's values in $80–$BC, so it must not write there.
Dual-Page RAM Requirement (512 Contiguous Bytes)
Every call needs a page-aligned 512-byte RAM work area: page N (passed in Y) and page N+1.
Page N: +$00-$11 Argument block (inputs) +$12 HSCSTAT result byte +$13-$4F Zero-page backup ($BC..$80) +$50-$8B Formatted table: 5 rows x 12 bytes (3 initials + 9 score characters) +$8C-$A5 Game name (26) +$A6-$BF Difficulty name (26) +$C0-$D8 "CONGRATULATIONS PLAYER n" +$DA-$F9 Owner name (32) (the personalization and menu screens reuse +$8C onwards for their own strings) Page N+1: +$00-$86 Display lists (screen-specific templates, patched at run time) +$87-... Display List List (DPPL = $87, DPPH = page N+1)
Reserve both pages. Display calls build their DLs and DLL in page N+1 and point MARIA there. A game that reserves only 256 bytes gets the following page overwritten. HSCSTAT also overwrites page N +$50..+$8B.
Cartridge Detection
Check the two signature bytes, which are the bottom rows of glyphs 'A' and 'E', before calling any vector:
DetectHSC: ; carry set = HSC present
lda $3900
cmp #$C6
bne .no
lda $3904
cmp #$FE
bne .no
sec
rts
.no clc
rts
Variants seen in games: lda #$C6 / cmp $3900 (Asteroids Deluxe), lda $3900 / eor #$C6 (current 7800basic), and lda $1002 / cmp #$68 (old 7800basic, which checks the NVRAM magic instead).
Public Vector Table & API
| Vector | Name | Target | Function ID ($1007) |
Description | Result in A |
|---|---|---|---|---|---|
$3FEB |
HSCSETRS | $39A9 |
— | Sets CTRL $4B, BACKGRND $00, P0C2–P4C2, CHARBASE $39. No argument block, no hold |
undefined |
$3FEE |
HSCCPYFM | $3000 |
$EF |
Copies the 25-byte table to the buffer pointed to by arg +$0F/$10 |
$00; $EF if no table (nothing copied)
|
$3FF1 |
HSCCPYTO | $3011 |
$04 |
Copies 25 bytes from arg +$0F/$10 into the table, creating it if needed |
$01
|
$3FF4 |
HSCENTER2 | $3127 |
$01 |
Like HSCENTER but takes the initials from arg +$0F..+$11. Not headless: it still shows the congratulations table |
$01 if inserted, $00 if not
|
$3FF7 |
HSCSTAT | $3022 |
$02 |
Query: rank, top score, number of tables. Never creates a table or updates LRU ages | $00
|
$3FFA |
HSCATRCT | $30DA |
$FF (then $FD) |
Shows one game/difficulty table (it doesn't cycle through difficulties) | $00; $FF if no table (returns at once)
|
$3FFD |
HSCENTER | $312B |
$00 |
Ranks the score; if it qualifies, inserts it and runs interactive initials entry | $01 if inserted (also after a cancel), $00 if not
|
Calling Conventions
- Y: high byte of page N.
- X: ignored.
HSCInitRoutinealways uses offset$00. - On return: I flag set (
SEIis never undone), D clear, stack balanced. X/Y undefined. - Return path:
HSCInitRoutineslips two bytes,$3Cand$2B, under its own return address. When the vector's body finishes withRTS, control goes to$3C2C, the common display hold. That code pushes the result, runs the hold, and leaves throughRestoreVarsAndRegsAndExit, which pulls the result into A and returns to the game. So every vector except HSCSETRS ends in the hold, including HSCSTAT and the copy calls.
Common Entry Processing (HSCInitRoutine, $3483)
- Store the function ID at
$1007, thenSEI,CLD. - Display vectors (IDs other than
$02,$EFand$04): CTRL =$7F(DMA off), AUDV0 = AUDV1 = 0. - Park
$94/$95in$1000/$1001and back up the zero page. - Read SWCHB into
$BCand write$FFto SWCHB, which drives any output bits high and forces one-button joystick mode. The saved value is written back on exit. - Copy argument bytes
+$00..+$11to$80..$91. Player index =+$02 & 3→$A9. - If the name-pointer high byte and the logo-pointer high byte are both 0, overwrite
$80–$8Ewith the defaults at$39EF:$80 $01 $F1 $01 $79 $E9 $00 $1A $4C $00 $00 $00 $FF $3C $0A. That's ID$80/$01, difficulty$F0, player 2, joystick, name at$E979, difficulty at$1A00, score at$004C, no logo, sound hook$3CFF(anRTS), 10-second hold. The purpose of this set is unknown. - Do the stack trick described above.
- Display vectors: load the palette and CHARBASE (
HSCSETRS2,$39AD). - Check the NVRAM magic (
$3512). If it fails, check again (one retry), and if it still fails, format ($3535). Formatting writes the magic, sets every slot's difficulty and age to$7F($3FCF), and then, unless the call is HSCCPYFM or HSCCPYTO, runs personalization ($3C95) and turns DMA off. - Convert the game's BCD score to binary (
$3A81, see Score conversion). This runs for every vector. - Look up the directory (
$3572) and set$A6/$A7to the table address.
User Argument Block (Page N)
| Offset | Size | ZP copy | Field | Description |
|---|---|---|---|---|
+$00 |
1 | $80 |
Game ID 1 | Bit 7 set: the game passes its score ÷ 10. The table shows an extra trailing 0 and the top score returned by HSCSTAT is in the game's units |
+$01 |
1 | $81 |
Game ID 2 | |
+$02 |
1 | $82 |
Difficulty / player | Bits 7–2: difficulty (one table per value). Bits 1–0: player index. 0 = left port (SWCHA high nibble, INPT4); 1–3 = right port (SWCHA low nibble, INPT5). Shown as "PLAYER index+1" |
+$03 |
1 | $83 |
Controller type | $02 = custom driver at the name pointer. Any other value = joystick ($00 and $01 behave the same)
|
+$04 |
2 | $84 |
Game name pointer | Exactly 26 characters, read raw and drawn centered (pad with $1D). With type $02, also the driver entry point. Overwritten by HSCSTAT
|
+$06 |
2 | $86 |
Difficulty name pointer | 26 characters. High byte 0 = no difficulty line (the 26 bytes are still copied from $00xx, see bugs)
|
+$08 |
2 | $88 |
Score pointer | 4-byte packed BCD, big-endian (1,234,560 = $01 $23 $45 $60). Read on every call, and written to by the clamp
|
+$0A |
2 | $8A |
Logo DLL pointer | High byte 0 = show the game name as text |
+$0C |
2 | $8C |
Sound hook | Called once per frame during the hold and during initials entry. Must be valid |
+$0E |
1 | $8E |
Hold time | Seconds (60-frame units) of display hold before returning. 0 = none |
+$0F |
2 | $8F |
Buffer pointer / initials | HSCCPYFM/HSCCPYTO: 25-byte buffer pointer. HSCENTER2: initials 1 and 2 |
+$11 |
1 | $91 |
Initial 3 | HSCENTER2 only |
+$12 |
1 | — | (result) | Written by HSCSTAT |
HSCSTAT Results
| Offset | Result |
|---|---|
+$04 |
Number of directory slots with this game's ID 1 and ID 2 and difficulty ≠ $7F, i.e. how many difficulty tables exist (0–69)
|
+$05 |
Rank: $00 = 1st place … $04 = 5th, $FF = doesn't qualify
|
+$0F..+$12 |
Current 1st-place score as 4-byte packed BCD, big-endian, in the game's units |
- A score of 0 never qualifies (
IsScoreNonZero,$3DDC). - A score equal to an existing entry ranks above it.
- If the game/difficulty has no table,
+$05is$FFfor any score and+$0F..+$12are 0. So a game that calls HSCENTER only when HSCSTAT reports a qualifying rank can never create its first table. - How the top score is produced: HSCSTAT formats the table as text (
FormatAllScoresForDisplay,$3769), then packs the eight digit characters of row 1 ($30A9) back into BCD, treating spaces as 0.
Sound Hook
The hook is reached by JSR $36DB, which does JMP (SOUNDPTR), so the hook returns with RTS. It may clobber A, X and Y. It's called during the hold (but not while paused) and during initials entry. It isn't called during personalization or the menu.
Custom Controller Driver ($03 = $02)
Reached by JMP ($84) ($39A6) from a subroutine call, with X = player index. It must return:
- A bits 7–4 = right, left, down, up (0 = pushed)
- Y bit 7 = fire (0 = pressed)
Driver input is used for text entry, the hold's exit check and fire-release waits. The hold's raw checks (INPT4 and any SWCHA bit) are skipped for type $02. The screen saver ($392C) and the maintenance menu still read the real ports. Because the name pointer doubles as the driver address, a driver game should supply a logo, or its code bytes get drawn as the game name.
Logo DLL
The decoder ($36DE) copies the game's 3-byte DLL entries verbatim to the start of the DLL. It keeps a running total of zone heights ((byte0 & $0F) + 1) and stops after the entry that brings the total to at least 65 lines ($41, when a difficulty name is present) or 79 lines ($4F, when it isn't).
DLI handling:
- If the first logo entry has its DLI bit set,
$B9is set to$40. -
SetupDLL($3715) shifts$B9left for every HSC entry flagged with a DLI. The first such entry keeps its DLI bit; all later ones have it stripped. - Without a logo DLI, every HSC DLI bit is stripped.
The one kept DLI lets the game's NMI handler restore the HSC's CHARBASE and palette after a logo that changed them.
Data Representations
Character Set & Font
The HSC uses its own character codes, often called Watkins encoding. Letters are 'X' - 'A'.
| Code | Glyph | Code | Glyph | Code | Glyph |
|---|---|---|---|---|---|
$00–$19 |
A–Z | $1D |
(space) | $21–$2A |
0–9 |
$1A |
_ (underscore: full-width bar on the bottom row) | $1E |
' (apostrophe) | $2B |
? |
$1B |
. (period) | $1F |
█ (solid block: END in text entry) | ||
$1C |
- (hyphen) | $20 |
← (left arrow: BACKSPACE in text entry) |
There's no colon.
Glyphs are 8×7 pixels, drawn in 320A mode with CHARBASE $39. Row r of glyph c (r = 0 at the top) is at $3900 + (6−r)×$100 + c.
DASM macro:
mac WATKINS
dc.b {1}-"A"
endm
Packed Initials (15 bits)
Byte 0: [ C1.4 | C1.3 | C1.2 | C0.4 | C0.3 | C0.2 | C0.1 | C0.0 ]
Byte 1: [ C1.1 | C1.0 | 0 | C2.4 | C2.3 | C2.2 | C2.1 | C2.0 ]
byte0 = ((c1 >> 2) << 5) | c0 c0 = byte0 & $1F
byte1 = ((c1 & 3) << 6) | c2 c1 = ((byte0 >> 5) << 2) | (byte1 >> 6)
c2 = byte1 & $1F
- Packing:
EncodeAndSaveInitialsToTable,$31D7. Unpacking:$3AD6. - Empty entries hold
___(three underscores,$1A $1A $1A), packed as$DA $9A. - Other examples:
ABC=$00 $42,XYZ=$D7 $19.
Score conversion
Games pass 8-digit packed BCD. NVRAM holds a 24-bit binary value, little-endian.
- Clamp (
$3A83): the score is compared byte by byte with$16 $77 $72 $15(16,777,215). If it's larger, the maximum is written back into the game's score and used instead. The comparison is signed, see bugs. - Convert (
$3AA8): for each nibble, the value is multiplied by 10 ($3A5E, a full 24-bit ×10) and the nibble is added. The result goes in$AD(LSB),$AE,$AF(MSB). The nibble add has a carry bug, see bugs. - Display (
$37BC): the value is turned into decimal by repeated subtraction of the powers of ten at$3EC7, giving a 9-character field:- ID1 bit 7 clear: a leading space, then 8 digits with leading zeros blanked. The last digit always shows.
- ID1 bit 7 set: 8 digits with leading zeros blanked, then a final
0.
Cartridge NVRAM Layout ($1000–$17FF)
| Address | Size | Disassembly label | Contents |
|---|---|---|---|
$1000–$1001 |
2 | CartRAMSaveZ94/Z95 |
Caller's $94/$95 during a call
|
$1002–$1006 |
5 | CartRAMMagic1..5 |
$68 $83 $AA $55 $9C = formatted
|
$1007 |
1 | CartRAMEntryID |
Function ID of the call in progress ($FD/$FE/$FF during attract-style displays)
|
$1008–$1027 |
32 | CartRAMOwnerName |
Owner name. Bytes after the length are leftover; the END code $1F usually follows the last character
|
$1028 |
1 | CartRAMOwnerNameLength |
1–32 |
$1029–$106D |
69 | CartRAMGameID1Table |
Game ID 1 per slot |
$106E–$10B2 |
69 | CartRAMGameID2Table |
Game ID 2 per slot |
$10B3–$10F7 |
69 | CartRAMGameDifficultyTable |
Difficulty (& $FC) per slot. $7F = free
|
$10F8–$113C |
69 | CartRAMGameAgeTable |
LRU age per slot. $7F = free
|
$113D–$17F9 |
1725 | — | 69 tables × 25 bytes; slot s at $113D + 25·s
|
$17FA–$17FE |
5 | CartRAMSavedEntry |
Copy of the displaced 5th-place entry during HSCENTER, used to undo a cancel |
$17FF |
1 | — | Unused |
Formatting only writes the magic and the $7F markers. IDs, tables and the owner name keep whatever was there.
Score Tables
Each table holds 5 entries of 5 bytes, 1st place first: packed initials (2 bytes), then the score as 24-bit little-endian binary (3 bytes). A new table is 5 × ($DA $9A $00 $00 $00).
Directory Lookup & LRU
Lookup ($3572):
- Scans slots 68 down to 0 for a slot whose ID 1, ID 2 and difficulty (
arg+$02 & $FC) all match. A free slot can't match, because$7Fhas its low bits set. - While scanning, it tracks the slot with the largest age (signed compare, strictly greater). That slot is the eviction victim: free slots (
$7F) win, and on ties the highest slot number wins.
Touch ($35A1), done by every call except HSCSTAT:
- Let old = the slot's age. Set its age to 0.
- Then increment every age that is less than old (signed), the touched slot included.
- So the most recently used slot ends with age 1, not 0. In-use ages stay distinct from 1 upward, and free slots stay
$7F.
Not found ($35E0):
- Negative function IDs (HSCATRCT
$FF, HSCCPYFM$EF) drop the call's return address, store the ID in the hold timer, and return the ID straight away, with no hold. DMA was already turned off and audio muted for HSCATRCT. - HSCSTAT reports "no table".
- Everything else creates a table (
$35F4) in the victim slot, so when all 69 slots are in use the least recently used game is evicted.
Interactive Behavior
Display Hold ($3C2C)
- The hold counts down
arg+$0Eseconds. Each second is 60 passes, and each pass waits one vertical blank, checks input, and calls the sound hook. - The input check (
$3C63) ends the hold on any of:- P1 fire, or any SWCHA bit (both of these are skipped for driver games)
- RESET or SELECT
- the selected controller (joystick or driver)
- Pause (
$3B51), triggered by SWCHB bit 3 low on two reads two frames apart:- Wait for release, mute audio, and stop calling the hook.
- While paused, the exit inputs above still work. Another PAUSE press resumes the countdown.
- Holding PAUSE and pressing SELECT (
$3B71) opens the maintenance menu. - After about 233×256 frames (roughly 16 minutes) paused, the screen saver (
$392C) turns DMA off until any input.
Initials Entry ($3242)
Setup. The new score has already been inserted: lower entries shifted down, and the old 5th place saved at $17FA. The inserted row still shows the displaced entry's initials.
The cursor is a separate one-character DL object on the row. It blinks every 30 frames by toggling palette bit 2 (P0 ↔ P4). It starts at position 0, showing the character already there.
Stepping through characters. The stick is sampled every 10 frames:
- Up/Right = next: A…Z, _, ., -, space, ', █, ←, then A. At position 0 the ← is skipped.
- Down/Left = previous, the same cycle backwards.
- Pressing Next on
_as the first move at a position jumps straight to 'A'. - Position 3 offers only █ (END) and ← (BACKSPACE).
Fire is checked every frame and acts after release:
- On a character: accept it and move right.
- On ←: move left. The ← glyph stays drawn in the position left behind.
- On █: finish.
- RESET or SELECT finishes at the current position.
Finishing:
- At position 0, finishing cancels: the table is shifted back and the old 5th place restored from
$17FA. The routine still falls through into the packing code, which rewrites the restored entry's initials unchanged. - At positions 1–2, the remaining initials are filled with spaces.
There's no time-out. A screen saver kicks in after about 15 minutes (233×233 frames) without input.
Personalization ($3C95)
Shown on first use and from the menu. The screen reads PERSONALIZE / YOUR ATARI HIGH SCORE / CARTRIDGE, and an entry line underneath stays centered as it grows. Entry rules:
- Up to 32 characters, same character cycle as the initials, no digits.
- It always uses the left joystick and has no console abort.
- Each new position starts on the previous character.
- █ (END) finishes. An empty name becomes one space.
- Before entry starts, the owner name in NVRAM is set to
A T A R I(length 9) in case power is lost mid-entry.
Maintenance Menu ($3B7F)
The menu reads WHAT WOULD YOU LIKE TO CLEAR?, with four choices: THIS GAME, ALL GAMES, PERSONALIZATION, NOTHING. The selection starts on NOTHING, and Up/Down move it, wrapping around. Fire brings up ARE YOU SURE? YES NO: Left/Right choose, starting on NO, and NO returns to the menu.
What each choice does after YES (dispatcher $39C8):
- THIS GAME: frees the current game/difficulty slot only (
$3FC4). The IDs and score bytes stay behind. - ALL GAMES: frees all 69 slots. The owner name is kept.
- PERSONALIZATION: runs the name entry.
- NOTHING: leaves the HSC, returning the original result to the game.
The first three return to the menu afterwards. The menu has no time-out, no console exit and no sound hook, and it always uses the left joystick.
Screen Layout & Colors
Colors: BACKGRND $00, P0C2 $FA (headers, cursor), P1C2 $98 (scores and initials of rows 2–5), P2C2 $96 (rank digits 2–5), P3C2 $0A (game, difficulty and owner names), P4C2 $9D (1st-place row, cursor blink).
The table screen, top to bottom:
- Header: 10+16+16 blank lines, the game name (26 characters at x = 56 in 320 mode), then 16 blank lines. A game logo replaces this.
- Difficulty (optional): 7 blank lines, then the difficulty name.
- Entry layout only: 16 blank lines,
CONGRATULATIONS, 7 blank lines,PLAYER n. - Table: 16 blank lines, the owner name (centered), then five rows. Each row is a rank digit at x = 100, the 9-character score at x = 116 and the initials at x = 196, separated by 7-line gaps.
- Blank 7-line zones pad the DLL to at least 243 lines.
The community "PAL hack" (PALHACK in the disassembly) only lengthens the gap zones.
Bugs and Quirks in the HSC ROM
| # | Where | Behavior | Effect |
|---|---|---|---|
| 1 | $3ABB, $3ACC |
Carry lost in BCD→binary conversion. After each nibble is added to the low byte, the carry goes into the middle byte with ADC #$00 but never on into the high byte ($AF). |
Whenever the running value crosses a multiple of 65,536 during a nibble add, 65,536 is lost. 65,536 → 0, 131,072 → 65,536, 655,360 → 0, which can then never rank. 14 multiples of 10 below 2,000,000 are affected. |
| 2 | $3A8B–$3A91 |
Signed clamp compare. CMP then BMI/BPL tests the sign of (limit − score) instead of the carry. |
First byte $97–$99 (scores ≥ 97,000,000) isn't clamped, and neither is $16 $77 $72 followed by $96–$99. Those values wrap modulo 2^24.
|
| 3 | $3A93 |
The clamp writes the maximum back into the game's score bytes, on every vector, including HSCATRCT and the copy calls. | A game showing its score after an HSC call shows 16,777,215. |
| 4 | $31D7 |
HSCENTER2 initials aren't masked to 5 bits. | A value above $1F in initial 1 or 3 corrupts initial 2's packed bits.
|
| 5 | $36AB |
The 26-byte difficulty-name copy runs even when its pointer's high byte is 0. | Reads zero page and TIA/MARIA addresses. Harmless on stock hardware. |
| 6 | $3C2D |
The hold timer is decremented and tested with BMI. |
Hold values $81–$FF behave like 0; $80 holds for 128 s.
|
| 7 | $3318 |
BACKSPACE stores the ← glyph in the position it leaves. | The ← stays on screen, and moving right again starts on ←. |
| 8 | $392C |
The screen-saver wake-up reads the raw ports. | Driver-based controllers can't wake the screen. |
| 9 | $392C |
The screen saver ends with SWACNT = SWBCNT = 0. | A game's two-button-mode setup is lost after a long idle period. |
| 10 | $3022 |
HSCSTAT reports $FF when no table exists, even for scores HSCENTER would accept. |
Games must call HSCENTER for any nonzero score to get their first table. |
| 11 | $35A1 |
The touch loop also increments the slot it just zeroed. | The most recently used age is 1, not 0. |
| 12 | $3C95 |
First-use formatting during HSCSTAT runs the personalization screen and returns with DMA off. | Games that call HSCSTAT at boot show a naming screen on a fresh cart. |
| 13 | $3242 |
Fire isn't required to be released before entry starts. | A button still held from gameplay accepts the first initial on release. |
| 14 | $36DE, $3715 |
$B9 is both the logo-DLI flag and the screen-saver timer. |
Harmless; worth knowing when reading the code. |
| 15 | $3F6E |
A stray letter 'A' between two strings is never referenced. | None. |
7800 Assembly Integration Example
Argument Block Template
HscRomTemplate:
.byte $8B, $23 ; game ID 1 and 2
.byte $00 ; difficulty << 2 | player (filled in at run time)
.byte $00 ; controller type: joystick
.word HscTitleText ; 26-character game name
.word HscDiffText ; 26-character difficulty name (or high byte 0)
.word GameScoreBcd ; 4-byte packed BCD score in RAM
.word 0 ; no logo: show the name as text
.word GameSoundCallback ; must be valid; point at an RTS if unused
.byte $05 ; hold time in seconds
HscTitleText: ; exactly 26 characters, padded to center "MY GAME"
.byte $1D,$1D,$1D,$1D,$1D,$1D,$1D,$1D,$1D,$1D
WATKINS "M"
WATKINS "Y"
.byte $1D
WATKINS "G"
WATKINS "A"
WATKINS "M"
WATKINS "E"
.byte $1D,$1D,$1D,$1D,$1D,$1D,$1D,$1D,$1D
Attract Mode
ShowHighScores:
jsr DetectHSC
bcc .done
jsr CopyArgs ; copy the 15-byte template to $2500 (page N = $25)
lda CurrentDifficulty
asl
asl
sta $2502 ; player index 0
ldy #$25
jsr $3FFA ; HSCATRCT: A = $FF if this difficulty has no table
jsr RestoreGameScreen ; DMA, DPP, CHARBASE, palettes, controller ports
.done
rts
Game Over
Don't gate HSCENTER on HSCSTAT: HSCSTAT reports $FF until a table exists (bug 10). HSCENTER does its own ranking and simply shows the table when the score doesn't qualify.
SubmitScore:
jsr DetectHSC
bcc .done
jsr CopyArgs
lda CurrentDifficulty
asl
asl
ora CurrentPlayer ; 0 or 1
sta $2502
ldy #$25
jsr $3FFD ; HSCENTER: A = $01 if inserted
jsr RestoreGameScreen
.done
rts
ReadTopScore: ; e.g. for an on-screen "HI" display
jsr CopyArgs
lda #0
sta $250E ; no hold, or the game's frame freezes
ldy #$25
jsr $3FF7 ; HSCSTAT
; $250F-$2512 = top score in BCD; $2504/$2505 overwritten, so rebuild the block before the next call
rts