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
- The Overlay System
- Screen Recovery
- Font Management ← this article
- Zero Page
- Copy Protection
- Localization
- File Format and Pagination
- Copy & Paste
- 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 characterPutString
– 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
- Michael Farr: The Official GEOS Programmer’s Reference Guide
- Berkeley Softworks: The Hitchhiker’s Guide to GEOS
- Berkeley Softworks: geoProgrammer User’s Manual
- Rebecca G. Bettencourt: GEOS Font Format
- Glenn Holmer: GEOS Fonts
- GEOS Source Code