Difference between revisions of "Langenscheidt Protection"
| Line 1: | Line 1: | ||
| − | |||
| − | |||
==== Pages that refer to this protection ==== | ==== Pages that refer to this protection ==== | ||
{{Special:Whatlinkshere/Langenscheidt Protection}} | {{Special:Whatlinkshere/Langenscheidt Protection}} | ||
| + | |||
| + | = Langenscheidt Protection = | ||
| + | |||
| + | The '''Langenscheidt Protection''' | ||
| + | is a Commodore 64 disk scheme found on Langenscheidt educational software | ||
| + | (confirmed on ''[[Wörter Rennen mit System]]'' and ''[[Jagd auf Vokabeln]]''). | ||
| + | It is the disk's own native storage format: a fully | ||
| + | custom raw-GCR encoding used for essentially the whole disk, with a small | ||
| + | hand-built filesystem (name catalog, page-ownership table) replacing CBM | ||
| + | DOS's own BAM/directory conventions entirely. Reading such a disk with | ||
| + | standard tools decodes almost nothing. | ||
| + | |||
| + | The findings below extend the analysis with a full sector decode algorithm | ||
| + | (validated byte-for-byte against a known worked example), a commented | ||
| + | disassembly of every key routine, and a complete description of the | ||
| + | on-disk filesystem, including the exact mechanism that resolves a | ||
| + | catalog entry to precise byte boundaries on disk. | ||
| + | |||
| + | == Mechanism == | ||
| + | |||
| + | # The disk boots via a short C64-side autostart stub that immediately hands off to the drive with a single, standard KERNAL <code>M-E $07B0</code> command (Memory-Execute) — confirmed to be the '''only''' KERNAL/IEC command used in an entire session, by a full-session trace that logged <code>CIOUT</code> calls from boot through menu interaction and found exactly five, spelling <code>M-E</code> + <code>$07B0</code> and nothing else, ever again. | ||
| + | # From that point on, the drive-resident program (itself streamed down from disk in 256-byte pages immediately after the handoff) drives '''everything''' — including standard 1541 DOS ROM job-dispatch calls it invokes internally — over a private, raw bit-banged protocol on CIA2 Port A (<code>$DD00</code>), completely bypassing the KERNAL serial routines for the rest of the session. | ||
| + | # Individual disk sectors use a custom 6-bit-to-8-bit run-length-limited GCR alphabet (64 valid byte values, no more than 2 consecutive zero bits), not the standard C64 5-bit GCR code. Every data sector carries a whole-payload running-XOR checksum spanning all 343 raw bytes. | ||
| + | # Files are identified '''by name''', through a small hand-built catalog filesystem (fixed 16-byte name records, linearly scanned) — not by a fixed per-menu-option track number. Content offsets are resolved via a second on-disk table (the page-ownership table) that gives each file's '''exact''' length in 256-byte pages, letting every file be extracted byte-perfect. See "File system structure" below. | ||
| + | |||
| + | == Basic sector structure == | ||
| + | |||
| + | === Sync-length pattern and marker bytes === | ||
| + | |||
| + | Every custom sector — header or data — starts the same way: a standard | ||
| + | sync run, followed immediately by a single marker byte identifying the | ||
| + | block type, decoded straight from a <code>g64conv <image> <out> 5</code> | ||
| + | raw-byte dump with no GCR interpretation needed (the marker and payload | ||
| + | bytes are literal members of the custom 64-value alphabet, not standard | ||
| + | 5-bit GCR nibbles): | ||
| + | |||
| + | <pre> | ||
| + | sync NN | ||
| + | ; Following raw bytes: 73 <skip> <id> 55 55 55 55 55 55 55 55 55 55 ff <- HEADER | ||
| + | ^^ $73 = header marker ^^ padding/gap bytes to next sync | ||
| + | |||
| + | sync NN | ||
| + | ; Following raw bytes: 6a <343 payload bytes...> <- DATA | ||
| + | ^^ $6A = data marker | ||
| + | </pre> | ||
| + | |||
| + | '''Header block''' (14 raw bytes total, only the first 3 meaningful): | ||
| + | byte 0 is always <code>$73</code>; byte 1 is an unchecked "skip" byte | ||
| + | (typically <code>$4D</code> in practice, but never validated by the | ||
| + | drive code); byte 2 is the '''sector-ID byte''', translated through the | ||
| + | same 64-entry table used for data (see below) to give a plain 0-15 | ||
| + | sector number. | ||
| + | |||
| + | '''Data block''' (344 raw bytes total): byte 0 is always <code>$6A</code>; | ||
| + | the following 343 bytes are the sector's translated, checksummed payload | ||
| + | (see "Sector decode algorithm" below) — 256 main bytes, 86 tail bytes, | ||
| + | and 1 running-checksum byte, laid out so that decoding and checksumming | ||
| + | happen in a single pass. | ||
| + | |||
| + | === Table 1 — the 64-value GCR alphabet === | ||
| + | |||
| + | 64 native on-disk bytes (raw floppy alphabet), each a run-length-limited | ||
| + | value (no more than 2 consecutive zero bits) standing in for a 6-bit | ||
| + | value 0-63. Sector-header IDs 0-15 reuse the '''same''' table directly — | ||
| + | that is why <code>$4A</code> through <code>$66</code> below double as | ||
| + | both "6-bit value 0-15" and "header sector ID 0-15": | ||
| + | |||
| + | <pre> | ||
| + | idx 0 1 2 3 4 5 6 7 idx 8 9 10 11 12 13 14 15 | ||
| + | 4A 4B 4D 4E 52 53 55 56 57 59 5A 5B 5D 5E 65 66 | ||
| + | |||
| + | idx 16 17 18 19 20 21 22 23 idx 24 25 26 27 28 29 30 31 | ||
| + | 67 69 6A 6B 6D 6E 72 73 75 76 77 79 7A 7B 7D 7E | ||
| + | |||
| + | idx 32 33 34 35 36 37 38 39 idx 40 41 42 43 44 45 46 47 | ||
| + | 95 96 97 9A 9B 9D 9E A5 A6 A7 A9 AA AB AD AE B2 | ||
| + | |||
| + | idx 48 49 50 51 52 53 54 55 idx 56 57 58 59 60 61 62 63 | ||
| + | B3 B5 B6 B7 B9 BA BB BD BE CA CB CD CE D2 D3 D5 | ||
| + | </pre> | ||
| + | |||
| + | The drive-resident code loads this table once at boot and builds a | ||
| + | '''sparse reverse table''' at <code>$0100</code>-<code>$01FF</code> | ||
| + | (<code>$0100 + native_byte -> 6-bit value</code>), used for every | ||
| + | subsequent decode: | ||
| + | |||
| + | <pre> | ||
| + | .8:079a A2 00 LDX #$00 | ||
| + | .8:079c B4 64 LDY $64,X ; Y = native byte from Table 1 at offset X | ||
| + | .8:079e 8A TXA | ||
| + | .8:079f 99 00 01 STA $0100,Y ; reverse_table[native_byte] = X (the 6-bit index) | ||
| + | .8:07a2 E8 INX | ||
| + | .8:07a3 E0 40 CPX #$40 ; 64 entries | ||
| + | .8:07a5 90 F5 BCC $079C | ||
| + | </pre> | ||
| + | |||
| + | === The directory track is genuinely mixed-format, not entirely custom === | ||
| + | |||
| + | Track 18 is a real exception: it carries a small number of ordinary | ||
| + | standard-CBM-format sectors (4 confirmed: sectors 0, 1, 8, 11 — plus one | ||
| + | further standard-format header observed with no matching data block | ||
| + | behind it) alongside custom sectors. Every '''other''' track has zero standard-format sectors and a | ||
| + | full complement of custom ones — confirmed directly and used as the | ||
| + | detection signal for "which tracks are part of the custom content | ||
| + | addressing scheme" (see "File system structure" below): a track with | ||
| + | '''zero''' custom sync hits is excluded from the sequential numbering, | ||
| + | while every genuine content track has exactly 16. | ||
| + | |||
| + | == Two-stage bootstrap: commented disassembly == | ||
| + | |||
| + | === Stage 1 — C64 autostart hands off to the drive === | ||
| + | |||
| + | The very first thing the C64-side autostart code does is send a literal | ||
| + | <code>M-E $07B0</code> command over the '''standard''' KERNAL | ||
| + | <code>CIOUT</code> — the only time the whole session ever uses it: | ||
| + | |||
| + | <pre> | ||
| + | .C:0334 A9 4D LDA #$4D ; 'M' | ||
| + | .C:0336 20 A8 FF JSR $FFA8 ; CIOUT | ||
| + | .C:0339 A9 2D LDA #$2D ; '-' | ||
| + | .C:033b 20 A8 FF JSR $FFA8 | ||
| + | .C:033e A9 45 LDA #$45 ; 'E' | ||
| + | .C:0340 20 A8 FF JSR $FFA8 | ||
| + | .C:0343 A9 B0 LDA #$B0 ; target address low byte | ||
| + | .C:0345 20 A8 FF JSR $FFA8 | ||
| + | .C:0348 A9 07 LDA #$07 ; target address high byte -> $07B0 | ||
| + | .C:034a 20 A8 FF JSR $FFA8 | ||
| + | .C:034d 20 FE ED JSR $EDFE ; finish the IEC command (KERNAL UNTALK) | ||
| + | </pre> | ||
| + | |||
| + | === Stage 2 — receiving the rest of the C64-side program === | ||
| + | |||
| + | Immediately after the handoff, the C64 enters a tight receive loop that | ||
| + | pulls the '''entire remainder of the resident program''' down from the | ||
| + | drive as a sequence of 256-byte pages, each preceded by its own 2-byte | ||
| + | destination address — this is how the ~7 KB of C64-side code seen | ||
| + | disassembled throughout this article actually arrives in RAM: | ||
| + | |||
| + | <pre> | ||
| + | .C:0350 A2 FF LDX #$FF | ||
| + | .C:0352 AD 00 DD LDA $DD00 | ||
| + | .C:0355 A0 10 LDY #$10 | ||
| + | .C:0357 CA DEX | ||
| + | .C:0358 D0 FD BNE $0357 | ||
| + | .C:035a 88 DEY | ||
| + | .C:035b D0 FA BNE $0357 ; short settle delay | ||
| + | .C:035d A9 03 LDA #$03 | ||
| + | .C:035f 8D 00 DD STA $DD00 | ||
| + | .C:0362 AD 00 DD LDA $DD00 | ||
| + | .C:0365 CD 00 DD CMP $DD00 | ||
| + | .C:0368 F0 FB BEQ $0365 ; wait for a line transition (drive ready) | ||
| + | .C:036a 20 80 03 JSR $0380 ; receive byte -> destination address low | ||
| + | .C:036d 85 D1 STA $D1 | ||
| + | .C:036f 20 80 03 JSR $0380 ; receive byte -> destination address high | ||
| + | .C:0372 85 D2 STA $D2 | ||
| + | .C:0374 A0 00 LDY #$00 | ||
| + | .C:0376 20 80 03 JSR $0380 ; receive byte -> data | ||
| + | .C:0379 91 D1 STA ($D1),Y | ||
| + | .C:037b 88 DEY | ||
| + | .C:037c D0 F8 BNE $0376 ; 256 bytes per page | ||
| + | .C:037e F0 EA BEQ $036A ; next (address, page) pair | ||
| + | </pre> | ||
| + | |||
| + | <code>$0380</code> is the core '''receive-one-byte''' primitive: a | ||
| + | hand-timed, 2-bits-per-CIA-read bit-banged protocol over | ||
| + | <code>$DD00</code>, entirely independent of the KERNAL: | ||
| + | |||
| + | <pre> | ||
| + | .C:0380 78 SEI | ||
| + | .C:0381 A9 27 LDA #$27 | ||
| + | .C:0383 8D 00 DD STA $DD00 | ||
| + | .C:0386 2C 00 DD BIT $DD00 | ||
| + | .C:0389 50 FB BVC $0386 ; wait for the drive's handshake (CLK IN, bit6) | ||
| + | .C:038b A9 03 LDA #$03 | ||
| + | .C:038d 8D 00 DD STA $DD00 | ||
| + | .C:0390 A2 08 LDX #$08 | ||
| + | .C:0392 CA DEX | ||
| + | .C:0393 D0 FD BNE $0392 ; short delay | ||
| + | .C:0395 A2 04 LDX #$04 | ||
| + | .C:0397 AD 00 DD LDA $DD00 ; sample 2 bits per iteration | ||
| + | .C:039a 0A ASL A | ||
| + | .C:039b 08 PHP | ||
| + | .C:039c 0A ASL A | ||
| + | .C:039d 26 2D ROL $2D | ||
| + | .C:039f 28 PLP | ||
| + | .C:03a0 26 2D ROL $2D | ||
| + | .C:03a2 CA DEX | ||
| + | .C:03a3 D0 F2 BNE $0397 ; 4 iterations x 2 bits = 8 bits total | ||
| + | .C:03a5 A9 17 LDA #$17 | ||
| + | .C:03a7 8D 00 DD STA $DD00 ; release/ack | ||
| + | .C:03aa EA NOP | ||
| + | .C:03ab EA NOP | ||
| + | .C:03ac EA NOP | ||
| + | .C:03ad A5 2D LDA $2D ; assembled byte | ||
| + | .C:03af 60 RTS | ||
| + | </pre> | ||
| + | |||
| + | A live RAM snapshot taken at menu-idle confirms the resulting program | ||
| + | occupies only <code>$0200</code>-<code>$1BFF</code>; everything from | ||
| + | <code>$2000</code> upward is still the untouched C64 power-on RAM test | ||
| + | pattern, confirming the whole resident program is under 7 KB. | ||
| + | |||
| + | == The unified transfer protocol == | ||
| + | |||
| + | <code>$1600</code>-<code>$16CB</code> duplicates the exact primitive | ||
| + | seen in <code>$0380</code>, split into four small reusable entry points | ||
| + | sharing an identical handshake shape: | ||
| + | |||
| + | {| class="wikitable" | ||
| + | ! Address !! Role | ||
| + | |- | ||
| + | | <code>$1600</code> || send one byte, source = <code>($2E),Y</code> (memory to drive) | ||
| + | |- | ||
| + | | <code>$1638</code> || receive one byte, destination = <code>($2E),Y</code> (drive to memory) | ||
| + | |- | ||
| + | | <code>$166A</code> || send one byte, source = accumulator | ||
| + | |- | ||
| + | | <code>$169C</code> || receive one byte, return in accumulator | ||
| + | |} | ||
| + | |||
| + | A live trace confirms this is genuinely the '''only''' transfer layer | ||
| + | used for the rest of the session: even a live-observed menu selection | ||
| + | that triggered a fresh drive-side track seek produced zero further | ||
| + | <code>CIOUT</code> calls — the C64-side command that told the drive | ||
| + | which content to fetch travelled entirely over this custom protocol, | ||
| + | indistinguishable byte-for-byte from ordinary data transfer without | ||
| + | tracing the protocol's own framing (see "The page-ownership table" | ||
| + | below for how that framing was actually decoded). | ||
| + | |||
| + | == Sector decode algorithm == | ||
| + | |||
| + | <code>$0603</code>-<code>$0669</code> is the single routine responsible | ||
| + | for turning 343 raw on-disk bytes (already past the <code>$6A</code> | ||
| + | marker) into 256 real content bytes, '''in one pass''' that | ||
| + | simultaneously computes and verifies the sector's checksum. This is the | ||
| + | routine that gives every custom sector its error-detection property, and | ||
| + | its exact behavior was validated byte-for-byte against a known worked | ||
| + | example (see the note at the end of this section) before being trusted | ||
| + | for the filesystem/extraction work below. | ||
| + | |||
| + | <pre> | ||
| + | ; ---- read 256 main bytes, translate, and running-XOR checksum in place ---- | ||
| + | .8:0603 20 56 F5 JSR $F556 ; wait for sync | ||
| + | .8:0606 50 FE BVC $0606 | ||
| + | .8:0608 AD 01 1C LDA $1C01 ; read raw byte | ||
| + | .8:060b B8 CLV | ||
| + | .8:060c C9 6A CMP #$6A ; must be the data marker | ||
| + | .8:060e D0 F3 BNE $0603 | ||
| + | .8:0610 98 TYA ; Y returned 0 by the sync wait -> A=0 | ||
| + | .8:0611 AA TAX ; X=0 too: running checksum accumulator starts at 0 | ||
| + | .8:0612 50 FE BVC $0612 ; <-- per-byte loop entry | ||
| + | .8:0614 B8 CLV | ||
| + | .8:0615 AC 01 1C LDY $1C01 ; read next raw byte | ||
| + | .8:0618 59 00 01 EOR $0100,Y ; A ^= reverse_table[raw_byte] (running XOR!) | ||
| + | .8:061b 9D 00 02 STA $0200,X ; store the RUNNING accumulator, not the raw lookup | ||
| + | .8:061e E8 INX | ||
| + | .8:061f D0 F1 BNE $0612 ; 256 iterations | ||
| + | |||
| + | ; ---- read 86 more "tail" bytes, same running checksum, stored $A4-$F9 ---- | ||
| + | .8:0621 A2 55 LDX #$55 ; X = 85 | ||
| + | .8:0623 50 FE BVC $0623 | ||
| + | .8:0625 B8 CLV | ||
| + | .8:0626 AC 01 1C LDY $1C01 | ||
| + | .8:0629 59 00 01 EOR $0100,Y | ||
| + | .8:062c 95 A4 STA $A4,X ; X counts 85 downto 0 -> 86 bytes, $A4-$F9 | ||
| + | .8:062e CA DEX | ||
| + | .8:062f 10 F2 BPL $0623 | ||
| + | |||
| + | ; ---- read the final check byte; A must be 0 if every prior byte was correct ---- | ||
| + | .8:0631 50 FE BVC $0631 | ||
| + | .8:0633 B8 CLV | ||
| + | .8:0634 AC 01 1C LDY $1C01 ; read byte 343 (the check byte) | ||
| + | .8:0637 59 00 01 EOR $0100,Y | ||
| + | .8:063a AA TAX ; checksum-good <=> A==0 <=> X==0 here | ||
| + | </pre> | ||
| + | |||
| + | The stored "main" bytes are the '''running XOR prefix''' of each raw | ||
| + | byte's table-translated value, not the plain per-byte lookup — this is | ||
| + | deliberate differential encoding: if the disk's mastering process stored | ||
| + | <code>delta[i] = plaintext[i] XOR plaintext[i-1]</code> (with an implicit | ||
| + | <code>plaintext[-1] = 0</code>) as the 6-bit value that got GCR-encoded, | ||
| + | then this running-XOR-accumulate loop exactly '''recovers''' the | ||
| + | original plaintext byte-for-byte, and the final check byte's own delta | ||
| + | is simply chosen so the whole 343-byte chain's cumulative XOR lands on | ||
| + | zero — a genuine whole-payload checksum, not just a per-header check. | ||
| + | |||
| + | Immediately following, with '''no <code>RTS</code> in between''' (this | ||
| + | is a straight fall-through, always executed, not a separate optional | ||
| + | step), the tail's 86 stored 6-bit values get expanded into the upper 2 | ||
| + | bits of the 256 main bytes, via a 6-and-2 bit-packing scheme — each | ||
| + | 6-bit tail value contributes exactly 2 bits to each of 3 consecutive | ||
| + | main bytes: | ||
| + | |||
| + | <pre> | ||
| + | .8:063b A0 55 LDY #$55 ; Y = 85 downto 1 (85 iterations) | ||
| + | .8:063d B9 A4 00 LDA $00A4,Y ; a 6-bit tail value, bits 5..0 | ||
| + | .8:0640 4A LSR A ; bit0 -> carry | ||
| + | .8:0641 3E 02 02 ROL $0202,X ; main[X+2] = (main[X+2]<<1)|bit0 | ||
| + | .8:0644 4A LSR A ; bit1 -> carry | ||
| + | .8:0645 3E 02 02 ROL $0202,X ; main[X+2] = (main[X+2]<<1)|bit1 (now 8 bits) | ||
| + | .8:0648 4A LSR A ; bit2 | ||
| + | .8:0649 3E 01 02 ROL $0201,X ; main[X+1], same 2-ROL treatment | ||
| + | .8:064c 4A LSR A ; bit3 | ||
| + | .8:064d 3E 01 02 ROL $0201,X | ||
| + | .8:0650 4A LSR A ; bit4 | ||
| + | .8:0651 3E 00 02 ROL $0200,X ; main[X+0] | ||
| + | .8:0654 4A LSR A ; bit5 | ||
| + | .8:0655 3E 00 02 ROL $0200,X | ||
| + | .8:0658 E8 INX ; step 3 bytes per tail value | ||
| + | .8:0659 E8 INX | ||
| + | .8:065a E8 INX | ||
| + | .8:065b 88 DEY | ||
| + | .8:065c D0 DF BNE $063D | ||
| + | ; special-case the 86th (last) tail value into the very last main byte, 2 bits only | ||
| + | .8:065e A5 A4 LDA $A4 | ||
| + | .8:0660 4A LSR A | ||
| + | .8:0661 2E FF 02 ROL $02FF | ||
| + | .8:0664 4A LSR A | ||
| + | .8:0665 2E FF 02 ROL $02FF | ||
| + | .8:0668 18 CLC | ||
| + | .8:0669 60 RTS | ||
| + | </pre> | ||
| + | |||
| + | Each of the 256 <code>$0200</code>-<code>$02FF</code> bytes ends up with | ||
| + | its original 6-bit running-checksum value shifted into bits 2-7, and 2 | ||
| + | more bits from a shared tail value filled in at bits 0-1 — recovering | ||
| + | full 8-bit entropy per byte from a 64-value (6-bit) on-disk alphabet. | ||
| + | |||
| + | '''Validation:''' this exact algorithm, transliterated instruction-for- | ||
| + | instruction into Python, was run against the raw bytes of the | ||
| + | preliminary analysis's own "Sector with Code" example (a capture of | ||
| + | sector 6, known independently to become the second-stage loader code at | ||
| + | <code>$0300</code>) and produced an '''exact byte-for-byte match''' to | ||
| + | the already-known plaintext (<code>A2 FF 9A 20 00 06 A2 00 BD 00 02 9D | ||
| + | 00 04 ...</code>, i.e. <code>LDX #$FF; TXS; JSR $0600; LDX #$00; LDA | ||
| + | $0200,X; STA $0400,X; ...</code>) — confirming both the running-XOR | ||
| + | checksum interpretation and the 6-and-2 bit-expansion scheme are | ||
| + | correct, not just plausible. | ||
| + | |||
| + | == Custom keyboard handling == | ||
| + | |||
| + | The menu is read by a complete, hand-written matrix scanner at | ||
| + | <code>$0CE0</code>-<code>$0DF4</code> — confirmed by the total absence | ||
| + | of any <code>JSR $FFE4</code> (KERNAL <code>GETIN</code>) anywhere in | ||
| + | the resident program: | ||
| + | |||
| + | <pre> | ||
| + | .C:0d03 8D 00 DC STA $DC00 ; select all columns | ||
| + | .C:0d0a AE 01 DC LDX $DC01 ; any key at all pressed? | ||
| + | .C:0d0d E0 FF CPX #$FF | ||
| + | .C:0d0f D0 03 BNE $0D14 | ||
| + | .C:0d11 4C B8 0D JMP $0DB8 ; no key -> exit | ||
| + | |||
| + | .C:0d15 A9 FE LDA #$FE ; walking-zero column select | ||
| + | .C:0d17 8D 00 DC STA $DC00 | ||
| + | .C:0d1c 48 PHA | ||
| + | .C:0d1d AD 01 DC LDA $DC01 | ||
| + | .C:0d20 CD 01 DC CMP $DC01 | ||
| + | .C:0d23 D0 F8 BNE $0D1D ; debounce: re-read until stable | ||
| + | .C:0d25 4A LSR A ; bit0 -> carry: 0 = key pressed (active low) | ||
| + | .C:0d26 B0 19 BCS $0D41 ; not pressed -> skip | ||
| + | .C:0d28 48 PHA | ||
| + | .C:0d29 B9 4A 1A LDA $1A4A,Y ; decode table: matrix position -> raw code | ||
| + | </pre> | ||
| + | |||
| + | Decoded scan positions are translated to real ASCII via a table at | ||
| + | <code>$1A4A</code>, including proper A-Z case handling (a self-contained | ||
| + | equivalent of what the KERNAL's own decode table does), then pushed into | ||
| + | a '''custom circular FIFO''' at <code>$0C8B</code> (count tracked at | ||
| + | <code>$0C8A</code>) — a hand-built keyboard buffer, independent of the | ||
| + | KERNAL's own. | ||
| + | |||
| + | == File system structure == | ||
| + | |||
| + | === The catalog: a genuine named-file directory === | ||
| + | |||
| + | A fixed catalog table holds up to 20 slots of 16-byte records — name, | ||
| + | NUL-padded — linearly scanned by name. Confirmed live on both known | ||
| + | titles at the identical location, '''track 2''' — the catalog is | ||
| + | content-identifiable (a run of 16-byte records starting with an | ||
| + | uppercase ASCII letter, zero-padded) rather than needing a fixed track | ||
| + | number hardcoded per title: | ||
| + | |||
| + | <pre> | ||
| + | [track 2, one catalog sector] | ||
| + | 57445341 00000000 00000000 00000000 "WDSA............" | ||
| + | 57445346 00000000 00000000 00000000 "WDSF............" | ||
| + | 57445347 00000000 00000000 00000000 "WDSG............" | ||
| + | ... | ||
| + | </pre> | ||
| + | |||
| + | On ''Wörter Rennen mit System'' this resolves to real vocabulary-list | ||
| + | filenames (<code>WDSA</code>, <code>WDSF</code>-<code>WDSW</code> — | ||
| + | letters chosen per topic, not strictly alphabetic; plus | ||
| + | <code>FELD.P64</code>, <code>TITLE.P64</code>, | ||
| + | <code>SYSC64.STARTUP</code>, and other system/resource files). On | ||
| + | ''Jagd auf Vokabeln'' the same mechanism resolves to a completely | ||
| + | different file set (<code>SYSC64.GAME</code>, <code>MENU</code>, | ||
| + | <code>SHAPES.SPR</code>, <code>BILD.P64</code>, short 1-2 letter | ||
| + | vocabulary-chapter codes, etc.) — confirming the catalog format itself | ||
| + | is generic across titles, only its contents differ. | ||
| + | |||
| + | Lookup (<code>$1776</code>, called from <code>$17A8</code> for | ||
| + | insert-or-find, and directly for plain lookup) linear-scans the 20 | ||
| + | catalog slots, comparing each 16-byte record against a "wanted name" | ||
| + | buffer at <code>$0C60</code>; on a match, it converts the matching | ||
| + | slot's address back into a small record index via an | ||
| + | <code>LSR</code>/<code>ROR</code> chain (dividing the byte offset by | ||
| + | 16). <code>$17A8</code> additionally scans for a '''free''' slot (first | ||
| + | byte <code>$00</code>) to register a new entry, confirmed by two | ||
| + | embedded error strings sitting in the code stream a few bytes after its | ||
| + | call site: | ||
| + | |||
| + | <pre> | ||
| + | >C:1817 20 b1 0e 0d 0d 43 41 54 41 4c 4f 47 20 46 55 4c ....CATALOG FUL | ||
| + | >C:1827 4c 07 00 20 46 0f 4c 03 20 85 41 20 cb 11 a5 40 L.. F.L. .A ...@ | ||
| + | >C:1837 20 d8 17 90 16 20 b1 0e 0d 0d 44 49 53 4b 20 46 .... ....DISK F | ||
| + | >C:1847 55 4c 4c 07 00 20 46 0f 4c 03 ULL.. F.L. | ||
| + | </pre> | ||
| + | |||
| + | <code>"CATALOG FULL"</code> (all 20 slots occupied) and | ||
| + | <code>"DISK FULL"</code> — genuine runtime error messages from a save | ||
| + | path, confirming this is a full read/write catalog filesystem, not a | ||
| + | fixed, pre-baked table. | ||
| + | |||
| + | === The page-ownership table: resolving a catalog entry to exact file boundaries === | ||
| + | |||
| + | A catalog match alone only gives a '''name''', not a length or location. | ||
| + | The mechanism that resolves a matched entry to precise byte boundaries | ||
| + | lives in the same routine's continuation, <code>$171B</code>- | ||
| + | <code>$1732</code>: | ||
| + | |||
| + | <pre> | ||
| + | .C:171b C8 INY | ||
| + | .C:171c B1 07 LDA ($07),Y ; next byte of the ownership table | ||
| + | .C:171e 29 1F AND #$1F ; mask to 5 bits | ||
| + | .C:1720 C5 3F CMP $3F ; $3F = (matched catalog index) + 1 | ||
| + | .C:1722 F0 F7 BEQ $171B ; still the same owner -> keep counting | ||
| + | .C:1724 8C 7D 0C STY $0C7D ; Y = run length so far -> becomes the page count! | ||
| + | .C:1727 20 58 17 JSR $1758 | ||
| + | .C:172a A5 09 LDA $09 | ||
| + | .C:172c A6 0A LDX $0A | ||
| + | .C:172e 85 2E STA $2E | ||
| + | .C:1730 86 2F STX $2F | ||
| + | .C:1732 4C DD 10 JMP $10DD ; hand off to the shared page-transfer loop | ||
| + | </pre> | ||
| + | |||
| + | The table pointed to by <code>$07</code>/<code>$08</code> is a | ||
| + | '''page-ownership run-length map''': one byte per 256-byte content | ||
| + | page, whose low 5 bits give <code>(owning catalog index) + 1</code>. The | ||
| + | code counts how many consecutive table entries share the wanted | ||
| + | catalog index's key — that count directly '''is''' the file's page | ||
| + | count, and the table's own cumulative offset up to that run '''is''' the | ||
| + | file's starting page. Located on disk purely by content (a run of | ||
| + | monotonically non-decreasing small values, distinguishable at a glance | ||
| + | from both the name records and real code/data) — on both known titles, | ||
| + | this table lives on the same track as the catalog itself. | ||
| + | |||
| + | Combined with an auto-detected content-start track (the first track past | ||
| + | the catalog/loader tracks with a full complement of custom sectors) and | ||
| + | the reserved-track exclusion described in "Basic sector structure" | ||
| + | above, this gives an exact, generic formula with no per-title constants: | ||
| + | |||
| + | <pre> | ||
| + | global_page = the ownership table's cumulative run-length position for this file | ||
| + | content_tracks = [ t | t is a used, in-sequence physical track, reserved | ||
| + | tracks like the directory track excluded ] | ||
| + | physical_track = content_tracks[global_page // 16] | ||
| + | physical_sector = global_page % 16 | ||
| + | </pre> | ||
| + | |||
| + | Verified byte-exact on ''Wörter Rennen mit System'': the first catalog | ||
| + | entry (<code>WDSA</code>, 12 pages) decodes to a clean run of | ||
| + | body-parts vocabulary starting exactly with <code>arm Arm back | ||
| + | Rücken...</code>; the very next entry (<code>WDSF</code>, starting | ||
| + | immediately after) decodes to a fresh, unrelated topic starting exactly | ||
| + | with <code>mind Verstand...</code> — no split words, no overlap, at the | ||
| + | computed boundary. | ||
| + | |||
| + | === File loading is not literally "1 file = 1 track" === | ||
| + | |||
| + | A live trace of a real menu selection (Vokabeltraining -> a submenu | ||
| + | choice) showed the resulting drive-side track seek land in completely | ||
| + | '''stock, unmodified''' 1541 ROM code (<code>$F31B: STA $22</code>, the | ||
| + | standard KERNAL DOS job-track store) — meaning the drive-resident | ||
| + | program issues perfectly ordinary job-dispatch calls '''internally''' | ||
| + | once it has resolved which track a requested page actually lives on; no | ||
| + | custom drive-side seek routine exists. Individual named files can be | ||
| + | (and, on both known titles, routinely are) shorter than one full track | ||
| + | and packed several to a track — e.g. on ''Wörter Rennen mit System'', 16 | ||
| + | <code>WDS*</code> vocabulary entries occupy only 9 physical tracks total | ||
| + | between them, not 16. | ||
| + | |||
| + | == Known titles == | ||
| + | |||
| + | {| class="wikitable" | ||
| + | ! Title !! Confirmed !! Notes | ||
| + | |- | ||
| + | | [[Wörter Rennen mit System]] || yes, fully traced || vocabulary trainer; catalog + page-ownership table both located and validated; 29/29 catalog files extract cleanly | ||
| + | |- | ||
| + | | [[Jagd auf Vokabeln]] || yes, catalog + table confirmed || same mechanism, different file set | ||
| + | |} | ||
| + | |||
| + | == Tooling == | ||
| + | |||
| + | [[File:langenscheidt_extract.zip]] | ||
| + | |||
| + | <code>langenscheidt_extract.py</code> implements the sector decode, catalog scan, and | ||
| + | page-ownership resolution above directly, and provides three functions | ||
| + | confirmed working on both known titles straight from their native | ||
| + | <code>.g64</code>/<code>.p64</code> images with no per-title hardcoding: | ||
| + | |||
| + | # '''identify''' — every custom sector actually carrying non-empty file data, by track/sector. | ||
| + | # '''verify''' — checksum-validates every custom sector and reports bad ones, each annotated with which catalog file it belongs to (or <code>unused</code>). | ||
| + | # '''extract''' — writes the on-disk catalog listing (names, exact page counts, starting pages) and one exactly byte-bounded file per catalog entry. | ||
| + | |||
| + | === Known limitations === | ||
| + | |||
| + | The tool operates on <code>g64conv</code>'s decoded text dump (a single fixed GCR interpretation per track), not raw flux. | ||
| + | |||
| + | == See also == | ||
| + | |||
| + | * [[Wörter Rennen mit System]] | ||
| + | * [[Jagd auf Vokabeln]] | ||
Revision as of 05:04, 29 August 2026
Contents
- 1 Pages that refer to this protection
- 2 Langenscheidt Protection
Pages that refer to this protection
- Descriptions of old disk copy protections (← links)
- Wörter Rennen mit System (← links)
- Jagd auf Vokabeln (← links)
Langenscheidt Protection
The Langenscheidt Protection is a Commodore 64 disk scheme found on Langenscheidt educational software (confirmed on Wörter Rennen mit System and Jagd auf Vokabeln). It is the disk's own native storage format: a fully custom raw-GCR encoding used for essentially the whole disk, with a small hand-built filesystem (name catalog, page-ownership table) replacing CBM DOS's own BAM/directory conventions entirely. Reading such a disk with standard tools decodes almost nothing.
The findings below extend the analysis with a full sector decode algorithm (validated byte-for-byte against a known worked example), a commented disassembly of every key routine, and a complete description of the on-disk filesystem, including the exact mechanism that resolves a catalog entry to precise byte boundaries on disk.
Mechanism
- The disk boots via a short C64-side autostart stub that immediately hands off to the drive with a single, standard KERNAL
M-E $07B0command (Memory-Execute) — confirmed to be the only KERNAL/IEC command used in an entire session, by a full-session trace that loggedCIOUTcalls from boot through menu interaction and found exactly five, spellingM-E+$07B0and nothing else, ever again. - From that point on, the drive-resident program (itself streamed down from disk in 256-byte pages immediately after the handoff) drives everything — including standard 1541 DOS ROM job-dispatch calls it invokes internally — over a private, raw bit-banged protocol on CIA2 Port A (
$DD00), completely bypassing the KERNAL serial routines for the rest of the session. - Individual disk sectors use a custom 6-bit-to-8-bit run-length-limited GCR alphabet (64 valid byte values, no more than 2 consecutive zero bits), not the standard C64 5-bit GCR code. Every data sector carries a whole-payload running-XOR checksum spanning all 343 raw bytes.
- Files are identified by name, through a small hand-built catalog filesystem (fixed 16-byte name records, linearly scanned) — not by a fixed per-menu-option track number. Content offsets are resolved via a second on-disk table (the page-ownership table) that gives each file's exact length in 256-byte pages, letting every file be extracted byte-perfect. See "File system structure" below.
Basic sector structure
Sync-length pattern and marker bytes
Every custom sector — header or data — starts the same way: a standard
sync run, followed immediately by a single marker byte identifying the
block type, decoded straight from a g64conv <image> <out> 5
raw-byte dump with no GCR interpretation needed (the marker and payload
bytes are literal members of the custom 64-value alphabet, not standard
5-bit GCR nibbles):
sync NN
; Following raw bytes: 73 <skip> <id> 55 55 55 55 55 55 55 55 55 55 ff <- HEADER
^^ $73 = header marker ^^ padding/gap bytes to next sync
sync NN
; Following raw bytes: 6a <343 payload bytes...> <- DATA
^^ $6A = data marker
Header block (14 raw bytes total, only the first 3 meaningful):
byte 0 is always $73; byte 1 is an unchecked "skip" byte
(typically $4D in practice, but never validated by the
drive code); byte 2 is the sector-ID byte, translated through the
same 64-entry table used for data (see below) to give a plain 0-15
sector number.
Data block (344 raw bytes total): byte 0 is always $6A;
the following 343 bytes are the sector's translated, checksummed payload
(see "Sector decode algorithm" below) — 256 main bytes, 86 tail bytes,
and 1 running-checksum byte, laid out so that decoding and checksumming
happen in a single pass.
Table 1 — the 64-value GCR alphabet
64 native on-disk bytes (raw floppy alphabet), each a run-length-limited
value (no more than 2 consecutive zero bits) standing in for a 6-bit
value 0-63. Sector-header IDs 0-15 reuse the same table directly —
that is why $4A through $66 below double as
both "6-bit value 0-15" and "header sector ID 0-15":
idx 0 1 2 3 4 5 6 7 idx 8 9 10 11 12 13 14 15
4A 4B 4D 4E 52 53 55 56 57 59 5A 5B 5D 5E 65 66
idx 16 17 18 19 20 21 22 23 idx 24 25 26 27 28 29 30 31
67 69 6A 6B 6D 6E 72 73 75 76 77 79 7A 7B 7D 7E
idx 32 33 34 35 36 37 38 39 idx 40 41 42 43 44 45 46 47
95 96 97 9A 9B 9D 9E A5 A6 A7 A9 AA AB AD AE B2
idx 48 49 50 51 52 53 54 55 idx 56 57 58 59 60 61 62 63
B3 B5 B6 B7 B9 BA BB BD BE CA CB CD CE D2 D3 D5
The drive-resident code loads this table once at boot and builds a
sparse reverse table at $0100-$01FF
($0100 + native_byte -> 6-bit value), used for every
subsequent decode:
.8:079a A2 00 LDX #$00 .8:079c B4 64 LDY $64,X ; Y = native byte from Table 1 at offset X .8:079e 8A TXA .8:079f 99 00 01 STA $0100,Y ; reverse_table[native_byte] = X (the 6-bit index) .8:07a2 E8 INX .8:07a3 E0 40 CPX #$40 ; 64 entries .8:07a5 90 F5 BCC $079C
The directory track is genuinely mixed-format, not entirely custom
Track 18 is a real exception: it carries a small number of ordinary standard-CBM-format sectors (4 confirmed: sectors 0, 1, 8, 11 — plus one further standard-format header observed with no matching data block behind it) alongside custom sectors. Every other track has zero standard-format sectors and a full complement of custom ones — confirmed directly and used as the detection signal for "which tracks are part of the custom content addressing scheme" (see "File system structure" below): a track with zero custom sync hits is excluded from the sequential numbering, while every genuine content track has exactly 16.
Two-stage bootstrap: commented disassembly
Stage 1 — C64 autostart hands off to the drive
The very first thing the C64-side autostart code does is send a literal
M-E $07B0 command over the standard KERNAL
CIOUT — the only time the whole session ever uses it:
.C:0334 A9 4D LDA #$4D ; 'M' .C:0336 20 A8 FF JSR $FFA8 ; CIOUT .C:0339 A9 2D LDA #$2D ; '-' .C:033b 20 A8 FF JSR $FFA8 .C:033e A9 45 LDA #$45 ; 'E' .C:0340 20 A8 FF JSR $FFA8 .C:0343 A9 B0 LDA #$B0 ; target address low byte .C:0345 20 A8 FF JSR $FFA8 .C:0348 A9 07 LDA #$07 ; target address high byte -> $07B0 .C:034a 20 A8 FF JSR $FFA8 .C:034d 20 FE ED JSR $EDFE ; finish the IEC command (KERNAL UNTALK)
Stage 2 — receiving the rest of the C64-side program
Immediately after the handoff, the C64 enters a tight receive loop that pulls the entire remainder of the resident program down from the drive as a sequence of 256-byte pages, each preceded by its own 2-byte destination address — this is how the ~7 KB of C64-side code seen disassembled throughout this article actually arrives in RAM:
.C:0350 A2 FF LDX #$FF .C:0352 AD 00 DD LDA $DD00 .C:0355 A0 10 LDY #$10 .C:0357 CA DEX .C:0358 D0 FD BNE $0357 .C:035a 88 DEY .C:035b D0 FA BNE $0357 ; short settle delay .C:035d A9 03 LDA #$03 .C:035f 8D 00 DD STA $DD00 .C:0362 AD 00 DD LDA $DD00 .C:0365 CD 00 DD CMP $DD00 .C:0368 F0 FB BEQ $0365 ; wait for a line transition (drive ready) .C:036a 20 80 03 JSR $0380 ; receive byte -> destination address low .C:036d 85 D1 STA $D1 .C:036f 20 80 03 JSR $0380 ; receive byte -> destination address high .C:0372 85 D2 STA $D2 .C:0374 A0 00 LDY #$00 .C:0376 20 80 03 JSR $0380 ; receive byte -> data .C:0379 91 D1 STA ($D1),Y .C:037b 88 DEY .C:037c D0 F8 BNE $0376 ; 256 bytes per page .C:037e F0 EA BEQ $036A ; next (address, page) pair
$0380 is the core receive-one-byte primitive: a
hand-timed, 2-bits-per-CIA-read bit-banged protocol over
$DD00, entirely independent of the KERNAL:
.C:0380 78 SEI .C:0381 A9 27 LDA #$27 .C:0383 8D 00 DD STA $DD00 .C:0386 2C 00 DD BIT $DD00 .C:0389 50 FB BVC $0386 ; wait for the drive's handshake (CLK IN, bit6) .C:038b A9 03 LDA #$03 .C:038d 8D 00 DD STA $DD00 .C:0390 A2 08 LDX #$08 .C:0392 CA DEX .C:0393 D0 FD BNE $0392 ; short delay .C:0395 A2 04 LDX #$04 .C:0397 AD 00 DD LDA $DD00 ; sample 2 bits per iteration .C:039a 0A ASL A .C:039b 08 PHP .C:039c 0A ASL A .C:039d 26 2D ROL $2D .C:039f 28 PLP .C:03a0 26 2D ROL $2D .C:03a2 CA DEX .C:03a3 D0 F2 BNE $0397 ; 4 iterations x 2 bits = 8 bits total .C:03a5 A9 17 LDA #$17 .C:03a7 8D 00 DD STA $DD00 ; release/ack .C:03aa EA NOP .C:03ab EA NOP .C:03ac EA NOP .C:03ad A5 2D LDA $2D ; assembled byte .C:03af 60 RTS
A live RAM snapshot taken at menu-idle confirms the resulting program
occupies only $0200-$1BFF; everything from
$2000 upward is still the untouched C64 power-on RAM test
pattern, confirming the whole resident program is under 7 KB.
The unified transfer protocol
$1600-$16CB duplicates the exact primitive
seen in $0380, split into four small reusable entry points
sharing an identical handshake shape:
| Address | Role |
|---|---|
$1600 |
send one byte, source = ($2E),Y (memory to drive)
|
$1638 |
receive one byte, destination = ($2E),Y (drive to memory)
|
$166A |
send one byte, source = accumulator |
$169C |
receive one byte, return in accumulator |
A live trace confirms this is genuinely the only transfer layer
used for the rest of the session: even a live-observed menu selection
that triggered a fresh drive-side track seek produced zero further
CIOUT calls — the C64-side command that told the drive
which content to fetch travelled entirely over this custom protocol,
indistinguishable byte-for-byte from ordinary data transfer without
tracing the protocol's own framing (see "The page-ownership table"
below for how that framing was actually decoded).
Sector decode algorithm
$0603-$0669 is the single routine responsible
for turning 343 raw on-disk bytes (already past the $6A
marker) into 256 real content bytes, in one pass that
simultaneously computes and verifies the sector's checksum. This is the
routine that gives every custom sector its error-detection property, and
its exact behavior was validated byte-for-byte against a known worked
example (see the note at the end of this section) before being trusted
for the filesystem/extraction work below.
; ---- read 256 main bytes, translate, and running-XOR checksum in place ---- .8:0603 20 56 F5 JSR $F556 ; wait for sync .8:0606 50 FE BVC $0606 .8:0608 AD 01 1C LDA $1C01 ; read raw byte .8:060b B8 CLV .8:060c C9 6A CMP #$6A ; must be the data marker .8:060e D0 F3 BNE $0603 .8:0610 98 TYA ; Y returned 0 by the sync wait -> A=0 .8:0611 AA TAX ; X=0 too: running checksum accumulator starts at 0 .8:0612 50 FE BVC $0612 ; <-- per-byte loop entry .8:0614 B8 CLV .8:0615 AC 01 1C LDY $1C01 ; read next raw byte .8:0618 59 00 01 EOR $0100,Y ; A ^= reverse_table[raw_byte] (running XOR!) .8:061b 9D 00 02 STA $0200,X ; store the RUNNING accumulator, not the raw lookup .8:061e E8 INX .8:061f D0 F1 BNE $0612 ; 256 iterations ; ---- read 86 more "tail" bytes, same running checksum, stored $A4-$F9 ---- .8:0621 A2 55 LDX #$55 ; X = 85 .8:0623 50 FE BVC $0623 .8:0625 B8 CLV .8:0626 AC 01 1C LDY $1C01 .8:0629 59 00 01 EOR $0100,Y .8:062c 95 A4 STA $A4,X ; X counts 85 downto 0 -> 86 bytes, $A4-$F9 .8:062e CA DEX .8:062f 10 F2 BPL $0623 ; ---- read the final check byte; A must be 0 if every prior byte was correct ---- .8:0631 50 FE BVC $0631 .8:0633 B8 CLV .8:0634 AC 01 1C LDY $1C01 ; read byte 343 (the check byte) .8:0637 59 00 01 EOR $0100,Y .8:063a AA TAX ; checksum-good <=> A==0 <=> X==0 here
The stored "main" bytes are the running XOR prefix of each raw
byte's table-translated value, not the plain per-byte lookup — this is
deliberate differential encoding: if the disk's mastering process stored
delta[i] = plaintext[i] XOR plaintext[i-1] (with an implicit
plaintext[-1] = 0) as the 6-bit value that got GCR-encoded,
then this running-XOR-accumulate loop exactly recovers the
original plaintext byte-for-byte, and the final check byte's own delta
is simply chosen so the whole 343-byte chain's cumulative XOR lands on
zero — a genuine whole-payload checksum, not just a per-header check.
Immediately following, with no RTS in between (this
is a straight fall-through, always executed, not a separate optional
step), the tail's 86 stored 6-bit values get expanded into the upper 2
bits of the 256 main bytes, via a 6-and-2 bit-packing scheme — each
6-bit tail value contributes exactly 2 bits to each of 3 consecutive
main bytes:
.8:063b A0 55 LDY #$55 ; Y = 85 downto 1 (85 iterations) .8:063d B9 A4 00 LDA $00A4,Y ; a 6-bit tail value, bits 5..0 .8:0640 4A LSR A ; bit0 -> carry .8:0641 3E 02 02 ROL $0202,X ; main[X+2] = (main[X+2]<<1)|bit0 .8:0644 4A LSR A ; bit1 -> carry .8:0645 3E 02 02 ROL $0202,X ; main[X+2] = (main[X+2]<<1)|bit1 (now 8 bits) .8:0648 4A LSR A ; bit2 .8:0649 3E 01 02 ROL $0201,X ; main[X+1], same 2-ROL treatment .8:064c 4A LSR A ; bit3 .8:064d 3E 01 02 ROL $0201,X .8:0650 4A LSR A ; bit4 .8:0651 3E 00 02 ROL $0200,X ; main[X+0] .8:0654 4A LSR A ; bit5 .8:0655 3E 00 02 ROL $0200,X .8:0658 E8 INX ; step 3 bytes per tail value .8:0659 E8 INX .8:065a E8 INX .8:065b 88 DEY .8:065c D0 DF BNE $063D ; special-case the 86th (last) tail value into the very last main byte, 2 bits only .8:065e A5 A4 LDA $A4 .8:0660 4A LSR A .8:0661 2E FF 02 ROL $02FF .8:0664 4A LSR A .8:0665 2E FF 02 ROL $02FF .8:0668 18 CLC .8:0669 60 RTS
Each of the 256 $0200-$02FF bytes ends up with
its original 6-bit running-checksum value shifted into bits 2-7, and 2
more bits from a shared tail value filled in at bits 0-1 — recovering
full 8-bit entropy per byte from a 64-value (6-bit) on-disk alphabet.
Validation: this exact algorithm, transliterated instruction-for-
instruction into Python, was run against the raw bytes of the
preliminary analysis's own "Sector with Code" example (a capture of
sector 6, known independently to become the second-stage loader code at
$0300) and produced an exact byte-for-byte match to
the already-known plaintext (A2 FF 9A 20 00 06 A2 00 BD 00 02 9D
00 04 ..., i.e. LDX #$FF; TXS; JSR $0600; LDX #$00; LDA
$0200,X; STA $0400,X; ...) — confirming both the running-XOR
checksum interpretation and the 6-and-2 bit-expansion scheme are
correct, not just plausible.
Custom keyboard handling
The menu is read by a complete, hand-written matrix scanner at
$0CE0-$0DF4 — confirmed by the total absence
of any JSR $FFE4 (KERNAL GETIN) anywhere in
the resident program:
.C:0d03 8D 00 DC STA $DC00 ; select all columns .C:0d0a AE 01 DC LDX $DC01 ; any key at all pressed? .C:0d0d E0 FF CPX #$FF .C:0d0f D0 03 BNE $0D14 .C:0d11 4C B8 0D JMP $0DB8 ; no key -> exit .C:0d15 A9 FE LDA #$FE ; walking-zero column select .C:0d17 8D 00 DC STA $DC00 .C:0d1c 48 PHA .C:0d1d AD 01 DC LDA $DC01 .C:0d20 CD 01 DC CMP $DC01 .C:0d23 D0 F8 BNE $0D1D ; debounce: re-read until stable .C:0d25 4A LSR A ; bit0 -> carry: 0 = key pressed (active low) .C:0d26 B0 19 BCS $0D41 ; not pressed -> skip .C:0d28 48 PHA .C:0d29 B9 4A 1A LDA $1A4A,Y ; decode table: matrix position -> raw code
Decoded scan positions are translated to real ASCII via a table at
$1A4A, including proper A-Z case handling (a self-contained
equivalent of what the KERNAL's own decode table does), then pushed into
a custom circular FIFO at $0C8B (count tracked at
$0C8A) — a hand-built keyboard buffer, independent of the
KERNAL's own.
File system structure
The catalog: a genuine named-file directory
A fixed catalog table holds up to 20 slots of 16-byte records — name, NUL-padded — linearly scanned by name. Confirmed live on both known titles at the identical location, track 2 — the catalog is content-identifiable (a run of 16-byte records starting with an uppercase ASCII letter, zero-padded) rather than needing a fixed track number hardcoded per title:
[track 2, one catalog sector] 57445341 00000000 00000000 00000000 "WDSA............" 57445346 00000000 00000000 00000000 "WDSF............" 57445347 00000000 00000000 00000000 "WDSG............" ...
On Wörter Rennen mit System this resolves to real vocabulary-list
filenames (WDSA, WDSF-WDSW —
letters chosen per topic, not strictly alphabetic; plus
FELD.P64, TITLE.P64,
SYSC64.STARTUP, and other system/resource files). On
Jagd auf Vokabeln the same mechanism resolves to a completely
different file set (SYSC64.GAME, MENU,
SHAPES.SPR, BILD.P64, short 1-2 letter
vocabulary-chapter codes, etc.) — confirming the catalog format itself
is generic across titles, only its contents differ.
Lookup ($1776, called from $17A8 for
insert-or-find, and directly for plain lookup) linear-scans the 20
catalog slots, comparing each 16-byte record against a "wanted name"
buffer at $0C60; on a match, it converts the matching
slot's address back into a small record index via an
LSR/ROR chain (dividing the byte offset by
16). $17A8 additionally scans for a free slot (first
byte $00) to register a new entry, confirmed by two
embedded error strings sitting in the code stream a few bytes after its
call site:
>C:1817 20 b1 0e 0d 0d 43 41 54 41 4c 4f 47 20 46 55 4c ....CATALOG FUL >C:1827 4c 07 00 20 46 0f 4c 03 20 85 41 20 cb 11 a5 40 L.. F.L. .A ...@ >C:1837 20 d8 17 90 16 20 b1 0e 0d 0d 44 49 53 4b 20 46 .... ....DISK F >C:1847 55 4c 4c 07 00 20 46 0f 4c 03 ULL.. F.L.
"CATALOG FULL" (all 20 slots occupied) and
"DISK FULL" — genuine runtime error messages from a save
path, confirming this is a full read/write catalog filesystem, not a
fixed, pre-baked table.
The page-ownership table: resolving a catalog entry to exact file boundaries
A catalog match alone only gives a name, not a length or location.
The mechanism that resolves a matched entry to precise byte boundaries
lives in the same routine's continuation, $171B-
$1732:
.C:171b C8 INY .C:171c B1 07 LDA ($07),Y ; next byte of the ownership table .C:171e 29 1F AND #$1F ; mask to 5 bits .C:1720 C5 3F CMP $3F ; $3F = (matched catalog index) + 1 .C:1722 F0 F7 BEQ $171B ; still the same owner -> keep counting .C:1724 8C 7D 0C STY $0C7D ; Y = run length so far -> becomes the page count! .C:1727 20 58 17 JSR $1758 .C:172a A5 09 LDA $09 .C:172c A6 0A LDX $0A .C:172e 85 2E STA $2E .C:1730 86 2F STX $2F .C:1732 4C DD 10 JMP $10DD ; hand off to the shared page-transfer loop
The table pointed to by $07/$08 is a
page-ownership run-length map: one byte per 256-byte content
page, whose low 5 bits give (owning catalog index) + 1. The
code counts how many consecutive table entries share the wanted
catalog index's key — that count directly is the file's page
count, and the table's own cumulative offset up to that run is the
file's starting page. Located on disk purely by content (a run of
monotonically non-decreasing small values, distinguishable at a glance
from both the name records and real code/data) — on both known titles,
this table lives on the same track as the catalog itself.
Combined with an auto-detected content-start track (the first track past the catalog/loader tracks with a full complement of custom sectors) and the reserved-track exclusion described in "Basic sector structure" above, this gives an exact, generic formula with no per-title constants:
global_page = the ownership table's cumulative run-length position for this file
content_tracks = [ t | t is a used, in-sequence physical track, reserved
tracks like the directory track excluded ]
physical_track = content_tracks[global_page // 16]
physical_sector = global_page % 16
Verified byte-exact on Wörter Rennen mit System: the first catalog
entry (WDSA, 12 pages) decodes to a clean run of
body-parts vocabulary starting exactly with arm Arm back
Rücken...; the very next entry (WDSF, starting
immediately after) decodes to a fresh, unrelated topic starting exactly
with mind Verstand... — no split words, no overlap, at the
computed boundary.
File loading is not literally "1 file = 1 track"
A live trace of a real menu selection (Vokabeltraining -> a submenu
choice) showed the resulting drive-side track seek land in completely
stock, unmodified 1541 ROM code ($F31B: STA $22, the
standard KERNAL DOS job-track store) — meaning the drive-resident
program issues perfectly ordinary job-dispatch calls internally
once it has resolved which track a requested page actually lives on; no
custom drive-side seek routine exists. Individual named files can be
(and, on both known titles, routinely are) shorter than one full track
and packed several to a track — e.g. on Wörter Rennen mit System, 16
WDS* vocabulary entries occupy only 9 physical tracks total
between them, not 16.
Known titles
| Title | Confirmed | Notes |
|---|---|---|
| Wörter Rennen mit System | yes, fully traced | vocabulary trainer; catalog + page-ownership table both located and validated; 29/29 catalog files extract cleanly |
| Jagd auf Vokabeln | yes, catalog + table confirmed | same mechanism, different file set |
Tooling
File:Langenscheidt extract.zip
langenscheidt_extract.py implements the sector decode, catalog scan, and
page-ownership resolution above directly, and provides three functions
confirmed working on both known titles straight from their native
.g64/.p64 images with no per-title hardcoding:
- identify — every custom sector actually carrying non-empty file data, by track/sector.
- verify — checksum-validates every custom sector and reports bad ones, each annotated with which catalog file it belongs to (or
unused). - extract — writes the on-disk catalog listing (names, exact page counts, starting pages) and one exactly byte-bounded file per catalog entry.
Known limitations
The tool operates on g64conv's decoded text dump (a single fixed GCR interpretation per track), not raw flux.