diff options
Diffstat (limited to 'docs/history/10-zc/code/README.md')
| -rw-r--r-- | docs/history/10-zc/code/README.md | 300 |
1 files changed, 0 insertions, 300 deletions
diff --git a/docs/history/10-zc/code/README.md b/docs/history/10-zc/code/README.md deleted file mode 100644 index c9576c3..0000000 --- a/docs/history/10-zc/code/README.md +++ /dev/null @@ -1,300 +0,0 @@ -# tinyvm zygote: notes and transcript - -a tiny stack cpu that started as a c89 code golf entry and ended up as a self-hosting runtime. a small native core runs vm code (jit-compiled to arm64, or interpreted anywhere else), and the genome it boots is a compiler for zc, a c-like language that keeps the forth habits: the compiler lives in vm memory next to your program, your program can call into it, patch its tables, add syntax to it and compile new code while it runs. heap objects carry an ownership tag (u, m or d) that a static analyzer inside the compiler checks, with deterministic frees at scope ends and a mark-sweep collector underneath for everything else. - -this folder only has the latest code. earlier stages are described in the transcript below, but their code isn't included, except the asm-only genome from stage 7, which sits in `legacy/`. - -## files - -`core.c` is the native core and the only native code at runtime: the arm64 jit, its stubs and invalidation logic, and a portable interpreter used on every other host. `src/rt.zc` is the runtime (i/o, software division, the heap and the collector) and `src/compiler.zc` is the compiler, both written in zc. together they are the genome. `zc0.c` is stage 0 of the bootstrap, a compiler in c for the subset of zc that `compiler.zc` is written in. `tools/build.zc` is a small zc program that makes the resident compiler compile its own source into a fresh image, and `mkgenome.c` turns that image into `genome.h`, which is generated and checked in. `examples/` has hello, factorial, echo, self-patching code, a list that owns its elements, and a repl. `tests/` has feature tests, plus `tests/errors/` with programs the analyzer has to reject (each starts with the error it expects) and `tests/runerr.sh` to run them. `test.sh` does the whole bootstrap and runs everything on both the interpreter and the jit. `legacy/` has the old asm genome. - -## build - -``` -cc -O2 core.c io_linux.c -o zy # io_linux.c provides Q, G and J -./zy <examples/hello.zc -``` - -on arm64 that builds the jit, anywhere else the interpreter (`-DZYINTERP` forces the interpreter on arm64 too). `J` is a `char` by default, so compile with `-DJTYPE=int` if yours is an int. the source ends at eof or at a backtick, and anything after the backtick stays unread for the program itself. - -regenerating the genome after changing `src/`: - -``` -cc zc0.c -o zc0 && cc mkgenome.c -o mkgenome -cat src/rt.zc src/compiler.zc | ./zc0 >genome.h # stage 0, from c -cc -O2 core.c io_linux.c -o zy -(cat tools/build.zc; printf '`'; cat src/rt.zc src/compiler.zc) | ./zy | ./mkgenome >g.h -mv g.h genome.h && cc -O2 core.c io_linux.c -o zy # now built by itself -``` - -run the last two lines once more and `genome.h` should come out byte for byte the same. `test.sh` checks that. - -## zc, the language - -it reads like c and behaves like b: there are no types, every value is a 32 bit word, and a heap object is an array of words. memory is word addressed, so `p + 1` is the next word and strings are one char per word with a 0 at the end. - -``` -const N = 10; -var table[N], count; // globals: scalars and arrays, constant initializers or "strings" -fn fib(n) { if (n < 2) return n; return fib(n - 1) + fib(n - 2); } -fn main() { - for (var i = 0; i < N; i++) table[i] = fib(i); - var sq = { var t = table[5]; t * t }; // a block is an expression too, its last line is its value - var x = asm { 2 3 + }; // raw vm code, leaves one value - putn(sq + x); putc(10); - return 0; -} -``` - -statements are `var`, `if`/`else`, `while`, `for`, `break`, `continue`, `return`, `{ }` blocks, `asm { }` and expressions. operators are c's, with c's precedence: `* / % + - << >> < > <= >= == != & ^ | && ||`, unary `- ! ~ * &`, `++`/`--` both ways, `=` and the compound assignments, indexing `a[i]` (which is `*(a + i)`), and calls. a call through a value works (`f(x)` when `f` holds an address). comparisons and `&& ||` give 0 or 1. `/`, `%` and `<<` call into the runtime, since the cpu has no divider and only a right shift. character literals, `0x` hex and the usual escapes are there. there's no `?:`, no `switch` and no struct syntax. - -a function is called by name once it's declared, so mutual recursion works without prototypes, except that a function with tagged parameters has to be declared before a checked call to it (`fn f(d p);` is a prototype). `main` runs after the whole program compiled. every function returns one word, 0 if it falls off the end. - -builtins come from the runtime: `putc`, `getc`, `eof`, `puts`, `putn`, `halt`, `strlen`, `streq`, `wcopy`, `wfill`, `new`, `free`, `gc`, `isobj`, `osize`, `live_objects`. - -## memory model: u, m and d - -`new(n)` gives back a fresh object of n words. bind it to a local and the local owns it, tagged **m**. tags live on local variables and parameters, the analyzer follows them through the code, and the compiler inserts `free` calls where owned objects die. - -**m** means the object belongs to this block and has to be used in it. you can lend it to callees as often as you like (to parameters declared `m`), and when the block ends it's freed. an m object that's never used anywhere in its block is an error. - -**d** means the object is going down. `x as d` recasts it, and the first time it's handed to a lower scope its life here is over: passing it to a parameter declared `d`, or touching it inside an inner block, which then owns it as m and frees it when that block ends. using it afterwards is an error, and so is ending the block with a d object that never left. - -**u** means the object is going up. `x as u` recasts it, and from then on it has to leave this block for the one above, where it arrives as m. it leaves through `return` (the function has to be declared `-> u`), as the value of a block expression, or by assignment to a variable of the enclosing block. letting it fall out of scope, or throwing away a u result, is an error. - -recasting only goes away from m: m can become u or d (and u and d can swap), but nothing goes back to m in the same scope. a `d` parameter arrives as m. an `m` parameter is a borrow: the callee can read and write it, but it can't free it, return it, recast it or store it anywhere. - -``` -fn box(v) -> u { var b = new(1); b[0] = v; return b as u; } -fn push(m list, d item) { list[list[0] + 1] = item as d; list[0]++; return 0; } -fn main() { - var l = new(9); - var b = box(5); // u from box becomes m here - push(l, b as d); // l is lent, b is given away - return 0; // l is freed here, and b with it, since l owns it -} -``` - -containers own what you store in them. `p[i] = c` needs `c` to carry the same tag as `p` (or be a fresh object), and from then on `c` follows `p`: if `p` is recast, so is `c`, if `p` is given away, `c` goes with it, and when `p` is freed its children are freed too. a child can't be recast or given away on its own. storing into an object you only borrowed is giving the child away, so it needs `as d`. the runtime records the owner in the child's header, so freeing a container frees exactly the objects it owns. - -globals, plain pointers and arrays aren't tracked. storing an owned object there needs `as d`, and the mark-sweep collector takes it over. a fresh object can go straight in. - -branches have to agree: at the end of an `if` every variable has to be in the same state on both paths (a path that returns or breaks doesn't count), and a loop body has to leave every outer variable the way it found it, so a d object can't be given away inside a loop that might run twice. - -### the analyzer - -it runs inside the compiler on every function, including code compiled at runtime by `eval` and friends, and stops at the first violation with a line number and the variable's name. `tests/errors/` has one small program for each rule. three ways to turn it off: `unchecked fn f() { }` for one function, `unchecked { }` for a block, and a top-level `unchecked;` for the rest of the unit (a parse word can also set `check_on = 0`). unchecked code doesn't track anything, so whatever it allocates belongs to the collector, and checked variables can't be touched from inside an unchecked block. what it doesn't see: a value that isn't a bare variable name (`p + 0`, `p[0]` read back out) is a plain word, so pointer arithmetic and reading pointers out of containers make untracked aliases. that's the escape hatch, and also the hole. - -## the heap and the collector - -the heap is 0x40000..0xA0000. every block is a power of two words with a 5 word header (size, class, flags, a check word, owner) and comes from a free list per size class or a bump pointer, so `new` and `free` are a few instructions in the common case. checked code frees deterministically and never needs the collector. the collector is mark-sweep, non-moving and conservative: roots are the program area, the genome's globals, the compiler's input stack, the live frame stack and the data stack region, and a word counts as a pointer only if its object header checks out. it runs when you call `gc()` (which returns how many objects it freed) or when `new` runs out of room. objects never move, so raw addresses stay valid, which keeps the forth habits working. - -## code as data - -the compiler is in memory with your program and every name in it is callable. the pieces: - -`asm { }` emits raw vm words: numbers push, `-5` pushes a negative, `#n` emits a raw word, one-char tokens from the mnemonic table are opcodes, `:name` defines a label local to the block, and other names push the address of a variable or function. - -`quote { ... }` is a token list as a fresh object: `[n, kind, value, kind, value, ...]`, where names, strings and operators are interned pointers (`intern("while")` gets one). `tokens(s)` makes one from a string, `qdup(q)` copies one. - -`eval(q)` compiles a token list as a function body and runs it, `compile(q)` returns its address instead, `load(q)` compiles top-level declarations, and `evals(s)` does `eval` on a string. - -parse words: `immediate fn name() { }` runs at compile time whenever `name` shows up as a statement, inside an expression or at the top level. it starts with the current token on its own name and works the compiler directly: `next()`, `tk`/`tv` (current token kind and value), `expect`, `accept`, `expr()`, `stmt()`, `substmt()`, `emit`, `lit`, `op`, `hole`/`setlit` for jumps, `here`. `grab()` reads a `{ }` group into a token list and `feed(q)` pushes a list back into the input, which is all a macro needs. parse words can call `eval` mid-compile; the compiler saves its state and emits the new function in place with a jump around it. the built-in keywords are entries in the same table, so a parse word called `while` replaces `while`. - -tables: `add_binop("**", 11, "ipow c")` adds or replaces a binary operator with a precedence and its code (written like asm, names resolve when it's used), `add_unop` does the same for prefix operators, and the lexer learns new spellings from them. `mnem` is the asm mnemonic table, `q@!$_~cjzr,?.+-*&|^><`, and can be rewritten like before. - -``` -fn ipow(a, b) { var r = 1; while (b > 0) { r *= a; b--; } return r; } -immediate fn pow_op() { next(); add_binop("**", 11, "ipow c"); return 0; } -pow_op // runs now, at compile time -immediate fn repeat() { // repeat 3 { body } - var n, body, i = 0; - next(); n = tv; next(); - body = grab(); - while (i < n) { feed(qdup(body)); i++; } - free(body as d); - return 0; -} -fn main() { repeat 2 { putn(2 ** 10); putc(10); } return 0; } -``` - -## the bootstrap - -`zc0.c` compiles the subset `compiler.zc` uses (no analyzer, quote, parse words or block expressions) into a first genome. that genome compiles `compiler.zc` again, running on the vm, which is where the real compiler and the analyzer first see their own source, and `compiler.zc` passes its own lifetime checks. the result compiles itself into identical words, and that's the `genome.h` that ships. an image build (`build_image(org, dst)`) compiles with only the image's own names visible, writes code for one address into another, and appends a symbol table so the booted genome knows its own names. - -## the instruction set - -unchanged since stage 3. every word is 32 bits. an odd word is a literal that pushes `w>>1` (31 bits, sign extended). an even word is an opcode, `w/2`. there's a data stack and a return stack, both stored inside memory. - -``` -0 q halt 5 ~ swap 10 , in (Q) 15 * mul 20 < less -1 @ load 6 c call 11 ? empty (J) 16 & and -2 ! store 7 j jump 12 . out (G) 17 | or -3 $ pick 8 z jz 13 + add 18 ^ xor -4 _ drop 9 r ret 14 - sub 19 > shr -``` - -stack effects: `!` is `(v addr --)`, `$` is `(n -- copy of the nth item)` so `0 $` is dup, `z` is `(cond addr --)` and jumps when cond is 0, and `c` and `j` take their target from the stack. there's no div or mod, because a divider can't finish in one cycle in hardware. division is done in software. - -## memory map and abi - -memory grew from 64k words to 1m (1048576) words, since a self-hosted compiler and a heap don't fit in 64k. nothing in the instruction set changed. - -``` -0x00000.. the program: code, with globals between functions -0x40000..0x9FFFF heap -0xA0000 frame pointer word -0xA0100..0xBDFFF compiler data: interned names, symbol table, locals, tables -0xC0000.. genome: 0xC0000 entry, 0xC0005 symbol table address, 0xC0006 image end -0xE0000..0xEFFFF frame stack, grows down from 0xF0000 -0xF0000.. return stack, grows up -0xF8000.. mark stack -0xFC000..0xFFFF0 data stack, grows down -``` - -calls push the arguments left to right and the callee's prologue pops them into its frame. locals live in the frame at `*0xA0000 + slot`. every function leaves exactly one word on the data stack. - -## how the core works - -registers: x19 holds the memory base, x20 the data stack pointer, x21 the return stack pointer, x22 the translation table and x23 the state block (memory, s, r, table, coverage map, Q, G, &J, store hook). - -compilation is lazy. the first time control reaches an address, that block is translated from live memory. a block ends at call, jump, ret or halt. a `z` doesn't end it: the not-taken side keeps compiling, which makes superblocks. a literal followed by `c`, `j` or `z` is fused into a constant target. every exit goes through a 5-instruction dispatch stub that bounds-checks the target, loads `tab[t]` and branches to it directly when it's already compiled. otherwise it returns to c, which compiles the block and re-enters. - -self-modification is handled with a byte map of which words some live translation covers. a store checks that byte inline: an uncovered target, the usual case, gets a plain store. a covered target calls the hook, which drops every translation containing that word and makes the jit code return to c at the next instruction, so even patches to the very next word take effect. writing back the same value is a no-op. if the 16 mb code buffer fills, everything is flushed. - -platform: arm64 for the jit. the darwin path uses `MAP_JIT`, `pthread_jit_write_protect_np` and `sys_icache_invalidate`. linux uses rwx mmap and `__builtin___clear_cache`. only the linux arm64 path has actually been run, under qemu. the darwin path follows apple's documented recipe but hasn't been tested. under the hardened runtime it also needs the allow-jit entitlement. every other host gets the interpreter, which has the same semantics: shift counts wrap at 32, stores outside memory are ignored, loads outside memory read 0, and jumping outside memory halts. - -## test results - -`test.sh` passes all 27 checks on x86-64 linux (interpreter) and on linux arm64 under qemu (jit): the fixpoint, an up-to-date `genome.h`, every example and feature test on both paths with identical output, all 24 programs in `tests/errors/` rejected with the expected message on both, and the jit's self-compile matching too. the genome is 37505 words. the compiler compiles itself in about 0.15 s on the interpreter and 0.45 s on the jit under qemu. - -## open ends - -- branch and loop states have to match exactly. a loop that gives something away and then always breaks is rejected, even though it's fine. -- untracked aliases (pointer arithmetic, pointers read out of containers) aren't checked, and can dangle once the owner is freed. -- the collector is conservative, so a stale word on the stack can keep an object alive a little longer. it scans the whole data stack region because the vm can't read its own stack pointer. -- `?:`, `switch` and structs are missing. a parse word could add any of them. -- free lists never coalesce, so a heap full of one size class can't be reused for another until those objects die. -- translations are only reclaimed by a full flush, and the hook rescans the block list on every store to a covered word. - ---- - -## transcript - -each of my messages is quoted exactly, followed by a summary of the reply. - -### 1 - -> codegolf a tiny cpu vm in C89. the goal is to achieve the lowest character count possible. include an assembler as well (scored separately) and a hello world program (not scored, but elegance and simplicity is a bonus) -> it must be capable of re-entrant code and a realistically workable instruction set. - -**summary:** a two-stack forth-style machine. the vm was 384 bytes: one ternary chain inside `while(o=m[p++])`, the top two stack items cached in `a` and `b`, the program loaded with `read(open(...))` so it needs no headers, and both stacks inside the 65536-word memory. it had 22 opcodes including div and mod, a two-word literal, and jump/call targets popped from the stack. the assembler was 348 bytes and two-pass, using `strcspn` instead of `strchr` so the implicit int return stays safe on 64-bit. hello world was a pointer loop over a string with a zero terminator. re-entrancy was shown with a recursive factorial plus a recursive decimal printer. - -### 2 - -> your cpu should also be realistically close to hardware (translating it to logical circuitry hardware should be fairly easy) and sport an acceptably fast execution (if hypothetically ported to hardware, not in software) -> -> assume i/o is handled by two external functions, `char Q()` (getchar) and `void G(char)` (putchar) and a state variable J (boolean, shows if input is empty). -> -> don't be afraid to use macros. - -**summary:** no reply was sent to this message at the time. it was answered together with message 3. - -### 3 - -> pro tip: if you define a macro as `!o--`, you can save 2 characters per if condition -> oh and btw, newlines are free - -**summary:** used the tip as `#define X !--o?`, with pre-decrement because opcode 0 (halt) is already handled by the loop, and with the `?` folded into the macro and free newlines used as separators. the hardware-oriented redesign: bit 0 of each word is a literal flag, so literals take one cycle like the j1 cpu. div and mod were dropped because they can't finish in one cycle and were replaced by an arithmetic shift right, giving 8 alu ops that decode from 3 bits. every instruction does a fixed amount of work with at most one data memory access, and call and jump share a datapath (`r-=o`). i/o now goes through `Q`, `G` and a `?` op that reads `J`. the result was the vm at 339 bytes and the assembler at 346. the final golfed code: - -```c -#define X !--o? -int m[1<<16],p,s=65e3,r=6e4,o,a,b;main(c,v)char**v;{read(open(v[1],0),m,s);while(o=m[p++])a=m[s],b=m[s+1],o&1?m[--s]=o>>1:(o/=2,X -m[s]=m[a]:X -m[a]=b,s+=2:X -m[s]=m[s-~a]:X -s++:X -m[s]=b,m[s+1]=a:(o-=2)<1?m[r]=p,r-=o,p=a,s++:X -p=b?p:a,s+=2:X -p=m[--r]:X -m[--s]=Q():X -m[--s]=J:X -G(a),s++:(m[++s]=X -b+a:X -b-a:X -b*a:X -b&a:X -b|a:X -b^a:X -b>>a:b<a));} -``` - -```c -char t[9999][99],*o="q@!$_~cjzr,?.+-*&|^><",*q;A[9999],k,i,j,n,P;E(y){n++;write(P,&y,4);}V(char*s){for(j=k;j--&&(*t[j]-58||strcmp(t[j]+1,s)););return~j?A[j]:atoi(s);}main(){while(~scanf("%s",t[k]))k++;for(;P<2;P++)for(n=i=0;i<k;i++)if(*(q=t[i])==34)while(*++q)E(*q);else*q==58?A[i]=n:!q[1]&&o[j=strcspn(o,q)]?E(j*2):*q==35?E(V(q+1)):E(V(q)*2+1);} -``` - -### 4 - -> now let's move in stages. write a compiler that takes this assembler's code and converts it into darwin arm - -**summary:** an ungolfed c89 compiler from vm assembly to darwin arm64 assembly. every word got its own label, the image was emitted as an addressable data array, x19/x20/x21 held the memory base and the two stack pointers, and computed jumps went through a table of offsets. a literal followed by a jump became a direct branch. `Q` and `G` went through `sxtb`, since char is signed on apple arm64, and `J` was read with `ldrb`. it was verified by assembling with llvm-mc for darwin, then rewriting to linux with sed and running under qemu, where it matched the vm byte for byte. this version couldn't handle self-modifying code. - -### 5 - -> can you write the assembler in assembly and pair the code with the assembly and the assembler so that it can re-wire itself at runtime? or maybe include an interp for self-modification, otherwise fns are exec'd native - -**summary:** both. `aslib.s` is the assembler written in vm assembly as a library (`a.src`, `a.dst`, `a.org`, `a.lab`, then `a.run c`), and with `asmain.s` it's a 568-word standalone assembler. it's byte-identical to the c assembler and reaches a fixpoint when assembling itself. this is also where the `:;comment` trick came from. the compiler became a hybrid. the image is split into basic blocks, each block counts how many of its words differ from the original image, and stores into code go through a slow path that updates the counters. clean blocks run native, while dirty blocks and anything past the image run in an interpreter compiled into the same binary, which hands control back to native code whenever it lands in a clean block. `cc -t` printed `[` and `]` at each switch. the demo rewired a function at runtime to point at freshly assembled code and then restored it. - -### 6 - -> which one do you like more? and which is more performant? -> keep that one, then port to using llvm ir so that it can be platform independent - -**summary:** no reply was sent at the time. the llvm port was dropped when message 7 changed direction. the question was answered at the start of the reply to message 7: the hybrid was the favorite because it kept the vm's semantics exact. the plain compiler was slightly faster on code that never rewrites itself, but it silently ran stale code once anything did. the zygote beats both. - -### 7 - -> can you make it a zygote approach? -> a minimal core loads first, reads and compiles the program (either wholly or partially, for speed) and replaces it with itself. so it's like a jvm but self-contained and on the same level as itself, where the assembler can itself be modified in-memory to assemble different code. for this we need to ditch llvm - -**summary:** the current design, described above. the native core only does jit and glue. it loads the genome (boot loader + assembler, both vm code) at 48000. the boot loader reads source until eof or a backtick, assembles it at 0 and jumps there. compilation is lazy per block, straight from live memory, with superblocks through `z`, fused constant targets, and dispatch chained through a table. invalidation uses the coverage byte map plus a store hook, so self-modified code gets retranslated instead of interpreted. the resident assembler sits at fixed addresses, and `repl.s` rewrites its mnemonic table so `p` means out, then assembles and runs input chunks in a loop, overwriting the same address each time. verified under qemu on linux arm64, including the self-hosted assembler running on the zygote. about 4x faster than the golfed interpreter. - -### 8 - -> damn dawg you made an entire baremetal os? -> -> let's move to cowork. pack everything up alongside a transcript of every message i've sent and your own notes on what you've implemented in an md file, with the code, so i can move it. - -**summary:** not quite an os. it's a userland runtime. it still runs on darwin or linux, uses their mmap for executable memory, and gets i/o from the host through `Q`, `G` and `J`. there's no scheduler, no drivers and no hardware access, so a closer description is a tiny self-hosting jit, in the spirit of a jvm. the first packing attempt bundled every stage's code with a full test script. - -### 9 - -> nono we don't need all the code, just the recent ones. my msgs and your summaries are more important and all should be there - -**summary:** this package. only the zygote stack plus its examples and tests are included, and earlier stages appear only as summaries. the golfed reference vm and assembler are inlined under message 3, because they define the instruction set. - -### 10 - -> we're gonna make this thing cooler. make the syntax C-like whilst still keeping the forth capabilities like super-homiconicity and the likes, add explicit and automatic gc (use a fast approach) and memory management that looks like this: -> every heap object has a classifier u m d, up middle down. up means the object will die at the scope above (becomes m when returned to the parent scope, and it must be returned and cannot be discarded). m means it must be used here and can be passed down (but once it's recasted as u or d it can't be casted back to m in this scope). d means the variable is passed down, and its lifetime ends after it's given for the first time to a scope lower than the current one. -> ask me questions to clarify this approach, then add it to the code and also add a simple static analyzer which checks these lifetimes - -my answers to the clarifying questions, exactly: - -> scope: every { } block -> -> lending an m object to a callee: borrow, no escape (Recommended) -> -> storing a heap object inside another object or a global: container gets ownership. putting a value inside a container requires its umd to match the parent, and from then on, the umd for that is also inherited from the parent (as in if a parent m is casted to d, so is the child heap object) -> -> gc: free lists + mark-sweep (Recommended) -> -> where the compiler lives: resident in vm code. bootstrap it step by step, by writing a c-like in core.c and then rewriting the compiler inside the c-like. also bring back llvm if you think it'll make your job easier -> -> homoiconic features: parse words, quoted code blocks, inline stack code, patchable compiler tables -> -> how an inner block hands a u object up: expression and assign, both -> -> when the analyzer runs: inside the compiler but can be turned off if the code requests it - -**summary:** the genome is now zc, a typeless c-like language, described above. stage 0 is `zc0.c` (kept as its own file next to core.c rather than inside it, so the runtime still has one native file), and the resident compiler is `src/compiler.zc` plus `src/rt.zc`, which reached a byte-identical fixpoint compiling itself. llvm wasn't brought back, since the target is the vm itself. memory went to 1m words, and core.c gained an interpreter for non-arm64 hosts. calls i made without asking: tags go on signatures (`fn f(m a, d b) -> u`) and recasts are `x as u` / `x as d`; a d object touched inside an inner block moves into that block; storing into a borrowed object, a global or untracked memory needs `as d`; handing a u up by assignment only reaches the directly enclosing block; unchecked code gives its objects to the collector. the old asm genome moved to `legacy/`. |
