MKNBI-MGL(8)

πŸ“¦ version 0.10.2, in nixos 26.05, nixpkgs unstable

🌸 MKNBI-MGL(8) β€” MaKe NetBoot Image for MGL program 🌸

🌟 NAME 🌟

mknbi-mgl β€” MaKe NetBoot Image for MGL program πŸ’…

🐚 SYNOPSIS 🐚

mknbi-mgl [OPTIONS] [<source file> [output file>]]

πŸ’– DESCRIPTION πŸ’–

okay so bestie, mknbi-mgl takes a program written in the Pascal-like Menu Generation Language β€” MGL for short β€” and converts it into a boot image file. That image is directly loadable by diskless systems using a bootrom that’s compliant with the netboot specification. slay. ✨

✨ OPTIONS ✨

  • -b, --batch-sys SYS β€” lets mknbi-mgl operate in batch mode. It looks up the given system name in a database file and generates a netbootable image according to the parameters specified in the database entry. If this option is not given, mknbi-mgl operates according to the other command line options. If you give any other options in addition to -b or --batch-sys, the parameters found in the database will override those processed from the command line. πŸ‘‘
  • -C, --config-file FILE β€” specifies the pathname of the configuration file. If this option is not given, the value of the NETBOOT_CONFIG environment variable or a compiled-in default will be used.
  • -D, --database FILE|DBSPEC β€” pathname of database file (or database specification) to be used together with the -b or --batch-sys options. If this option is not given, the value of the NETBOOT_DATABASE environment variable or a compiled-in default will be used. See the netboot(5) man page for a further explanation of different ways to specify a database. πŸ—‚οΈ
  • -F, --logfile β€” name of log file to write debugging output to. The default is to print all verbose output to stdout.
  • -h, --help β€” print short usage information.
  • -H, --home-dir β€” specifies the home directory. If this command line option is not given, either the homedir configuration file parameter or the current user’s home directory will be used. 🏠
  • -i, --infile FILE β€” pathname of input file. If not given, standard input is used.
  • -I, --includes PATH β€” path to search for MGL include files. It is possible to specify more than one path with this option. However, all path names have to be seperated by a colon (like in the PATH environment variable). mknbi-mgl then searches within all directories for required include files in the order the directory names appear in the PATH value. If this command line option is not specified, the value of the includes parameter in the configuration file or a compiled-in default will be used.
  • -l, --loader FILE β€” specifies the name of the primary netboot loader file. If this option is not given, mknbi-mgl will use a built-in loader depending on the -t or --filetype command line option. Specifying an external loader file is usually only necessary for debugging purposes.
  • -L, --libdir DIR β€” pathname of the non-shared-data directory. If this option is not given, the value of the NETBOOT_LIB environment variable or a compiled-in default will be used.
  • -N, --datadir DIR β€” pathname of the shared-data directory. If this option is not given, the value of the NETBOOT_DATA environment variable or a compiled-in default will be used.
  • -o, --outfile FILE β€” pathname of output file, which is lateron loaded by the bootrom via TFTP. πŸ“‘
  • -r, --runtime FILE β€” specifies the name of the MGL runtime binary file. Giving this option is usually not necessary because mknbi-mgl can use an internally builtin runtime binary.
  • -t, --filetype STRING β€” with this option it is possible to specify the type of output file generated by mknbi-mgl. The following types are available:
    • plain β€” this file type does not contain any primary boot loader. Programs generated with this file type can not be run without a debugger.
    • netboot β€” the netboot file type allows the mknbi-mgl output file to be used as a boot image file for a netboot bootrom.
    • pxe β€” with this file type the resulting output file can be used as a boot image file for commercial PXE bootroms.
    • dos β€” this will produce an output file which is in DOS .EXE format, and which can be executed directly on any DOS system. However, the DOS image does not have access to any network functions. It is therefore not possible to load a new operating system with the MGL program. The resulting DOS program expects the name of a binary file on it’s command line, which contains the contents of a BOOTP/DHCP packet. It’s contents will then be provided to the MGL program. If no such binary file name has been specified, the DOS program provides a simple default BOOTP/DHCP packet for use by the MGL code. πŸ’€

The default file type depends on the outtype parameter in the configuration file, or to netboot if no default type has been specified.

  • -v, --version β€” print version information.
  • -x, --verbose β€” verbose output to let you know what mknbi-mgl is doing. Each -x increases the verbosity level. πŸ—£οΈ

🧚 MGL SYNTAX 🧚

The Menu Generation Language MGL is very similar to the Pascal programming language. However, it does not support the full Pascal command set, while it adds some commands which allow an easier design of menus. cute, right? πŸŽ€

πŸ’¬ Comments πŸ’¬

Comments are delimited with curled brackets β€” e.g. in the same way as in standard Pascal. In addition, comments can also be delimited with (* and *). However, in addition to the Pascal notation, MGL also treats everything following a double-slash as a comment up to the end of the line. so // is your bestie too. πŸ’¬

πŸ€– Comment commands πŸ€–

In order to modify the behaviour of the MGL compiler, it is possible to specify single-letter commands within bracket-delimited comments. Immediately following the open bracket and an optional space or tab has to come a dollar sign, then the command letter and, following another space or tab, a command argument. The following commands are currently implemented:

  • {$I "FILE"} β€” include another file into the current MGL source. If the file name of the include file is not absolute, the file will be searched along the path specified with the -i command line option.
  • {$H STACKSIZE} β€” specify the stack size to use for the generated MGL program. The builtin default should usually be enough, but in some situations it might be necessary to increase the stack size. However you should note that the maximum size of the data segment including the stack can not be larger than 64 kB. The minimum value for the STACKSIZE parameter, which is also the default, is 8192 (e.g. 8kB), and the maximum value is 32768 (e.g. 32kB). If multiple H commands are given in an MGL source file, only the last occurring commands will have an effect. 🧠
  • {$C CHARSETNUM} β€” change the character set of the input file. The following numbers are supported:
    • 1 β€” ASCII
    • 2 β€” UTF-8
    • 3 β€” LATIN-1 (aka ISO-8859-1)
    • 4 β€” LATIN-9 (aka ISO-8859-15)

It is only possible to use this command on a system which uses ASCII as it’s base character set, and it does not work on EBCDIC systems. Using this command is useful if character constants in the MGL source file contain non-ASCII characters of foreign languages. mknbi-mgl will convert those characters so that they look correct on the target system which runs the MGL boot image. However, you should note that the target system can only display a limited set of characters, and all characters which can not be displayed, will show up as an inverted question mark. 🌍

  • {$S [+|-]} β€” the MGL compiler can decode strings in two different ways. One way is to write strings like in Pascal, with a single quote at the beginning and the end, and just as character constants as well. This mode can be activated using a - argument to the S command. With a + argument it is possible to modify the string handling so that string constants can be written like in C, with a double quote at either side. This makes it possible to write character and string constants differently within the MGL code. The C mode is activated by default. πŸ”€
  • {$B [+|-]} β€” with MGL it is possible to directly call functions built into the runtime module by prepending the string _builtin_ to the name of the function. This is necessary in case a builtin function has been redefined in the MGL source code. Using the - argument to the B command it is possible to turn off the decoding of the _builtin_ prefix.

πŸ”’ Reserved words πŸ”’

The following words are reserved and can not be used as identifiers: array, at, begin, break, const, default, do, else, end, function, if, item, of, procedure, record, repeat, restart, return, select, then, type, until, var, while, with. Unlike Pascal MGL is case sensitive. no cap. 🚫

🧩 General layout of a program 🧩

An MGL program consists of declaration blocks like Pascal. Those declaration blocks are constant declarations, type declarations, variable declarations, function declarations and procedure declarations. All declaration blocks can be mixed and don’t have to follow in any specific order. Each block has to be terminated by a semicolon, except for the program block which has to end with a dot. Every declaration block has to be introduced by a keyword:

const β€” define symbols with constant values. Each such definition has to look like:

<constname> = <value>;

The value can be any scalar or string expression, but has to be constant. Variable names in constant declarations are not allowed. For string constants the string value has to be delimited by double quotes, which is unlike Pascal. You can use a backslash to define special characters within the string:

  • \r β€” Carriage return
  • \t β€” Tab
  • \n β€” new line
  • \b β€” back space
  • \f β€” form feed
  • \<octal number> β€” the character represented by that octal number

Character constants are written in single quotes, and ngl you can use the exact same backslash notation you'd use in strings inside them too πŸ’… β€” but bestie, a character constant literally cannot be longer than one character, that's the whole vibe.

Any character inside a string or a character constant gets converted from the host representation into whatever byte representation your target (e.g. an Intel PC) architecture uses. The catch: only characters defined in the ASCII character set will display correctly on the target system 😭. All the special characters β€” block graphics, national language characters, the whole squad β€” will not be converted. You gotta define those yourself with the backslash notation above, matching the standard PC character set. No cap.

Boolean constants are represented by the values true and false. These are predefined constant values, where the ordinal number of false is 0, and the ordinal number of true is 1. And heads up β€” unlike Pascal, MGL is case sensitive, so these boolean constant values have to be given in all lower case fr fr. True is not it. πŸ™ˆ


✨ type

Define a new type identifier. Assigning a type to an identifier looks like:

<typename> = <type>;

See below for a further discussion about types 🫢

🌸 var

Define a new variable. The syntax is:

<varname> : <type>;

This generates a new variable named <varname> of type <type>. That <type> can be a standard type, or a <typename> you defined in a type section as explained above πŸ‘‘

πŸ’– function

A function is defined the same way as in Pascal. After the keyword function you give the function name, followed by the arguments in round brackets, a colon, and the return type of the function:

function <funcname> ( <arguments> ) : <type>;

The arguments are specified like variable definitions, seperated by semicolons. By default, all arguments to a function are passed by value β€” including non-scalar arguments like strings. If the var keyword is given in front of an argument specification, that particular argument is passed by reference instead. And since passing an argument by value requires copying it onto the stack (which is space and time consuming with strings 😩), there's an additional keyword: const. If it's given instead of the var keyword in front of an argument specification, that argument is passed to the function by reference, but cannot be altered by the function. This is an extension to the Pascal standard. Unlike Pascal, MGL does not support functions or procedures as arguments β€” periodt.

Within a function, the name of the function serves as a variable for the return value. So assigning a value to <funcname> is literally you setting the return value of the function ✨

After the function header, you can define variables, types, constants, procedures and functions local to that function. The function body starts with the keyword begin and ends with the keyword end. In between those two keywords, only commands are allowed, and all commands have to be seperated from each other using a semicolon. Cute shortcut: the keyword begin can be abbreviated with a { character, and the keyword end can be abbreviated with a } character. Iconic.

πŸŽ€ procedure

A procedure is like a function that does not return a value. So defining a procedure is very similar to defining a function:

procedure <procname> ( <arguments> );

Defining the arguments is the same as with functions. Of course, it is not possible to assign a value to <procname> because there is no return value. Obviously πŸ’…


πŸ¦‹ Data types

There are two general types: scalar and non-scalar. Scalar types are those which can be internally represented by a number. All others β€” including records, arrays and strings β€” are non-scalar. Each possible value of a scalar type has an ordinal number, so scalar types are defined by their range of ordinal numbers. Slay.

The following basic types are already predefined:

  • integer β€” a scalar type with an internal data width of two bytes. Its range of values is from -maxint to +maxint, where maxint is a predefined constant with a value of 32767.
  • string β€” different from Pascal, which only knows about strings of a constant length. Strings in MGL can have a variable length. The maximum length for a string is 255 characters, and you can reduce that by appending a maximum length in square brackets to the type identifier: string[16] therefore limits the maximum length of a string to 16 characters πŸ“
  • char β€” a scalar type with an internal data width of one byte. Its ordinal numbers range from 0 to 255.
  • boolean β€” a scalar type with an internal data width of one byte. Its only possible ordinal numbers are 0 and 1.
Note that there is no floating point type in MGL πŸ’€. Like Pascal and unlike C, MGL is very strict regarding types. For example it is not possible to assign an integer value to a character variable.

In addition to these basic types, it's also possible to define additional scalar and non-scalar types. User defined scalar types can be enumerations or subrange types:

πŸ’œ Enumeration

The enumeration type consists of named elements, which have to be specified in round brackets. All named elements are separated by commas from each other, and get assigned an ordinal number starting at zero. The following declaration defines an enumeration type with five elements:

( Mon, Tue, Wed, Thu, Fri )

where Mon gets the ordinal number 0, and Fri gets the ordinal number 4. Main character energy from Mon ngl 🌈

⭐ Subrange

A subrange type is based upon one of the basic scalar types integer, char and boolean. It's specified by giving two constant values, seperated by three dots. For example, the following declaration defines a subrange type with 26 elements:

'A'...'Z'

This subrange type consists of character elements, ranging from the character 'A' to (and including) the character 'Z'. Declaring a variable with this type, and assigning it a value of 'a' will result in a runtime error β€” yes, really, don't do it 😭. Subrange types can be useful for specifying array subscripts. Even character or boolean subrange types can be used for subscripts. Versatile much?

🧚 User defined non-scalar types

User defined non-scalar types can be records and arrays:

  • Record β€” a record is a collection of elements with different types. It's defined with the keyword record. Following this keyword, all elements of the record have to appear, with the same syntax as with variable declarations. The record definition is terminated by the end keyword. Unlike Pascal, MGL does not implement variant record elements (which are similar to unions in C). Sorry bestie.
  • Array β€” an array is a collection of elements with the same type. It's defined with the keyword array followed by a scalar type in square brackets, the keyword of, and the type specifier for the elements. The following are examples of array definitions:
array [char] of integer

array [10...20] of char

In the first example an array of integers is defined with a number of 256 elements (the ordinal number range of the char type), while in the second example an array of characters is defined with a number of 11 elements. Note that unlike Pascal, an array of characters is handled differently than a string type πŸ‘€


πŸ”₯ Expressions

An expression is a set of operations applied to variables and constants. The operations allowed differ for the types involved in the expression. There is no possible operation for array and record types (which is in contrast to Pascal, where it's possible to compare variables with these types). The following operations are available:

πŸ’… Numerical operations

These include addition (+), subtraction (-), multiplication (*), division (/ or div) and the modulo operation (% or mod). All these operations can only be applied to numerical values (i.e. values of integer type and integer subrange types), and the result has the same type as the types involved in the operation. Also there are two predefined functions which operate on integer values: abs returns the absolute value of its argument, and sqr returns the square value of its argument. In addition to these operations it's also possible to apply boolean operations to numerical values: and, or, xor and not. These apply bitwise on the numerical values, and the result is numerical again. Bit manipulation ate, honestly 🫢

🌸 String operations

The only valid string operation is the addition (+). This will append the string on the right side of the plus sign at the end of the string on the left side. The result is always a string. If the result gets assigned to a variable, it will be automatically truncated if it exceeds the maximum length allowed for the variable. No error, just vibes βœ‚οΈ

There are three special operations with string variables:

  1. When the variable name is followed by a dot and the keyword len, the result is a numerical value and specifies the actual length of the string.
  2. Similar to arrays, giving a numerical value in square brackets to a variable returns the character at the corresponding string position. It is an error to use a numerical value which is larger than the maximum string size. However, no error will be generated when an index is used which is beyond the actual end of the string, but still within the limits of the string variable. πŸ™ˆ
  3. As an addition to this index operation, it's possible to extract a substring of a string variable by specifying two numerical index values, separated by a comma and surrounded by square brackets.

πŸ‘Ύ Character operations

Okay so bestie, characters are literally social β€” they can be added together just like strings πŸ’–. But plot twist: the result is always a string, no cap. Same energy when you add a character to a string β€” char plus string, still giving string.

The other iconic char move is multiplication (*). Left side of the * has to be a character value, right side has to be a numerical value. The character gets repeated as many times as that number says. So yes, the result is always a string too. Periodt.

And then there's the special one: the chr function 🧚 β€” it converts a numerical value into a character. Unlike Pascal (sorry Pascal), only the lower 8 bits of the numerical value matter, and no runtime error gets generated even if the number is bigger than the largest ordinal value for characters. She just vibes and truncates, iykyk.

πŸŽ€ Boolean operations

The normal boolean operations and, or, xor and not can be applied to boolean values, and the result is boolean again β€” main character energy, always comes back as itself ✨.

Comparisons (like =, <>, >, <, >=, <=) always give a boolean result regardless of the types involved. But there's a rule: you can only compare two values which are assignable to each other. And in contrast to Pascal, comparing arrays and records is not possible πŸ™ˆ.

There's also a predefined boolean function: odd. It returns true if its numerical argument is odd, and false if its argument is even. Simple, iconic, no notes.

🌈 General scalar operations

For all scalar values, the ord function returns their ordinal values. The result of this function is always numerical.

Then we've got succ and pred β€” they increase respectively decrease the corresponding scalar value to the next ordinal value. And heads up: it will result in a runtime error to increase or decrease out of the range of the corresponding scalar type. So like, don't do that πŸ’€.


When the result of an expression turns out to be constant, it gets collapsed by the compiler and treated as one single constant value. This is also true for most of the builtin functions like chr or ord.

Whenever it's required to use a constant value β€” for example for defining an integer subrange type, or for specifying a maximum length for a string β€” it is possible to use an expression which consists only of constant values. These can also be named constants like maxint or user defined constants 🫢.

πŸ’… Commands

MGL knows of some commands in addition to Pascal to ease the generation of menus. Also, some Pascal commands are not implemented β€” especially for and case 😭. The following commands are implemented in MGL:

  • while <boolean expression> do β€” repeat the following command or block of commands, delimited with begin and end, as long as the boolean expression returns true πŸ”.
  • repeat <commands> until <boolean expression> β€” repeat the commands until the boolean expression returns true. In contrast to while, this command does not allow the <commands> block to be delimited by begin and end.
  • if <boolean expression> then <command 1> else <command 2> β€” execute command 1 if the boolean expression returns true, otherwise execute command 2. Both commands can also be command blocks delimited by begin and end. The keyword then is optional and can be left out. However, if a command 2 is specified, the else keyword is required. With all these commands affecting program flow, the keywords begin and end can be replaced with the characters { and } respectively πŸŽ€.
  • print <expression> at [ <x> , <y> ] β€” prints the result of the expression onto the screen at the position pointed to by the two numerical expressions x and y. The position specification is optional; if it's not given, the expression gets printed at the current cursor position. The expression to be printed has to result in a numerical, string or character type ✨.
  • get <variable> at [ <x>, <y> ] with timeout <expression> β€” read a value into the variable using the timeout in seconds given by the numerical expression. If no coordinates are given, the current cursor position gets used. The variable has to have a numerical, character or string type. It will be echoed to the console while reading. Note that you will get a runtime error when the user enters a numerical value which is larger than the range for the numerical variable β€” however, entering values larger than +/- maxint is not possible, so a runtime error will not occur with an integer variable πŸ™. When no timeout is given, the get function will wait forever for proper user input. If a timeout is given, the variable receives the value entered so far when the timeout occurs.
  • select at [ <x>, <y> ] with timeout <expression> of <items> end β€” a complex command which operates similar to the get command. However, it only allows entering a numerical character from x and y coordinates and with a timeout given by the numerical expression. Depending on what key the user pressed, a specific item gets selected. When no key is pressed, the default item is executed. If the user pressed a key for which no item is defined, no action is taken and another key press is waited for. select operates in an endless loop, which can only be left by using the keywords break, return and restart πŸ‘‘.
  • item <constant numerical expression> : <commands> β€” the way you define an item for select (this is the first of the two forms). The constant numerical expression has to have a value from 0 to 9, corresponding to the keys which are accepted by select. There is no limit concerning the number of commands to be executed when the corresponding item is selected. They must not be bracketed by begin and end.
  • default : <commands> β€” the other form of defining an item for select.
  • load <string expression> from <IP address> gateway <IP address> β€” this command lets the program return to the bootrom and tell the bootrom to load the file named by the string expression from the system given by the first IP address, using a gateway given by the second IP address πŸ¦‹. The string expression does not have to be constant, but the IP addresses must be constant, because the runtime module presently lacks a hostname resolver. However, the IP addresses can either be specified in dotted format like 111.222.111.222 or as a string constant expression. If it's a string constant, the compiler treats it as a host name and resolves the name into an IP address at compile time.
  • gotoxy at [ <x> , <y> ] β€” move the cursor to the position on the screen given by the two numerical expressions x and y 🌸.
  • restart β€” continue operation at the beginning of the current procedure or function πŸ”.
  • break β€” leave the innermost while, repeat or select command and continue execution at the end of the loop πŸ’….
  • return β€” terminate the current function or procedure and return to the caller.
  • <variable> := <value> β€” assign a value to a variable. The value has to be assignable to the variable regarding types.

🧚 Scope of symbols

All symbols which are not keywords can be redefined in a different nesting level. So while it's not possible to have two symbols with the same name within one procedure or function, the same symbol names can be used again in a different procedure, or in a procedure which is local to the current procedure ✨.

This also applies to internally defined symbols, which have an implicit nesting level of -1, which is lower than any other level. Therefore you can redefine internal procedures or functions like print or get 😌. Yes bestie, you can absolutely shadow the builtins.

πŸ’œ BOOTP or DHCP values

When the MGL program gets loaded and run by a bootrom, it gets passed the BOOTP answer or DHCP ack record the bootrom received from the boot server πŸ“‘.

It's possible to access the BOOTP tags in this record from within the program by using the special variable construct $[<expression>]. The expression has to be numerical, and defines the number of the tag in the BOOTP RFC vendor data area. The type of this special BOOTP variable is always a string πŸ’–. You can use this kind of variable within string expressions, or assign a value to it like with any other variable. When the MGL program terminates, the so modified BOOTP record gets passed back to the bootrom.

Using this method it's possible to pass special BOOTP tags to the operating system loaded next by the bootrom. But note that the bootrom only allows a maximum size for the BOOTP record (which depends on the type of the bootrom used). When this space gets exceeded, the resulting tags are truncated ⚠️.

To make it easier to access the BOOTP tags, some constants are predefined:

  • BOOTP_HOSTNAME β€” Tag 12: the hostname of the client. This tag is only present in the BOOTP record if you specified the hn flag in /etc/bootptab on the server (or the equivalent specification in the DHCP server configuration).
  • BOOTP_DOMAIN β€” Tag 15: the domain name of the client. This tag is only present if you specified the dn tag in /etc/bootptab on the server.
  • BOOTP_ROOTPATH β€” Tag 17: the path to the root directory of the client. This tag is only present if you specified the rp tag in /etc/bootptab on the server.

So, for example, to access the client's root directory on the server, you can use $[BOOTP_ROOTPATH] in your MGL program πŸ“.

In addition to this there are also two read-only variables predefined:

  • hostname β€” this string variable gives the client's host name. It has the same value as $[BOOTP_HOSTNAME].
  • servername β€” this string variable contains the name of the BOOTP server which answered the request of the boot rom. Since this name is not encoded as a tag in the BOOTP record, using this variable is the only way of accessing the server name πŸ‘‘.

πŸ—‚οΈ DATABASE FILE

mknbi-mgl does not use any sections in the configuration file except the [general] section. However, for easier maintenance of different client configurations, the database file can be used πŸ’….

The entries have to be preceded by a [mknbi-mgl:<system name>] tag and can contain the following parameters:

  • outfile β€” specifies the name of the output file.
  • infile β€” specifies the name of the input source file.
  • debug β€” this boolean parameter controls the generation of debugging code πŸ›.
  • noheader β€” if the boolean argument of this parameter is true, no boot image header will be produced, so that the output file can be run as a COM program under DOS. This is useful for testing πŸŽ€.

🌸 ENVIRONMENT VARIABLES 🌸

okay so this one's short and sweet bestie β€” mknbi-mgl doesn't have its own little ecosystem of env vars, nope. It just uses all the environment variables that netboot uses, and those are fully documented over in the netboot(5) man page. So if you're like "what does this variable even do 😭" β€” that's your field trip. Go read netboot(5). πŸ’…


πŸ’€ BUGS πŸ’€

ngl, the author is fully upfront about the messy parts, and honestly? respect.

  • The code generator needs to be cleaned up. Like, it works, but it's giving "my room during finals week" energy. πŸ™ˆ
  • There is almost no optimization. None. Zero. It's not out here being efficient, it's just out here being correct-ish.
  • Error handling and reporting has to be better β€” right now the error messages are not serving the way they should. 😭
  • And because this is alpha code, there are probably still many undiscovered bugs in the compiler. Not "maybe." Probably. So like… vibe check your output before you ship it, bestie. 🫢
fr fr: alpha code means alpha code. Don't come crying to the author when the compiler does compiler things.

πŸ”₯ SEE ALSO πŸ”₯

(these two are your besties for the whole netboot ecosystem β€” go say hi πŸ’œ)


πŸ‘‘ AUTHOR πŸ‘‘

mknbi-mgl was written by Gero Kuhlmann <gero@gkminix.han.de>. Iconic. We stan. πŸ’–


✨ aaaand that's the whole page, bestie ✨ You made it to the very end β€” main character energy fr. πŸŽ€ This little rewrite is serving the 04 Dec 2004 era of MKNBI-MGL(8), so if you're out here reading it decades later, just know the vibes are preserved but the bugs are also preserved. πŸ’€ slay, go compile something. πŸ¦‹

πŸ“¦ where this page comes from