Inside geoWrite – 3: Font Management

In the series about the internals of the geoWrite WYSIWYG text editor for the C64, this article discusses the font manager’s system of caches for pixel fonts.

Article Series

  1. The Overlay System
  2. Screen Recovery
  3. Font Management ← this article
  4. Zero Page
  5. Copy Protection
  6. Localization
  7. File Format and Pagination
  8. Copy & Paste
  9. Keyboard Handling

GEOS Fonts Overview

The GEOS operating system contains a rendering library for pixel fonts of up to 63 pt, using its own font file format.

Like most GEOS files, fonts are VLIR bundles that contain one “sub-file” for every point size. This is “California”, which is available at 10, 12, 14 and 18 pt:

California         size
\--- File Header    256
\--- 10             892
\--- 12            1114
\--- 14            1322
\--- 18            2110

To use a font, an application has to explicitly load it into its own memory buffer and activate it using the LoadCharSet API:

    LoadW   r0, otherFnBuffer
    jsr     OpenRecordFile          ; open font file
    lda     #12
    jsr     PointRecord             ; select 12 pt font
    LoadW   r7, FONT_BUFFER
    LoadW   r2, FONT_BUFFER_SIZE
    jsr     ReadRecord              ; read pixel font into memory
    jsr     CloseRecordFile         ; close font file
    LoadW   r0, FONT_BUFFER
    jsr     LoadCharSet             ; activate font

The 9 pt system font (called “BSW”) is always in memory and can be activated using UseSystemFont:

jsr     UseSystemFont

As soon as a font is activated, it can be used for drawing text:

  • Putchar – draw a character
  • PutString – draw a zero-terminated string of characters

Font metrics are accessible through:

  • curHeight (global variable) – font height (in pixels)
  • baselineOffset (global variable) – baseline offset (in pixels from top)
  • GetRealSize – query width and height of a given character code

All this API is very basic. The GEOS KERNAL does not help the application with:

  • enumerating available fonts and sizes: the application has to find font files on disk and decode their metadata.
  • dynamically caching several fonts in memory: GEOS only knows about a single font at a time.
  • getting font metrics without loading the font data: the GEOS API for getting the metrics requires the font to be loaded and active.

geoWrite implements all this on the application side.

Enumerating Fonts

geoWrite’s “font” menu shows all available fonts and their point sizes:

To get this information, applications have to find font files on disk and extract it from their metadata.

A font file has a file type of FONT, and its file name is also the font’s name, so that’s what the application will show in the UI.

The API FindFTypes returns an array of file names matching a filename or type, so this is how geoWrite gets the (file) names of the fonts on disk:

    LoadW   r6, fontNames
    LoadB   r7L, FONT               ; file type
    LoadB   r7H, MAX_FONT_FILES
    LoadW   r10, 0                  ; no name filter
    jsr     FindFTypes              ; get font files
    lda     #8
    sub     r7H                     ; number of files found
    sta     numFontFiles

(All code has been edited for readability.)

geoWrite also needs to get the available point sizes for each font, as well as the start track and sector of the data on disk and its size. This way, it can later load the data for a particular point size without having to read any extra metadata again.

In C notation, the data structure that it builds looks like this:

struct {
    uint16_t font_id;
    uint16_t record_size;
    uint16_t start_ts;
} disk_fonts[16][8];

For each of the (up to) 8 font files, there are (up to) 16 point sizes, for each of which geoWrite collects the font ID, the record size and the start track and sector.

A font ID is a GEOS concept that allows applications to use numbers instead of font name strings. It is a 16 bit value that uniquely identifies the font and point size:

  • The upper 10 bits are a unique ID assigned by Berkeley Softworks, e.g. 3 is a synonym “California”.
  • The lower 6 bits are the point size (0-63).

This is the main function to extract the metadata of a font:

;---------------------------------------------------------------
; extractFontMetadata
;
; Function:  Read point sizes, track/sector pointers and data
;            sizes for a font file from its file header
;
; Pass:      a   font index (0-7)
;            r0  font filename
;---------------------------------------------------------------
extractFontMetadata:
        pha
        MoveW   r0, r6
        jsr     FindFile                ; get file
        LoadW   r9, dirEntryBuf
        jsr     GetFHdrInfo             ; read file header
        MoveW   dirEntryBuf+OFF_DE_TR_SC, r1
        jsr     ldR4DiskBlkBuf
        jsr     GetBlock                ; read index block
        pla                             ; font index (0-7)
        asl     a
        asl     a
        asl     a
        asl     a                       ; * 16
        tay
        ldx     #0
@loop:  jsr     extractFontIdTrackSector
        jsr     extractFontRecordSize
        iny
        iny
        inx
        inx
        cpx     #FONTS_PER_FONTFILE
        bne     @loop
        rts

It opens each font file and reads its file header and index block. For every point size, it calls extractFontIdTrackSector and extractFontRecordSize.

Here is extractFontIdTrackSector:

;---------------------------------------------------------------
; extractFontIdTrackSector
;
; Function:  Copy a point size ID and its track/sector pointer
;            from the font file header and the index block
;            into the app's data structures.
;
; Pass:      x   font index within font file (0-15)
;            y   fontfile * 16 + fontindex * 2
;---------------------------------------------------------------
extractFontIdTrackSector:
        lda     fileHeader+OFF_GHPOINT_SIZES,x
        sta     diskFontIds,y
        and     #FONT_SIZE_MASK
        sta     r6L                     ; point size
        lda     fileHeader+OFF_GHPOINT_SIZES+1,x
        sta     diskFontIds+1,y
        ora     diskFontIds,y
        beq     @rts                    ; skip empty records
        txa
        pha
        lda     r6L                     ; point size
        asl     a
        tax
        lda     diskBlkBuf+2,x          ; track
        sta     diskFontRecordTrackSector,y
        lda     diskBlkBuf+3,x          ; sector
        sta     diskFontRecordTrackSector+1,y
        pla
        tax
@rts:   rts

A font’s file header contains 16 words at offset OFF_GHPOINT_SIZES that contain font IDs of the different point sizes, which this code copies into its internal data structure. It takes the start track and sectors for each point size from the VLIR index sector.

And this is extractFontRecordSize:

;---------------------------------------------------------------
; extractFontRecordSize
;
; Function:  Copy a font's data size from the font file header
;            into the app's data structures.
;
; Pass:      x   font index within file (0-15)
;            y   fontfile * 16 + fontindex * 2
;---------------------------------------------------------------
extractFontRecordSize:
        lda     fileHeader+OFF_GHSET_LENGTHS,x
        sta     diskFontRecordSize,y
        sta     r2L
        lda     fileHeader+OFF_GHSET_LENGTHS+1,x
        sta     diskFontRecordSize+1,y
        sta     r2H

        CmpWI   r2, MEM_SIZE_FONTS      ; data size too big?
        bcc     @rts
        beq     @rts
        lda     #0
        sta     diskFontRecordTrackSector,y ; then pretend it doesn't exist

@rts:   rts

Similarly, it extracts the data size for each point size.

Caching Font Data

geoWrite can keep up to 8 fonts in memory at the same time and dynamically allocates space for fonts in a 7000 byte buffer.

Fonts are managed with an LRU strategy, meaning that if a new font is supposed to be loaded that wouldn’t fit, the least recently used font will be removed from memory.

The font buffer contains one font immediately after the other: If a font is removed, fonts at higher addresses are moved down to fill the hole. This way, the free space is always at the end and there is no fragmentation.

These are the data structures in C notation:

uint8_t buffer[7000];
struct {
    uint16_t font_id;
    uint16_t data_ptr;
    uint16_t data_size;
    uint16_t lru;
} loaded_fonts[8];

For every loaded font, geoWrite keeps track of its font ID, the pointer to the data in the buffer, the size in the buffer, and its LRU ID.

The main API of the font library is the call setFontFromFile, which allows the application to ask the library to activate a font given its ID. If it’s not already in memory, it will be loaded into the buffer, and if necessary, one or more previously used fonts will be removed from memory.

This is the first part of the function:

;---------------------------------------------------------------
; setFontFromFile
;
; Function:  Set font. If necessary, load from disk and cache it.
;
; Pass:      r1  font ID
;
; Return:    c   =0: success
;                =1: fail, system font was loaded instead
;---------------------------------------------------------------
setFontFromFile:
        CmpW    r1, curFont
        bne     @find
        clc
        rts

@find:  jsr     findLoadedFont          ; is it already loaded?
        bcs     @load                   ; not found
        jsr     updateloadedFontLruId   ; mark it as the latest one that was used
        lda     loadedFontPtrsHi,x
        sta     r0H
        lda     loadedFontPtrsLo,x
        sta     r0L
        jsr     LoadCharSet             ; switch to it
        MoveW   r1, curFont
        clc
        rts

If the requested font is the currently active font, the function does nothing. Otherwise, it checks whether the font is already loaded into memory, and if yes, it just activates it and returns.

This is the implementation of findLoadedFont:

;---------------------------------------------------------------
; findLoadedFont
;
; Function:  Search for font in font buffer.
;
; Pass:      r1  font ID
;
; Return:    c   =0: found
;                    x   index
;---------------------------------------------------------------
findLoadedFont:
        ldx     #0
@loop:  cpx     loadedFontsCount
        beq     @notfound
        lda     loadedFontIdsLo,x
        cmp     r1L
        bne     @1
        lda     loadedFontIdsHi,x
        cmp     r1H
        beq     @found
@1:     inx
        bra     @loop

@notfound:
        sec                             ; failure
        rts
@found:
        clc                             ; success
        rts

It scans the array of loaded font IDs. If the ID is found, the index to be used with the data structures is returned in X.

If the font ID is not currently loaded into memory, setFontFromFile will load it:

;---------------------------------------------------------------
; setFontFromFile
; (continued)
;---------------------------------------------------------------
@load:  jsr     findFontIdOnDisk        ; does the font exist on disk?
        bcs     useSystemFont           ; no, quietly use system font instead
        lda     diskFontRecordTrackSector,x ; does point size exist?
        beq     useSystemFont           ; no, quietly use system font instead
        txa
        pha
        lda     diskFontRecordSize,x    ; r3 = size of font data
        sta     r3L
        lda     diskFontRecordSize+1,x
        sta     r3H
        jsr     allocateFontBufferSpace ; kick out least recently used font(s) if needed
        jsr     updateloadedFontLruId   ; mark it as the latest one that was used
        lda     r1L
        sta     loadedFontIdsLo,x       ; save the ID in the table so the font
        lda     r1H                     ; can be found in RAM again
        sta     loadedFontIdsHi,x
        lda     loadedFontPtrsHi,x      ; r7 = allocated location in RAM
        sta     r7H
        lda     loadedFontPtrsLo,x
        sta     r7L
        pla
        tax
        PushW   r1                      ; save ID
        PushW   r7                      ; save RAM location
        lda     diskFontRecordTrackSector,x; location on disk
        sta     r1L
        lda     diskFontRecordTrackSector+1,x
        sta     r1H
        LoadW   r2, MEM_SIZE_FONTS      ; maximum file size
        jsr     setDevice
        jsr     ReadFile                ; load font data into font buffer
        PopW    r0                      ; read RAM location into r0
        PopW    curFont                 ; read ID into curFont
        cpx     #0
        bne     useSystemFont           ; read error
        jsr     LoadCharSet
        clc                             ; success
        rts

useSystemFont:
        jsr     UseSystemFont
        LoadW   curFont, SYSTEM_FONT_ID
        sec                             ; fail: it's not the font we wanted
        rts

It calls findFontIdOnDisk (not shown) to check whether the information about the available fonts and point sizes on disk contains the requested font ID.

If the ID is found, setFontFromFile calls allocateFontBufferSpace with the required data size to make space for the font in the buffer, and loads it using ReadFile and the track and sector pointer. If anything goes wrong, the system font is activated instead.

This is allocateFontBufferSpace:

;---------------------------------------------------------------
; allocateFontBufferSpace
;
; Function:  Allocate buffer space for a new font.
;
; Pass:      r3  size of font data
;
; Note:      This function cannot fail: It will remove fonts
;            using an LRU strategy until there is space.
;---------------------------------------------------------------
allocateFontBufferSpace:
        ldx     loadedFontsCount        ; no fonts loaded?
        beq     @first                  ; then load it to start of buffer

        cpx     #MAX_FONTS_LOADED
        beq     @remove                 ; too many fonts loaded, remove one

        lda     loadedFontPtrsLo-1,x    ; check for r3 bytes of spaces in font buffer
        clc                             ; (last font pointer + last font size + required size)
        adc     loadedfontDataSizeLo-1,x
        tay
        lda     loadedFontPtrsHi-1,x
        adc     loadedfontDataSizeHi-1,x
        tax
        tya
        add     r3L
        tay
        txa
        adc     r3H
        cmp     #>MEM_SCRRECV
        bne     :+
        cpy     #<MEM_SCRRECV
:       bcc     @add                    ; it fits
        beq     @add

@remove:
        PushW   r1                      ; does not fit
        jsr     unloadLruFont           ; remove one
        PopW    r1
        bra     allocateFontBufferSpace ; try again

@first: lda     #>MEM_FONT              ; load first font to start
        ldy     #<MEM_FONT              ; of font buffer
        bra     @set

@add:   ldx     loadedFontsCount        ; new ptr = last ptr + size
        lda     loadedFontPtrsLo-1,x
        clc
        adc     loadedfontDataSizeLo-1,x
        tay
        lda     loadedFontPtrsHi-1,x
        adc     loadedfontDataSizeHi-1,x
@set:   ldx     loadedFontsCount
        sta     loadedFontPtrsHi,x      ; store new ptr
        tya
        sta     loadedFontPtrsLo,x
        lda     r3L
        sta     loadedfontDataSizeLo,x  ; new size
        lda     r3H
        sta     loadedfontDataSizeHi,x
        inc     loadedFontsCount        ; one font more
        rts

If the new font does not fit into the empty space at the end of the buffer, this function keeps calling unloadLruFont until there is enough space. It then fills the data pointer and size fields for the new font and increments the number of currently loaded fonts.

unloadLruFont is used to make space:

;---------------------------------------------------------------
; unloadLruFont
;
; Function:  Unload the least recently used font and compress
;            the font buffer.
;---------------------------------------------------------------
unloadLruFont:
                                        ; find lowest LRU ID
        ldy     #0                      ; candidate for lowest
        ldx     #1
@loop1: cpx     loadedFontsCount
        beq     @end1                   ; done iterating
        lda     loadedFontLruIdHi,x
        cmp     loadedFontLruIdHi,y
        bne     @1
        lda     loadedFontLruIdLo,x
        cmp     loadedFontLruIdLo,y
@1:     bcs     @2
        txa                             ; current one is lower
        tay                             ; -> update candidate
@2:     inx
        bra     @loop1

@end1:  tya
        tax                             ; lowest index to X

@loop2: inx
        cpx     loadedFontsCount        ; is it the last one?
        beq     @end2                   ; then we're done

        dex
        lda     loadedfontDataSizeHi+1,x; count: size of the one after
        sta     r2H
        lda     loadedfontDataSizeLo+1,x
        sta     r2L
        lda     loadedFontPtrsHi+1,x    ; source: address of the one after
        sta     r0H
        lda     loadedFontPtrsLo+1,x
        sta     r0L
        lda     loadedFontPtrsHi,x      ; target: address of the current one
        sta     r1H
        lda     loadedFontPtrsLo,x
        sta     r1L
        txa
        pha
        jsr     MoveData                ; move the next font down
        pla
        tax
        lda     loadedFontIdsLo+1,x     ; move loadedFontIds
        sta     loadedFontIdsLo,x
        lda     loadedFontIdsHi+1,x
        sta     loadedFontIdsHi,x
        lda     loadedFontLruIdLo+1,x   ; move FontLru
        sta     loadedFontLruIdLo,x
        lda     loadedFontLruIdHi+1,x
        sta     loadedFontLruIdHi,x
        lda     loadedfontDataSizeLo+1,x
        sta     loadedfontDataSizeLo,x  ; move loadedfontDataSize
        clc
        adc     loadedFontPtrsLo,x      ; update fontPtrs
        sta     loadedFontPtrsLo+1,x
        lda     loadedfontDataSizeHi+1,x
        sta     loadedfontDataSizeHi,x
        adc     loadedFontPtrsHi,x
        sta     loadedFontPtrsHi+1,x
        inx
        bra     @loop2                  ; repeat for all fonts above removed one

@end2:  dec     loadedFontsCount
        rts

It finds the lowest LRU ID, i.e. the least recently used font, moves all fonts at higher addresses (and their pointers) down, and decrements the number of loaded fonts.

To keep track of which font is least recently used, updateLoadedFontLruId is called on load and on every activation of a font:

;---------------------------------------------------------------
; updateLoadedFontLruId
;
; Function:  Mark a given font as most recently used.
;
; Pass:      x   font index
;---------------------------------------------------------------
updateLoadedFontLruId:
        lda     fontLruCounter
        sta     loadedFontLruIdLo,x
        lda     fontLruCounter+1
        sta     loadedFontLruIdHi,x
        IncW    fontLruCounter
        bne     @rts

        ; 16 bit overflow: clear LRU ID for all fonts
        ldy     #0
        tya
@loop:  sta     loadedFontLruIdLo,y
        sta     loadedFontLruIdHi,y
        iny
        cpy     loadedFontsCount
        bne     @loop

@rts:   rts

It keeps assigning the next number of a sequence to the given font, guaranteeing that it will always be the highest number and therefore the last one to be removed.

Caching Metrics

When a word processor is dealing with fonts, it does not always need the actual image data for it. Sometimes the fonts metrics are enough, i.e. the height of the font, the baseline offset and the width of the different characters. This is true when selecting text, for example, to know where the character boundaries are, or when reflowing a document.

Caching font data is expensive; the 7000 bytes of geoWrite can hold five 12pt fonts, but only two 24pt fonts. Caching metrics is cheap: The character widths take up only 96 bytes per point size (for the printable ASCII character codes $20-$7F).

geoWrite therefore has an independent cache for font metrics that holds information about the last 8 loaded fonts.

The data structure looks like this:

struct {
    uint16_t font_id;
    uint8_t height;
    uint8_t baseline_offset;
    uint8_t widths[96];
} metrics[8];

So if the application wants to draw characters, it has to call setFontFromFile, which will make sure the pixel data is in memory and activated, but if it only needs the font for measuring, it should call lookupFontMetrics instead:

;---------------------------------------------------------------
; lookupFontMetrics
;
; Function:  Prepare cached font metrics for use.
;
; Pass:      a3  font ID
;---------------------------------------------------------------
lookupFontMetrics:
        ldx     #0                      ; find font id in metricsIds
@loop:  lda     metricsIds,x
        tay
        ora     metricsIds+8,x
        beq     @nfound
        cpy     a3L
        bne     @no
        lda     metricsIds+8,x
        cmp     a3H
        beq     @found
@no:    inx
        cpx     #MAX_FONTS_LOADED
        bne     @loop

        jsr     getMod8Index

        ; not found in metrics cache
@nfound:
        PushB   r1H
        jsr     moveA3R1                ; r1 = font id
        txa
        pha
        jsr     setFontFromFile         ; set font
        pla
        tax                             ; mod8 index
        PopB    r1H
        lda     a3L                     ; store font id in metricsIds
        sta     metricsIds,x
        lda     a3H
        sta     metricsIds+8,x
        lda     curHeight               ; store height in table
        sta     metricsHeights,x
        lda     baselineOffset
        sta     metricsBaselineOffsets,x

        jsr     getCachedFontMetrics
        jsr     calcCharWidths
        rts

@found: jmp     getCachedFontMetrics

; ----------------------------------------------------------------------------
getCachedFontMetrics:
        [...]
        sta     metricsWidths
        [...]
        sta     metricsWidths+1
        lda     metricsHeights,x
        sta     curFontHeight
        lda     metricsBaselineOffsets,x
        sta     curBaselineOffset
        rts

If the metrics for the requested font and point size are in the cache, they will be copied into curFontHeight, curBaselineOffset and the array metricsWidths. Otherwise, the font’s pixel data is loaded and the metrics are added to the cache using calcCharWidths (not shown).

To get the width of a character after metrics have been looked up, the app can now call getCharWidth:

;---------------------------------------------------------------
; getCharWidth
;
; Function:  Get the width of a specified char of the currently
;            active metrics set (-> lookupFontMetrics).
;
; Pass:      a   character
;            x   currentMode
;
; Return:    a   width
;---------------------------------------------------------------
getCharWidth:
        sub     #$20                    ; ASCII -> table index
        pha
        MoveW   metricsWidths, r14
        pla
        tay
        lda     (r14),y                 ; width
        sta     metricsTmp
        txa                             ; mode
        and     #SET_BOLD
        beq     :+
        inc     metricsTmp              ; add one
:       txa
        and     #SET_OUTLINE
        beq     :+
        inc     metricsTmp              ; add 3
        inc     metricsTmp
:       lda     metricsTmp
        rts

This is basically a reimplementation of the GEOS KERNAL’s GetRealSize API: If the current font style is bold or outline, the width is increased by one or three pixels, respectively.

Conclusion

One goal of a modern operating system and even of many kinds of libraries is to abstract what is going on underneath it. GEOS is a very constrained operating system with only 64 KB of total RAM at its disposal, so it tries to provide as many useful functions as possible (graphics, text rendering, disk access, mouse, printer, …) that fit into 20 KB of code, but in many parts of the system, it barely abstracts the underlying hardware.

GEOS applications are seen as as the natural extension of the operating system, and many features that did not fit into the operating system were implemented in the applications, with full awareness of the details of the filesystem or the file formats of system files.

The GEOS font manager can be regarded as a low-level system library, which deals with the internals of the BAM/VLIR filesystem and the font file format.

References

Leave a Comment