Merge pull request #3006 from djupdal/geos-c-da

CBM GEOS desk accessory support
This commit is contained in:
Bob Andrews authored and GitHub committed 2026-10-10 13:51:30 +02:00
commit 05ca685d95
13 files changed
+461 -33

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
FEATURES {
STARTADDRESS: default = $1000;
}
SYMBOLS {
__BACKBUFSIZE__: type = weak, value = $2000;
__STACKSIZE__: type = weak, value = $0400; # 1k stack
__STACKADDR__: type = weak, value = __VLIR0_LAST__;
__SAVEDZPADDR__: type = weak, value = __STACKADDR__ + __STACKSIZE__;
__HIMEM__: type = weak, value = __SAVEDZPADDR__ + __ZP_SIZE__;
__VLIR0_BLOCKS__: type = export, value = ((__VLIR0_LAST__ - __VLIR0_START__ - __BSS_SIZE__) + 253) / 254;
}
MEMORY {
CVT: file = %O, start = $0, size = $40000;
ZP: define = yes, start = $58, size = $1A + $06;
VLIR0: define = yes, start = %S, size = ($6000 - %S) - __STACKSIZE__ - __ZP_SIZE__;
SAVEDZP: define = yes, start = __SAVEDZPADDR__, size = __ZP_SIZE__;
}
SEGMENTS {
ZEROPAGE: type = zp, load = ZP;
EXTZP: type = zp, load = ZP, optional = yes;
DIRENTRY: type = ro, load = CVT, align = $FE;
FILEINFO: type = ro, load = CVT, align = $FE;
RECORDS: type = ro, load = CVT, align = $FE, optional = yes;
STARTUP: type = ro, run = VLIR0, load = CVT, align_load = $FE, define = yes;
LOWCODE: type = ro, run = VLIR0, load = CVT, optional = yes;
ONCE: type = ro, run = VLIR0, load = CVT, optional = yes;
CODE: type = ro, run = VLIR0, load = CVT;
RODATA: type = ro, run = VLIR0, load = CVT;
DATA: type = rw, run = VLIR0, load = CVT;
INIT: type = bss, load = VLIR0, optional = yes;
BSS: type = bss, load = VLIR0, define = yes;
SAVEDZP: type = bss, load = SAVEDZP;
}
FEATURES {
CONDES: type = constructor,
label = __CONSTRUCTOR_TABLE__,
count = __CONSTRUCTOR_COUNT__,
segment = ONCE;
CONDES: type = destructor,
label = __DESTRUCTOR_TABLE__,
count = __DESTRUCTOR_COUNT__,
segment = RODATA;
CONDES: type = interruptor,
label = __INTERRUPTOR_TABLE__,
count = __INTERRUPTOR_COUNT__,
segment = RODATA,
import = __CALLIRQ__;
}
+11 -3
View File
@@ -24,8 +24,9 @@ Optional resources might be menu definitions, other headers (e.g., for data
files of an app.), dialog definitions, etc. Without an application's header,
GEOS is unable to load and start it.
Currently, <bf/grc65/ supports only menus and the required header definition,
along with support for building applications with VLIR-structured overlays.
Currently, <bf/grc65/ supports menus, the required header definition,
driver/accessory headers (desk accessories), and building applications with
VLIR-structured overlays.
<bf/grc65/ generates output in two formats: C header and <bf/ca65/ source (.s).
That is because the application header data must be in assembly format, while
@@ -115,7 +116,8 @@ HEADER <GEOS_TYPE> "dosname" "classname" "version" {
}</verb></tscreen>
The header definition describes the GEOS header sector which is unique to
each file. The definition starts with the keyword <tt/HEADER/, then goes the
GEOS file-type. You can use only <tt/APPLICATION/ here at the moment. Then,
GEOS file-type. It can be <tt/APPLICATION/, <tt/AUTO_EXEC/, or <tt/DESK_ACC/.
Then,
there are (each one in quotes) the DOS file-name (up to 16 characters), the GEOS
Class name (up to 12 characters), and the version info (up to 4 characters).
The version should be written as &dquot;<tt/V/x.y&dquot;, where <em/x/ is the
@@ -266,6 +268,12 @@ required for the correct process of GEOS sequential application building.
<sect>Building desk accessories<label id="building-da">
<p>Desk accessories (<tt/DESK_ACC/) must be built using the following target: <tt/geos-c-da/
See <tt>samples/geos</tt> for a working example.
<sect>Building a GEOS VLIR overlay application<label id="building-vlir">
<p>Large GEOS applications typically don't fit in one piece in their designated
memory area. They are therefore split into overlays which are loaded into memory
+1
View File
@@ -12,6 +12,7 @@ void InitForIO(void);
void DoneWithIO(void);
void MainLoop(void);
void EnterDeskTop(void);
void RstrAppl(void);
void ToBASIC(void);
void Panic(void);
+9 -2
View File
@@ -31,7 +31,8 @@ CBMS = c128 \
vic20
GEOS = geos-apple \
geos-cbm
geos-cbm \
geos-c-da
TARGETS = agat \
apple2 \
@@ -191,18 +192,24 @@ else ifeq ($(TARGET),atarixl)
DRVPFX = atrx
else ifeq ($(TARGET),sim65c02)
SRCDIR = sim6502
else ifeq ($(TARGET),geos-c-da)
SRCDIR = geos-cbm
else
SRCDIR = $(TARGET)
endif
SRCDIRS = $(SRCDIR)
ifeq ($(TARGET),geos-c-da)
SRCDIRS := geos-c-da $(SRCDIRS)
endif
ifeq ($(TARGET),$(filter $(TARGET),$(CBMS)))
SRCDIRS += cbm
endif
ifeq ($(TARGET),$(filter $(TARGET),$(GEOS)))
SRCDIRS += $(addprefix $(TARGET)/, $(GEOSDIRS))
SRCDIRS += $(addprefix $(SRCDIR)/, $(GEOSDIRS))
SRCDIRS += $(addprefix geos-common/,$(GEOSDIRS))
endif
+76
View File
@@ -0,0 +1,76 @@
;
; Startup code for a GEOS desk accessory (DESK_ACC), based on
; libsrc/geos-common/system/crt0.s
;
.export _exit
.export __STARTUP__ : absolute = 1 ; Mark as startup
.export __GEOS_SAVED_ZP__
.export _RstrAppl
.import __STACKADDR__, __STACKSIZE__ ; Linker generated
.import __ZP_START__, __ZP_SIZE__ ; Linker generated
.import initlib, donelib
.import callmain
.import zerobss
.importzp c_sp
.include "jumptab.inc"
; ------------------------------------------------------------------------
; Somewhere to save cc65's own runtime zero page block while this
; accessory is running.
.segment "SAVEDZP"
__GEOS_SAVED_ZP__:
; ------------------------------------------------------------------------
; Place the startup code in a special segment.
.segment "STARTUP"
; Save zero page before anything below gets a chance to use it.
ldy #<(__ZP_SIZE__ - 1)
SaveZP: lda __ZP_START__,y
sta __GEOS_SAVED_ZP__,y
dey
bpl SaveZP
; Clear the BSS data.
jsr zerobss
; Set up the stack.
lda #<(__STACKADDR__ + __STACKSIZE__)
ldx #>(__STACKADDR__ + __STACKSIZE__)
sta c_sp
stx c_sp+1
; Call the module constructors.
jsr initlib
; Push the command-line arguments; and, call main().
jsr callmain
; Call the module destructors, then leave this DA and resume the
; calling application, restoring cc65's own runtime zero page block
; first. Whether main() just returned or RstrAppl() was called
; explicitly, both need to happen the same way, so _exit and _RstrAppl
; are the same code.
_exit:
_RstrAppl:
jsr donelib
ldy #<(__ZP_SIZE__ - 1)
RestoreZP:
lda __GEOS_SAVED_ZP__,y
sta __ZP_START__,y
dey
bpl RestoreZP
jmp RstrAppl
+6
View File
@@ -79,6 +79,7 @@ endef # SUBDIR_recipe
EXELIST_geos-cbm = \
bitmap-demo.cvt \
deskacc.cvt \
filesel.cvt \
geosver.cvt \
getid.cvt \
@@ -172,9 +173,14 @@ yesno.cvt: yesnores.grc yesno.c
$(if $(QUIET),echo $(SYS):$@)
$(CL) -t $(SYS) -O -o $@ -m yesno.map yesnores.grc yesno.c
deskacc.cvt: deskaccres.grc deskacc.c
$(if $(QUIET),echo $(SYS):$@)
$(CL) -t geos-c-da -O -o $@ -m deskacc.map deskaccres.grc deskacc.c
clean:
@$(DEL) overlay-demores.h 2>$(NULLDEV)
@$(DEL) deskaccres.h 2>$(NULLDEV)
@$(DEL) bitmap.c 2>$(NULLDEV)
@$(DEL) *.cvt 2>$(NULLDEV)
@$(DEL) *.map 2>$(NULLDEV)
+210
View File
@@ -0,0 +1,210 @@
/*
** Desk accessory example, replicating the behaviour of the sample desk
** accessory shipped with GeoProgrammer
**
** 2026, Asbjørn Djupdal (asbjoern@djupdal.org)
*/
#include <stdbool.h>
#include <geos.h>
#include "deskaccres.h"
/*****************************************************************************/
/* defines */
/* screen region this accessory uses, in cards */
#define DA_TOP 8
#define DA_LEFT 10
#define DA_HEIGHT 8
#define DA_WIDTH 20
#define NUM_CARDS (DA_HEIGHT * DA_WIDTH)
#define COLOR_MATRIX_BASE ((char *) 0x8c00)
#define CARD_ADDR(base, cardRow, cardCol) \
((base) + (unsigned) (cardRow) * (40 * 8) + (unsigned) (cardCol) * 8)
/*****************************************************************************/
/* variables */
static char recoverFlag;
static char colorBuf[NUM_CARDS];
static char foreBuf[NUM_CARDS * 8];
static char backBuf[NUM_CARDS * 8];
/*****************************************************************************/
/* icon */
static const unsigned char iconPicture[] = {
0x06, 0xFF, 0x81, 0x80, 0x04, 0x00, 0x82, 0x01,
0x80, 0x04, 0x00, 0x82, 0x01, 0x80, 0x04, 0x00,
0x82, 0x01, 0x80, 0x04, 0x00, 0x82, 0x01, 0x80,
0x04, 0x00, 0xBE, 0x01, 0x80, 0x0E, 0x00, 0x00,
0x00, 0x01, 0x80, 0x04, 0x00, 0x00, 0x00, 0x01,
0x80, 0x04, 0x00, 0x00, 0x00, 0x01, 0x80, 0x04,
0x38, 0x63, 0x60, 0x01, 0x80, 0x04, 0x4C, 0x91,
0x90, 0x01, 0x80, 0x04, 0x41, 0x09, 0x10, 0x01,
0x80, 0x04, 0x41, 0x09, 0x10, 0x01, 0x80, 0x04,
0x41, 0x09, 0x10, 0x01, 0x80, 0x04, 0x44, 0x91,
0x10, 0x01, 0x80, 0x0E, 0x38, 0x63, 0xB8, 0x01,
0x80, 0x04, 0x00, 0x82, 0x01, 0x80, 0x04, 0x00,
0x82, 0x01, 0x80, 0x04, 0x00, 0x82, 0x01, 0x80,
0x04, 0x00, 0x82, 0x01, 0x80, 0x04, 0x00, 0x82,
0x01, 0x80, 0x04, 0x00, 0x81, 0x01, 0x06, 0xFF
};
void DoIcon1 (void);
static const struct icontab icon = {
1,
{ DA_LEFT * 8, DA_TOP * 8 },
{
{ (char *) iconPicture, DA_LEFT + 3, DA_TOP * 8 + 24, 6, 23,
(unsigned) DoIcon1 }
}
};
/*****************************************************************************/
/* framed box */
static const graphicStr drawBox = {
NEWPATTERN (0), /* white */
MOVEPENTO (DA_LEFT * 8, DA_TOP * 8),
RECTANGLETO ((DA_LEFT + DA_WIDTH) * 8 - 1, (DA_TOP + DA_HEIGHT) * 8 - 1),
NEWPATTERN (1), /* black */
FRAME_RECTO (DA_LEFT * 8, DA_TOP * 8),
GSTR_END
};
/*****************************************************************************/
/* functions */
/* copy this accessory's screen area, one card at a time, between the given
** screen bitmap and the given RAM buffer.
*/
static void CopyBitmap (char *screen, char *buf, bool toBuf)
{
char row, col, i;
char *card;
for (row = 0; row < DA_HEIGHT; ++row) {
for (col = 0; col < DA_WIDTH; ++col) {
card = CARD_ADDR (screen, DA_TOP + row, DA_LEFT + col);
for (i = 0; i < 8; ++i) {
if (toBuf) {
*buf++ = card[i];
} else {
card[i] = *buf++;
}
}
}
}
}
/* save screen buffers */
static void SaveScreen (void)
{
CopyBitmap (BACK_SCR_BASE, backBuf, true);
if (recoverFlag & 0x80) {
CopyBitmap (SCREEN_BASE, foreBuf, true);
}
}
/* restore screen buffers */
static void RestoreScreen (void)
{
CopyBitmap (BACK_SCR_BASE, backBuf, false);
if (recoverFlag & 0x80) {
CopyBitmap (SCREEN_BASE, foreBuf, false);
}
}
/* save color values */
static void SaveColors (void)
{
char row, col, fillColor;
fillColor = COLOR_MATRIX_BASE[39];
for (row = 0; row < DA_HEIGHT; ++row) {
for (col = 0; col < DA_WIDTH; ++col) {
if (recoverFlag & 0x40) {
colorBuf[row * DA_WIDTH + col] =
COLOR_MATRIX_BASE[(DA_TOP + row) * 40 + DA_LEFT + col];
}
COLOR_MATRIX_BASE[(DA_TOP + row) * 40 + DA_LEFT + col] = fillColor;
}
}
}
/* restore color values */
static void RestoreColors (void)
{
char row, col;
for (row = 0; row < DA_HEIGHT; ++row) {
for (col = 0; col < DA_WIDTH; ++col) {
COLOR_MATRIX_BASE[(DA_TOP + row) * 40 + DA_LEFT + col] =
colorBuf[row * DA_WIDTH + col];
}
}
}
/*****************************************************************************/
/* handlers */
void DoClose (void)
{
GotoFirstMenu ();
}
void DoCut (void)
{
GotoFirstMenu ();
}
void DoCopy (void)
{
GotoFirstMenu ();
}
void DoPaste (void)
{
GotoFirstMenu ();
}
void DoIcon1 (void)
{
}
void DoQuit (void)
{
GotoFirstMenu ();
if (recoverFlag & 0x40) {
RestoreColors ();
}
RestoreScreen ();
RstrAppl ();
}
/*****************************************************************************/
void main (void)
{
recoverFlag = r10L;
SaveScreen ();
SaveColors ();
dispBufferOn = ST_WR_FORE | ST_WR_BACK;
GraphicsString (&drawBox);
DoMenu ((struct menu *) &mainMenu);
DoIcons ((struct icontab *) &icon);
MainLoop ();
}
+24
View File
@@ -0,0 +1,24 @@
MENU fileSubMenu 80,79 VERTICAL
{
"close" MENU_ACTION DoClose
"quit" MENU_ACTION DoQuit
}
MENU editSubMenu 100,79 VERTICAL
{
"cut" MENU_ACTION DoCut
"copy" MENU_ACTION DoCopy
"paste" MENU_ACTION DoPaste
}
MENU mainMenu 80,64 HORIZONTAL
{
"file" SUB_MENU fileSubMenu
"edit" SUB_MENU editSubMenu
}
HEADER DESK_ACC "deskacc" "DeskAcc Demo" "V1.0" {
dostype USR
info "Sample desk accessory"
mode 40only
}
+1
View File
@@ -333,6 +333,7 @@ static void SetSys (const char* Sys)
NewSymbol ("__GAMATE__", 1);
break;
case TGT_GEOS_CBM_DA:
case TGT_GEOS_CBM:
/* Do not handle as a CBM system */
NewSymbol ("__GEOS__", 1);
+1
View File
@@ -243,6 +243,7 @@ static void SetSys (const char* Sys)
DefineNumericMacro ("__GAMATE__", 1);
break;
case TGT_GEOS_CBM_DA:
case TGT_GEOS_CBM:
/* Do not handle as a CBM system */
DefineNumericMacro ("__GEOS__", 1);
+2
View File
@@ -185,6 +185,7 @@ static const TargetEntry TargetMap[] = {
{ "gamate", TGT_GAMATE },
{ "geos", TGT_GEOS_CBM },
{ "geos-apple", TGT_GEOS_APPLE },
{ "geos-c-da", TGT_GEOS_CBM_DA },
{ "geos-cbm", TGT_GEOS_CBM },
{ "kim1", TGT_KIM1 },
{ "lunix", TGT_LUNIX },
@@ -251,6 +252,7 @@ static const TargetProperties PropertyTable[TGT_COUNT] = {
{ "kim1", CPU_6502, BINFMT_BINARY, CTNone },
{ "rp6502", CPU_W65C02, BINFMT_BINARY, CTNone },
{ "agat", CPU_6502, BINFMT_BINARY, CTAgat },
{ "geos-c-da", CPU_6502, BINFMT_BINARY, CTNone },
};
/* Target system */
+1
View File
@@ -93,6 +93,7 @@ typedef enum {
TGT_KIM1,
TGT_RP6502,
TGT_AGAT,
TGT_GEOS_CBM_DA,
TGT_COUNT /* Number of target systems */
} target_t;
+70 -28
View File
@@ -148,6 +148,7 @@ static void OptTarget (const char* Opt attribute ((unused)), const char* Arg)
{
switch (FindTarget (Arg)) {
case TGT_GEOS_CBM_DA:
case TGT_GEOS_CBM:
apple = 0;
break;
@@ -462,6 +463,7 @@ static void DoHeader (void)
char *token;
char i1[9], i2[9], i3[9];
int i;
int isDeskAcc = 0;
openSFile ();
@@ -471,20 +473,28 @@ static void DoHeader (void)
if (apple == 1) {
switch (i) {
case 0:
case 0: /* APPLICATION */
myHead.geostype = 0x82;
break;
case 2: /* DESK_ACC */
myHead.geostype = 0x81;
isDeskAcc = 1;
break;
default:
AbEnd ("Filetype '%s' is not supported yet", token);
}
} else {
switch (i) {
case 0:
case 0: /* APPLICATION */
myHead.geostype = 6;
break;
case 1:
case 1: /* AUTO_EXEC */
myHead.geostype = 14;
break;
case 2: /* DESK_ACC */
myHead.geostype = 5;
isDeskAcc = 1;
break;
default:
AbEnd ("Filetype '%s' is not supported yet", token);
}
@@ -597,31 +607,46 @@ static void DoHeader (void)
/* OK, all information is gathered, do flushout */
if (isDeskAcc) {
/* The end address for a desk accessory must cover the whole memory
** range GEOS has to save and restore. __HIMEM__ is used for this
*/
fprintf (outputSFile,
" .import __HIMEM__\n");
}
fprintf (outputSFile,
" .import __BSS_SIZE__, __STARTUP_RUN__\n"
" .import __VLIR0_START__, __VLIR0_LAST__\n"
" .import __VLIR0_BLOCKS__\n"
" .import __VLIR1_BLOCKS__\n"
" .import __VLIR2_BLOCKS__\n"
" .import __VLIR3_BLOCKS__\n"
" .import __VLIR4_BLOCKS__\n"
" .import __VLIR5_BLOCKS__\n"
" .import __VLIR6_BLOCKS__\n"
" .import __VLIR7_BLOCKS__\n"
" .import __VLIR8_BLOCKS__\n"
" .import __VLIR9_BLOCKS__\n"
" .import __VLIR10_BLOCKS__\n"
" .import __VLIR11_BLOCKS__\n"
" .import __VLIR12_BLOCKS__\n"
" .import __VLIR13_BLOCKS__\n"
" .import __VLIR14_BLOCKS__\n"
" .import __VLIR15_BLOCKS__\n"
" .import __VLIR16_BLOCKS__\n"
" .import __VLIR17_BLOCKS__\n"
" .import __VLIR18_BLOCKS__\n"
" .import __VLIR19_BLOCKS__\n\n"
);
if (myHead.structure == APPHEADER_STRUCTURE_VLIR) {
fprintf (outputSFile,
" .import __VLIR1_BLOCKS__\n"
" .import __VLIR2_BLOCKS__\n"
" .import __VLIR3_BLOCKS__\n"
" .import __VLIR4_BLOCKS__\n"
" .import __VLIR5_BLOCKS__\n"
" .import __VLIR6_BLOCKS__\n"
" .import __VLIR7_BLOCKS__\n"
" .import __VLIR8_BLOCKS__\n"
" .import __VLIR9_BLOCKS__\n"
" .import __VLIR10_BLOCKS__\n"
" .import __VLIR11_BLOCKS__\n"
" .import __VLIR12_BLOCKS__\n"
" .import __VLIR13_BLOCKS__\n"
" .import __VLIR14_BLOCKS__\n"
" .import __VLIR15_BLOCKS__\n"
" .import __VLIR16_BLOCKS__\n"
" .import __VLIR17_BLOCKS__\n"
" .import __VLIR18_BLOCKS__\n"
" .import __VLIR19_BLOCKS__\n"
);
}
fprintf (outputSFile, "\n");
fprintf (outputSFile,
" .segment \"DIRENTRY\"\n\n");
@@ -674,12 +699,22 @@ static void DoHeader (void)
* add size of each VLIR segment, plus 1 block for the info block (icon),
* plus another block for the VLIR RECORDS table (VLIR structure only)
*/
" .word %d + __VLIR0_BLOCKS__ + __VLIR1_BLOCKS__ + __VLIR2_BLOCKS__ + __VLIR3_BLOCKS__ + __VLIR4_BLOCKS__ + __VLIR5_BLOCKS__ + __VLIR6_BLOCKS__ + __VLIR7_BLOCKS__ + __VLIR8_BLOCKS__ + __VLIR9_BLOCKS__ + __VLIR10_BLOCKS__ + __VLIR11_BLOCKS__ + __VLIR12_BLOCKS__ + __VLIR13_BLOCKS__ + __VLIR14_BLOCKS__ + __VLIR15_BLOCKS__ + __VLIR16_BLOCKS__ + __VLIR17_BLOCKS__ + __VLIR18_BLOCKS__ + __VLIR19_BLOCKS__\n"
/* PRG formatted or SEQ formatted */
" .byte \"%s formatted GEOS file V1.0\"\n\n",
" .word %d + __VLIR0_BLOCKS__",
myHead.structure, myHead.geostype,
myHead.year, myHead.month, myHead.day, myHead.hour, myHead.min,
(myHead.structure == APPHEADER_STRUCTURE_SEQ) ? 1 : 2,
(myHead.structure == APPHEADER_STRUCTURE_SEQ) ? 1 : 2
);
if (myHead.structure == APPHEADER_STRUCTURE_VLIR) {
fprintf (outputSFile,
" + __VLIR1_BLOCKS__ + __VLIR2_BLOCKS__ + __VLIR3_BLOCKS__ + __VLIR4_BLOCKS__ + __VLIR5_BLOCKS__ + __VLIR6_BLOCKS__ + __VLIR7_BLOCKS__ + __VLIR8_BLOCKS__ + __VLIR9_BLOCKS__ + __VLIR10_BLOCKS__ + __VLIR11_BLOCKS__ + __VLIR12_BLOCKS__ + __VLIR13_BLOCKS__ + __VLIR14_BLOCKS__ + __VLIR15_BLOCKS__ + __VLIR16_BLOCKS__ + __VLIR17_BLOCKS__ + __VLIR18_BLOCKS__ + __VLIR19_BLOCKS__"
);
}
fprintf (outputSFile,
"\n"
/* PRG formatted or SEQ formatted */
" .byte \"%s formatted GEOS file V1.0\"\n\n",
(myHead.structure == APPHEADER_STRUCTURE_SEQ) ? "SEQ" : "PRG"
);
}
@@ -702,8 +737,15 @@ static void DoHeader (void)
fprintf (outputSFile,
" .byte %i, %i, %i\n"
" .word __VLIR0_START__, __VLIR0_START__ - 1, __STARTUP_RUN__\n\n",
myHead.dostype, myHead.geostype, myHead.structure);
" .word __VLIR0_START__, %s, __STARTUP_RUN__\n\n",
myHead.dostype, myHead.geostype, myHead.structure,
/* For desk accessories, GEOS computes the memory range to back up
** and restore as (end address - start address), i.e. end address
** is exclusive. Every other file type uses the documented
** "start address - 1" dummy convention instead (this field isn't
** meaningful for them).
*/
isDeskAcc ? "__HIMEM__" : "__VLIR0_START__ - 1");
fillOut (myHead.classname, 12, "$20");