Langenscheidt Protection

From Software Archive
Revision as of 05:04, 29 August 2026 by Enigma (talk | contribs)
Jump to navigation Jump to search

Pages that refer to this 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

  1. The disk boots via a short C64-side autostart stub that immediately hands off to the drive with a single, standard KERNAL M-E $07B0 command (Memory-Execute) — confirmed to be the only KERNAL/IEC command used in an entire session, by a full-session trace that logged CIOUT calls from boot through menu interaction and found exactly five, spelling M-E + $07B0 and nothing else, ever again.
  2. 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.
  3. 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.
  4. 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:

  1. identify — every custom sector actually carrying non-empty file data, by track/sector.
  2. verify — checksum-validates every custom sector and reports bad ones, each annotated with which catalog file it belongs to (or unused).
  3. 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.

See also