bass is a table-driven cross assembler with an embedded programming language, which is used to provide powerful macro support.
Tables allow bass to support any desired processor instruction set, and the optional ability to open target files in modification mode allows bass to be used as a patching assembler.
bass [options] source [source ...]
-o target will specify the default target filename, and overwrite any existing file by said filename.
-m target will specify the default target filenamd, and will modify, rather than replace, any existing file by said name.
-d name[=value] will create a define with the given name, and assign to it either an empty value or the value provided.
-c name[=value] will create a constant with the given name, and assign to it either a value of 1 or the value provided.
-strict will abort the assembly process on warnings.
-benchmark will display the time required to assemble the source.
Parsing consists of the following phases:
The tokenize phase will combine all source files, and insert any nested include statements into a single stream of instructions.
The analyze phase will parse blocks, such as macros and functions, and note where they begin and end.
The execute phase will recurse macro invocations, substitute defines and evaluate conditional expressions.
The query phase invokes the execute phase, and computes the values of constants and labels.
The write phase invokes the execute phase, uses the previously computed values for constants and labels, and writes to any opened output file.
Initially, each source file specified on the terminal is loaded in. For each source file, all tabs (\t) and carriage returns (\r) are converted to spaces, and each line is split by line feeds (\n). Next, each line is clipped at the first appearance of a comment marker (//). Then, each line is split by any semicolons (;) not appearing inside of quoted strings. That is to say, semicolons can be used inside of quoted strings. Using semicolons outside of strings splits the line into multiple statements. The semicolon acts as a statement separator, and not as a statement terminator, meaning that a semicolon is not required at the end of each line. Finally, if the statement is an include directive, the source file parser will be invoked recursively to load in nested source files.
Macros, functions, defines, variables and constants must be in the following format:
[_A-Za-z][_A-Za-z0-9.]*
Valid numbers must be in one of the following formats:
[0-9]+ integer 0b[01]+ binary 0o[0-7]+ octal 0x[0-9a-f]+ hex %[01]+ binary $[0-9a-f]+ hex
Numbers may be prefixed with - or + if desired.
Numbers may also use ' as a digit separator. For example:
123'456'789 //same as 123456789 0b1001'0110 //same as 0b10010110
Strings are surrounded by double-quotes. They support the following escape sequences:
\\ = backslash (\) \' = single-quote (') \" = double-quote (") \n = new line \t = tab
Strings may also be concatenated via the ~ operator, which is useful for string construction via defines:
"foo" ~ "bar" //equivalent to "foobar"
Characters are surrounded by single-quotes, and evaluate to integer values which can be used inside of expressions. They support the same escape sequences as strings.
Note that characters are not escaped for block tokenization. That means you must use '\b' instead of ';' to avoid splitting the character into two separate statements.
Execution acts much like a scripting language. Statements are evaluated and a control flow (stack frame) is maintained.
Expressions can be used to transform variables or constants by use of parameters. Expressions may be recursive.
expression sum(x, y) = x + y print sum(1, 2), "\n" //prints 3
Defines can be used to substitute values in expressions. The define keyword allows specifying an exact expression to substitute, whereas the evaluate keyword will evaluate the expression to an integer value. The latter is useful for conditional expressions. Defines must be declared before being used, and can be re-declared later on.
define x = 1 + 2 print "{x}\n" //prints "1 + 2\n" evaluate x = {x} + 3 //x = evaluate(1 + 2 + 3) print "{x}\n" //prints "6\n"
Defines can also take arguments for substitution.
define sum(x, y) = ({x} + {y}) print "{sum(1,2)}\n" //prints "(1 + 2)\n"
Defines can be invoked with: {defineName}. Defines with parameters can be invoked with {defineName(parameter, ...)}. If a define is not matched, there is no error, the literal {defineName} will be passed along to the assembler verbatim.
Defines are evaluated from right-to-left order, meaning that expressions such as {x{y}} will first expand {y}, and then the result of that expression, {x...}.
It is possible to test if a define has been declared or not by using this special syntax: {defined name}
//create {value} if it does not yet exist if !{defined value} { define value(...) }
{defined name} is substituted with either 1 (if a define by the given name exists) or 0 (if it does not.)
Macros are supported. They can take zero or more arguments, and name overloading with differing arity is possible. Recursion is supported, but requires conditionals in order to break infinite recursion. Macros must be declared before being used, and can be re-declared later on.
By default, macro parameters are simply the names of the values, and are passed in as defines. It is also possible to specify the type of the parameter, which will cause the invocation to pass the value in as the requested type. Supported types are: define, evaluate and variable.
macro seek(offset) { origin {offset} & 0x3fffff base 0xc00000 | {offset} } seek(0xc08000) macro test(define a, evaluate b, variable c, d) { //{d} has no type, so it defaults to "define d" } test(1+2, 1+2, 1+2, 1+2) //{a} = 1+2, {b} = 3, c = 3, {d} = 1+2
Because expanded macros are passed directly to the assembler, a macro with a label name cannot be expanded twice in the same scope, or the label name will be declared twice, resulting in an error. The special token {#} can be used in a label name, where it will be substituted with a numeric value that increments every time a macro is invoked.
Note that the invocation counter may not work as expected inside of recursive macros. Only use this for top-level macros.
Macros can be invoked with the syntax: macroName(parameter, parameter, ...). If a macro is not matched, there is no error, the literal macroName(...) will be passed along to the assembly phase. Note that macros cannot appear inside expressions: the macro invocation must be the entire statement.
Every time a macro is invoked, a new object stack frame is created, which will supercede all previous stack frames. All macro arguments, as well as any objects declared inside of said macro, are appended to the new frame. When the macro completes execution, said frame is destroyed, and said objects are lost. Note that this does not apply to constants, which must always be placed in the global frame to support forward-declarations.
It is however possible to access the global frame by prefixing object creation with the global keyword, for example:
macro square(value) { global evaluate result({value} * {value}) } square(16) print "{square.result}\n" //prints 256
Further, it is also possible to reference the parent frame by prefixing object creation with the parent keyword, which is useful for recursive macros, or to represent macro return values. For example:
macro factorial(variable n) { parent variable result = 1 if n >= 1 { factorial(n - 1) result = n * factorial.result } } factorial(10) print factorial.result, "\n" //prints 3628800
Macros can also be created without a stack frame by using the inline keyword, which will cause any objects created inside of them to appear in the same frame as the macro was invoked in. For example:
inline square(variable value) { variable result = value * value } function main { square(16) } print main.result, "\n" //prints 256
This is obviously not a good idea to use for recursive macros.
bass supports traditional conditional expressions.
define x = 16 while {x} > 0 { print "{x}\n" evaluate x = {x} - 1 } if {x} > 16 { ... } else if {x} > 8 { ... } else { ... }
Variables and constants can be used in conditional expressions. Just note that variables must be declared before they can be used in expressions. Only constants support forward-declaration.
Any statements that fall through the execute phase are passed into the assembly phase.
Variables and constants hold integer values. Variables must be declared before being used, but can be redefined. Constants can be used before their declaration, but subsequently cannot be redefined. Labels are stored as constants.
variable x = 16 lda #x //16 variable x = 32 lda #x //32 lda #y //64 constant y = 64
Arrays of variables can be created. The size of the array is fixed once it has been created, but the array can be redefined later on to another size if desired. Array elements not specified initially are initialized to zeroes.
array[4] x = 1,2,4,8 array[2] y y[1] = x[3] print y[0], ",", y[1], "\n" //prints 0,8 array[8] x //recreates a new array with eight entries
Labels can be created with the syntax: labelName:
loop: dex; bne loop
Labels without names can be created using - and +.
-; beq +; lsr; dex; bne -; + -; bra ++ //A: go to D -; bra + //B: go to C +; bra - //C: go to B +; bra -- //D: go to A
The previous - label can be referenced with -, and the next + label can be referenced with +. The second to last - label can be referenced with --, and the second to next + label can be referenced with ++. Deeper scoping is not supported: you will have to switch to named labels at this point.
Macros, defines, variables and constants can be scoped. This allows reuse of common names like loop and finish inside of scopes, without causing declaration collisions. Note that labels are stored as constants, meaning that scoping also applies to labels.
It's also important to understand that for macro scoping, the macro name's scope is determined where the macro is declared, and the actual scope used while executing a macro is determined where the macro is invoked.
variable offset = 16 namespace information { variable length = 32 lda #offset //16 lda #length //32 } lda #offset //16 lda #information.length //32
It is possible to declare a scope and label at the same time, which is a useful way to mark functions and their boundaries.
function main { subroutine: } jsr main.subroutine
It is also possible to create blocks which do not create scopes. These are used strictly for code clarity, and have no functional effect.
labelName: { } - { } + { } { }
Note that this command is parsed in the very first phase, and is only noted here for completeness. It includes another source file in place of this command.
Do not attempt conditional recursion on the same source file, as this will result in an infinite loop which will eventually exhaust all memory.
This command can be used in place of the -o filename [-create] command-line argument, or in addition to it, and can open multiple files sequentially for output (only one output file can be open at a time.) The create parameter, if specified, states to overwrite the target file if it already exists. Otherwise, the file is opened in modification mode.
This command will change the currently active architecture. An architecture is essentially a processor that bass supports.
bass will first try to select any built-in architecture by the given name, which allows bass to support architectures written as C++ modules. Currently, the only built-in architecture is none, which is also the default state at the start of assembly.
When no built-in architecture is found, bass will instead use its table-driver assembler architecture. This architecture takes a text file as input, which defines all supported opcodes and their encodings for a given processor.
The name is transformed to name.arch, and bass tries to find said file in two locations: first, in the architectures/ subdirectory next to the main bass executable. And second, in ~/.local/share/bass/architectures/ or %localappdata%/bass/architectures/, depending upon your OS.
bass ships with a variety of pre-made table architecture files. On Linux/BSD, users should run make install to place these files into the appropriate location to be used.
bass is also extensible, so users can add their own table architecture files to support additional processors. Architectures written in C++ will however require recompiling bass to include said architecture.
Note that the architecture command will also change the current endian mode of bass, to match that of the given processor architecture.
In the event that name does not match a built-in architecture, and no appropriate name.arch file can be found, this command will generate an error.
This command controls whether multi-byte values (eg from dw and dd) are output in little-endian (lsb) or big-endian (msb) format.
This command seeks the output file write cursor to the specified location.
This command creates a signed displacement against the origin value, which is used when computing the pc (program counter) value for labels. This command allows mapping file address space into a virtual memory address space.
This can be used to save and restore internal state. Currently supported values are: origin, base, pc.
This command copies a block from the currently open file to another location within the file. It does this by reading the entire block in first, and then writing said block out, so be careful with overlapping addresses.
This command inserts a binary file into the target file. You can optionally specify a name, offset and length. If you specify a name, it will create a label by the given name, which contains the address where the data begins, and it will also create name.size, which contains the size of the included data. If you specify an offset, it will seek that far into the referenced filename before copying the data. If you want to specify a length, you must specify an offset first, and the length will determine the maximum number of bytes to copy from the referenced filename.
This command deletes a file from disk. Useful for removing temporary work files needed by a project being assembled.
Inserts length number of bytes into the target file. The default fill byte is 0x00, but can be specified via with.
Modifies the mappings for strings passed to db, dw, etc. This can be used to map strings to custom tilemaps that do not follow traditional ASCII values.
char is the first value to modify, value is the value to map said char to, and length can be used for contiguous entries. For instance, if A-Z appear sequentially, give a value of 26 for the length, to avoid having to declare 26 separate assignments. Each step of length increments both the char and value by exactly one, so the characters must be contiguous with both ASCII and your custom map for this to work.
If you wish to restore the table to its default ASCII values, use the following command:
map 0, 0, 256
Inserts binary data directly into the target file. db stores 8-bit values, dw stores 16-bit values, dl stored 24-bit values, dd stored 32-bit values and dq stores 64-bit values.
Prints information to the terminal. Useful for debugging.
The print directive also supports formatting prefixes for variable or constant expressions. These are: binary, hex, and char. Example:
print "%", binary:15 //prints %1111 print "$", hex:65536 //prints $10000 print "'", char:65, "'" //prints 'A'
Seeks forward or backward from the current output file write address. The value can be positive (to seek forward) or negative (to seek backward.) This is useful for skipping bytes when in file modification mode.
Tracks writes to a given output file when enabled. Selecting a new output file automatically clears the tracking list. This is useful when using bass as a patching assembler to detect when the same file address is written to more than once. If this happens while the tracker is enabled, an error is produced.
Note: disabling the tracker does not clear the previously tracked addresses, in case you only wish to intentionally disable it for a short time. If you want to clear the tracking history, use the reset argument.
Prints a notice to the terminal, but continues assembly.
Prints a warning to the terminal, but continues assembly.
Prints an error to the terminal, and aborts assembly.
bass supports built-in functions. The syntax is equivalent to macros, however they are used in expressions rather than as statements, and they always return a numeric value.
seek(0x8000) //this is a macro statement ... if pc() > 0x8fff { //this is a function used inside an assembler statement ... }
Returns the number of elements in an array, or produces an error if the array is not defined.
Sorts the specified array in ascending order.
Produces an error if the expression evaluates to zero.
Returns the size of "filename" on disk, or produces an error if the file is not found.
Returns 1 if "filename" exists, or 0 if not.
Reads a byte from the currently open output file, or produces an error if there is no file currently open. The address provided is the origin, or literal file address. The base offset is not factored in when this function is used.
Important note: the resulting values read from a target file are only valid during the write phase of assembly! If you rely on the value read back during a previous pass to control outputting code, bass may assemble the source code incorrectly. Use this function with caution. It is mostly intended for when bass is used in patching mode.
Returns the current origin.
Returns the current base.
Returns the current program counter (origin + base.)
Regretfully, this documentation does not cover how to create your own architecture tables. This is an advanced topic to cover, and it is expected that these will be provided for you by any given bass distribution. bass was not really designed with end-users creating these tables in mind.
If you wish to create a new architecture table for bass, you will need to study the bass source code and the existing architecture files to gain an understanding of how they work.
Hopefully this has been informative. The best way to learn is through practice, so please do experiment and see what you can come up with!
Thank you for using bass!