Files
TerminalBasic/docs/tbvm-design.md
Chili Palmer f7e57b0bd8 Phase 2 abgeschlossen: Bytecode, TBVM, Runtime-Scheibe, tbc run
- Sema zum Lowering-Pass umgebaut: typisiertes HIR (Slots, explizite
  Konvertierungsknoten) als Codegen-Eingabe; BYREF verlangt exakten Typ
- Bytecode-Feindesign umgesetzt: monomorpher Opcode-Satz,
  .tbc-Container (Formatversion 1) mit eigenem Writer/Reader
- Codegenerator HIR -> Bytecode (Fixup-Listen, keine globalen Passes)
- TBVM-Interpreter: Kontrollfluss, GOSUB-Stack je Frame, BYREF/BYVAL,
  STATIC, DEF FN, DATA/READ/RESTORE, ON [LOCAL] ERROR/RESUME/ERR/ERL,
  Breakpoints/Einzelschritt/Inspektion, STOP fortsetzbar
- Runtime-Scheibe: Host-Trait (Konsole/Capture), Builtin-Tabelle,
  Konvertierungsmatrix, PRINT-Formatierung/Druckzonen, Stringfunktionen
- tbc run/build/check mit Exit-Codes nach Entscheidung D6
- Korpus-Harness (byte-genauer Vergleich) + 3 neue Korpusdateien
  (konvertierung, fehlerbehandlung, byref); 137 Tests gruen
- Benchmarks: Einzelmodul 1,2 ms / Projekt 49.760 Zeilen 124 ms
  (Budgets eingehalten), VM ~5 Mio Schleifeniterationen/s
- Doku fortgeschrieben (tbvm-design, sprachreferenz, PLAN);
  verlagerte Punkte als explizite Aufgaben in Phase 3
- OpenSpec-Change phase-2-bytecode-vm (27/27 Tasks)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 11:28:07 +02:00

15 KiB
Raw Blame History

TBVM — Designentwurf

Eigene Stack-basierte Bytecode-VM (Entscheidung siehe PLAN.md, 0.2). Die VM ist in tb (IDE) und in von tbc erzeugte Executables eingebettet — identischer Code, identisches Verhalten, schneller Turnaround.

Werte-Modell

Tagged Enum, kein NaN-Boxing (Einfachheit und Debugbarkeit vor Mikro- performance):

enum Value {
    Int(i16),          // INTEGER
    Long(i32),         // LONG
    Single(f32),       // SINGLE
    Double(f64),       // DOUBLE
    Currency(i64),     // CURRENCY, Festkomma ×10 000
    Str(Rc<str>),      // STRING (immutabel geteilt; Copy-on-Write bei MID$-Anweisung)
    // Arrays und TYPE-Instanzen leben im Heap-Bereich der VM,
    // Variablen-Slots referenzieren sie per Handle (Rc<RefCell<…>>).
}
  • Kein GC nötig: der Dialekt kennt keine Zyklen (keine Objektreferenzen in TYPEs, keine Closures) → Rc genügt.
  • Feste Strings (STRING * n) und TYPE-Records werden als eigene Speicherobjekte mit fester Zeichen-/Elementstruktur geführt.

Bytecode

  • Stack-Maschine, Opcodes 1 Byte + Operanden variabler Länge (u8/u16/u32, little-endian).
  • Modul-Struktur: Konstantenpool (Strings, Zahlen), Typtabelle (TYPE-Layouts), Prozedurtabelle (Signatur, Locals-Anzahl, Code-Offset), globale Slots, DATA-Segment (für READ/RESTORE), Zeilentabelle.
  • Zeilentabelle: Abbildung Code-Offset → (Moduldatei, Zeile). Grundlage für ERL, Fehlermeldungen, Breakpoints und Einzelschritt.
  • Aufrufkonventionen:
    • SUB/FUNCTION: eigener Frame (Locals-Slots, Operandenstack-Basis); BYREF-Parameter als Referenz-Slots (Handle auf Variablen-Slot).
    • GOSUB: kein Frame — Rücksprungadresse auf separatem GOSUB-Stack im aktuellen Frame (RETURN prüft diesen zuerst).
  • Fehlerbehandlung: Pro Frame ein Handler-Zustand (ON ERROR GOTO x). Laufzeitfehler → VM sucht aktiven Handler im aktuellen Modulkontext (Vorbild: Handler sind modul-/prozedurlokal, TODO: exakte Scoping-Regel testen), setzt ERR/ERL, springt. RESUME nutzt gemerkten Anweisungs-Offset.

Ausführungsmodell / Unterbrechbarkeit

  • Die Interpreterschleife läuft in Ticks: nach jeder Anweisung (Grenze aus der Zeilentabelle) prüft sie ein Flag-Set: Breakpoint? Einzelschritt? Strg+Untbr? Ereignis-Queue nicht leer und Zustellung erlaubt?
  • Ereigniszustellung (Forms, Timer) erfolgt kooperativ: nur an Zustellpunkten (DOEVENTS, SLEEP, blockierende Eingabe, Ende einer Ereignisprozedur) — wie im Vorbild.
  • Die VM ist eine gewöhnliche zustandsbehaftete Struktur, step()-basiert; die einbettende Schleife (IDE-Debugger oder Runner) treibt sie. Kein eigener Thread nötig; Terminal-Events werden zwischen Ticks gepollt.

.tbc-Container (Stand Phase 2, Formatversion 1)

Magic "TBC\0" · Formatversion u16 · Flags u16 · Abschnittsanzahl u32
Abschnittstabelle: [ (Kennung 4 Byte, Offset u32, Länge u32) ]
Abschnitte:
  MODN  Modulname + OPTION BASE
  CONS  deduplizierter Stringpool
  TYPS  TYPE-Layouts (Feld-Initialisierungstypen)
  GLOB  globale Slots (Initialisierungstyp + Name für Debugger)
  PROC  Prozedurtabelle: Name, Parameteranzahl, Frame-Slots, Code
        (Zeileninfo liegt inline im Code: Stmt-/SetErl-Instruktionen —
        ersetzt den früher geplanten separaten LINES-Abschnitt)
  DATA  DATA-Konstanten (Rohtext + Quellzeile)
  JMPT  Sprungtabellen für ON n GOTO/GOSUB
  FORMS (ab Phase 4: serialisierte .FRM-Beschreibungen)

Serialisierung mit einfachem eigenem Writer/Reader (kein serde nötig, Format bleibt stabil und dokumentiert); unbekannte Formatversionen werden mit Meldung abgewiesen. Instruktions-Encoding: 1 Opcode-Byte + Operanden little-endian, Opcode-Bytes gruppenweise mit Lücken vergeben (crates/tb-vm/src/bytecode.rs ist die normative Liste).

Eigenständige Executables

tbc build --exe kopiert den vorkompilierten Runner (dieselbe tb-vm/tb-runtime/tb-ui-Bibliothek wie die IDE) und hängt das .tbc als Ressource an (Anhängen ans Binary + Fußzeile mit Offset/Magic; portabel für alle drei Plattformen). Alternative — include_bytes! + Cargo-Build beim Nutzer — verworfen: erfordert Rust-Toolchain beim Anwender.

Performance

Compiler (Instant-Compile, Anforderung siehe PLAN.md):

  • Single-Pass pro Modul: Lexen, Parsen und Codegen in einem Durchlauf; Vorwärtsreferenzen (Prozeduren, Labels) über Fixup-Listen statt zweitem Pass. Die Sprache ist dafür gemacht — das Vorbild kompilierte auf 1992er-Hardware gefühlt sofort.
  • Modulweise inkrementell: .BAS/.FRM werden unabhängig zu Bytecode- Einheiten übersetzt und beim Build nur zusammengebunden; die IDE recompiliert nur geänderte Module (Hash über Quelltext).
  • Keine Optimierungspasses. Erlaubt sind nur Gratis-Optimierungen im Codegen (Konstantenfaltung im Ausdruck, Peephole beim Emit).
  • Budget als Test verankern (Phase 2): Benchmark-Projekt (~50k Zeilen) muss unter 1 s kompilieren (Release-Build der Toolchain, Referenzrechner); einzelnes Modul < 50 ms.

VM-Ausführung:

  • Alle Namen werden zur Compilezeit aufgelöst: Variablen/Parameter sind Slot-Indizes, Prozeduren Tabellenindizes — zur Laufzeit keine Hash-Lookups.
  • Statische Typen des Dialekts ausnutzen: typisierte Opcodes (ADD_I16, ADD_F64, CONCAT …) statt generischem Dispatch über Value-Tags in heißen Pfaden.
  • Dichte Opcodes, match-Dispatch in einer engen Schleife; Tick-Prüfung (Events/Breakpoints) nur an Anweisungsgrenzen über ein einzelnes Flag-Wort, nicht pro Opcode.
  • Strings immutabel via Rc<str> (Kopien sind Pointer-Kopien); Konstantenpool dedupliziert.
  • Messlatte (Phase 2, Benchmarks in benches/): typische Schleifen-/String-Lasten mindestens ~100× schneller als das Vorbild auf Originalhardware; Richtwert grob Lua-Interpreter-Klasse, gemessen und dokumentiert statt geraten.

Benchmark-Ergebnisse (2026-09-02, Release-Build, Entwicklerrechner Windows 11, cargo bench -p tb-vm):

  • Compile-Budget (benches/compile.rs):
    • Einzelmodul, 508 Zeilen: 1,2 ms (Budget 50 ms — eingehalten)
    • Projekt, 49 760 Zeilen über 20 Module: 124 ms (Budget 1 000 ms — eingehalten); Durchsatz ≈ 400 000 Zeilen/s
  • VM-Durchsatz (benches/vm.rs):
    • INTEGER-Schleife (Arithmetik + MOD): ≈ 4,8 Mio Iterationen/s
    • DOUBLE-Schleife: ≈ 5,2 Mio Iterationen/s
    • SUB-Aufrufe mit BYREF: ≈ 5,2 Mio Aufrufe/s
    • String-Lasten (MID$/INSTR/Verkettung): ≈ 0,8 Mio Runden/s
  • Einordnung: bei ≈ 13 Instruktionen je Schleifeniteration entspricht das ≈ 60 Mio Instruktionen/s — Vorbild-Programme auf 1992er-Hardware werden um weit mehr als den Faktor 100 übertroffen; für 80×25- Terminalprogramme mehr als ausreichend. Ein Performance-Pass ist erst bei Bedarf vorgesehen (Phase 6).

Zahlenkonvertierungs-Matrix (Feindesign, 2026-09-02)

Grundlage: Original-Hilfe (Topics „Arithmetic Operators", „CINT/CLNG", „MOD", „Numeric Types"). Zellen ohne belastbare Fundstelle sind mit TODO verify markiert; der Korpustest konvertierung.bas verankert die Matrix ausführbar.

Rangfolge für Promotion: INTEGER < LONG < CURRENCY < SINGLE < DOUBLE. Ausnahme: CURRENCY gemischt mit SINGLE oder DOUBLE ergibt DOUBLE (nicht SINGLE), damit keine Festkomma-Präzision in f32 verloren geht (TODO verify gegen PDS-Hilfe; plausibelste Lesart).

Ergebnistyp binärer Operatoren (l op r; T = promoteter Typ):

Operator Ergebnistyp Besonderheiten
+ - * T; gleiche Typen bleiben erhalten Ganzzahl-/CURRENCY-Überlauf → Fehler 6 (auch INTEGER+INTEGER promotet nicht)
/ DOUBLE, wenn ein Operand DOUBLE oder CURRENCY, sonst SINGLE Division durch 0 → Fehler 11
\ MOD INTEGER, wenn beide Operanden INTEGER, sonst LONG Operanden werden vorher kaufmännisch (banker's) auf Ganzzahl gerundet; außerhalb LONG → Fehler 6; Divisor 0 → Fehler 11; MOD-Vorzeichen wie Dividend
^ DOUBLE, wenn ein Operand DOUBLE oder CURRENCY, sonst SINGLE (TODO verify) Rechnung intern in DOUBLE; 0 ^ negativ → Fehler 11; negative Basis mit nicht-ganzem Exponent → Fehler 5
= <> < <= > >= INTEGER (1/0) Vergleich im promoteten Typ; Strings codepoint-weise
NOT AND OR XOR EQV IMP INTEGER, wenn alle Operanden INTEGER, sonst LONG bitweise; Operanden vorher banker's-gerundet auf Ganzzahl, außerhalb LONG → Fehler 6
unäres - Typ des Operanden (32768) INTEGER → Fehler 6

Konvertierung Quelle → Ziel (implizit bei Zuweisung/Argument, explizit via CINT/CLNG/CSNG/CDBL/CCUR — identische Semantik):

von \ nach INTEGER LONG SINGLE DOUBLE CURRENCY
INTEGER exakt exakt exakt ×10 000
LONG Bereich prüfen → 6 nächstgelegener f32 exakt ×10 000
SINGLE banker's + Bereich → 6 banker's + Bereich → 6 exakt (Widening) banker's auf 4 Nachkommastellen + Bereich → 6
DOUBLE banker's + Bereich → 6 banker's + Bereich → 6 Bereich prüfen → 6, sonst nächstgelegener f32 banker's auf 4 Nachkommastellen + Bereich → 6
CURRENCY ÷10 000, banker's + Bereich → 6 ÷10 000, banker's + Bereich → 6 ÷10 000.0 ÷10 000.0
  • Banker's Rounding: zur nächsten Ganzzahl, bei .5 zur geraden (Original-Hilfe: CINT). Gilt für alle Gleitkomma→Ganzzahl-Übergänge inklusive \/MOD/Logik-Operanden und CURRENCY-Skalierung.
  • Zahl ↔ String nie implizit (Compile-Zeit „Type mismatch"); explizit nur über STR$/VAL.

Textdarstellung (PRINT und STR$):

  • INTEGER/LONG: dezimal ohne Dezimalpunkt.
  • SINGLE: bis 7 signifikante Stellen, kürzeste Darstellung, keine führende Null vor dem Dezimalpunkt (.5), keine nachgestellten Nullen; außerhalb des Festformat-Bereichs Exponentialform mE±xx (exakte Schwelle TODO verify; umgesetzt: Exponentialform, wenn der Dezimalexponent < 7 oder ≥ 7 ist).
  • DOUBLE: wie SINGLE mit bis 16 signifikanten Stellen und D±xx als Exponentmarke.
  • CURRENCY: Festformat mit bis zu 4 Nachkommastellen, nachgestellte Nullen entfallen (TODO verify).
  • PRINT stellt nicht-negativen Zahlen ein Leerzeichen, negativen ein - voran und hängt stets ein Leerzeichen an; STR$ nur das führende Leerzeichen/-, kein nachgestelltes.

Opcode-Satz (Feindesign, 2026-09-02)

In-Memory führt die VM dekodierte Instruktionen (Vec<Instr>, ein Rust- Enum mit eingebetteten Operanden — Wort-Dispatch, keine Byte-Dekodierung im heißen Pfad); die .tbc-Serialisierung bildet jede Instruktion auf 1 Opcode-Byte + Operanden (little-endian) ab. Typkürzel: I2=INTEGER, I4=LONG, R4=SINGLE, R8=DOUBLE, CY=CURRENCY, STR=STRING.

Gruppe Instruktionen Bemerkung
Anweisungsgrenze Stmt(line:u32) · SetErl(n:u32) Stmt prüft das Tick-Flag-Wort (Breakpoint/Einzelschritt/Abbruch), aktualisiert Zeile und Resume-Punkt; SetErl bei numerischen Zeilennummern
Konstanten/Stack PushInt(i16) PushLng(i32) PushSng(f32) PushDbl(f64) PushCur(i64) PushStr(pool:u16) · Dup Pop Stringpool dedupliziert
Variablen LoadGlobal/StoreGlobal(u16) · LoadLocal/StoreLocal(u16) · MakeRefGlobal/MakeRefLocal(u16) · LoadRef/StoreRef(u16) Slots statisch aufgelöst; *Ref bedienen BYREF-Parameter (Referenz-Werte)
Arrays DimGlobal/DimLocal{slot,dims,elem} RedimGlobal/RedimLocal{…} EraseGlobal/EraseLocal(u16) · LoadElem/StoreElem{dims:u8} · MakeRefElem{dims:u8} Grenzen auf dem Stack (lo/hi je Dimension als LONG); Indexprüfung → Fehler 9
UDT LoadField/StoreField(u16) · MakeRefField(u16) Feldindex aus Typtabelle; verschachtelt durch Verkettung
Arithmetik Add/Sub/Mul{I2,I4,R4,R8,CY} · Neg{…} · Div{R4,R8} · IDiv/Mod{I2,I4} · PowR8 (^ rechnet in DOUBLE, SINGLE-Ergebnis per Conv) monomorph; Ganzzahl-/CY-Überlauf → Fehler 6, Division durch 0 → Fehler 11
Konvertierung Conv_<src>_<dst> für alle 20 geordneten Paare aus {I2,I4,R4,R8,CY} Semantik exakt nach Matrix (Rundung/Überlauf)
Logik Not/And/Or/Xor/Eqv/Imp{I2,I4} bitweise
Vergleich Cmp{Eq,Ne,Lt,Le,Gt,Ge}{I2,I4,R4,R8,CY,STR} Ergebnis INTEGER 1/0
Strings Concat Rc<str>, Kopien sind Pointer-Kopien
Kontrollfluss Jump(u32) JumpIfFalse(u32) JumpIfTrue(u32) Ziele absolut (Instruktionsindex) innerhalb der Code-Einheit; Fixups beim Emit
GOSUB Gosub(u32) RetGosub RetGosubTo(u32) GOSUB-Stack pro Frame; leer → Fehler 3
Berechnete Sprünge OnJump{table:u16,gosub:bool} Sprungtabellen im Modul; 0/zu groß: kein Sprung; negativ/>255 → Fehler 5
Prozeduren Call(proc:u16) RetProc RetFn CallBuiltin{id:u16,argc:u8} Argumente links→rechts auf dem Stack (Werte oder Referenzen); RetFn transportiert den Funktionswert über den Frame-Abbau
Fehler OnErrorGoto(u32) OnErrorLocal(u32) OnErrorDisable OnErrorLocalDisable · Resume0 ResumeNext ResumeLabel(u32) · RaiseError Scoping siehe unten
DATA ReadData(art) Restore(u32) art 0 = String, 1 = Zahl (DOUBLE, dann Conv nach Matrix); Ende → Fehler 4, unkonvertierbar → Fehler 13
E/A Input{…} sowie Builtins (PRINT-Familie über CallBuiltin) Konsolenwirkung ausschließlich über das Host-Trait
Ende End StopInstr SystemInstr Verhalten siehe Runner

FOR/NEXT wird ohne Spezial-Opcodes kompiliert: Grenz-/Schrittwert einmal in versteckte Slots ausgewertet; bei konstantem STEP wählt der Codegen die Vergleichsrichtung zur Compilezeit, sonst verzweigt ein einmalig berechnetes Vorzeichen-Flag auf zwei Vergleichssequenzen. Inkrement mit regulärer typisierter Addition (Überlauf → Fehler 6, wie Vorbild). SELECT CASE wertet den Selektor in einen versteckten Slot aus und wird als Vergleichs-/Sprungkette abgesenkt.

Fehlerbehandlung: Scoping (Feindesign, 2026-09-02)

  • ON ERROR GOTO x setzt den modulweiten Handler — auch aus einer Prozedur heraus. ON LOCAL ERROR GOTO x setzt einen Frame-lokalen Handler, der den modulweiten für die Dauer des Prozedurlaufs verdeckt. … GOTO 0 deaktiviert den jeweiligen Handler.
  • Fehlerfall: erst die Frame-Kette von innen nach außen nach LOCAL-Handlern absuchen, sonst modulweiter Handler; Stack wird bis zum Handler-Frame abgewickelt, Anweisungs-Offset und Frame für RESUME gemerkt, ERR/ERL gesetzt. Ohne Handler: Abbruch mit Meldung und Quellzeile.
  • Fehler während aktiver Behandlung (zwischen Handler-Eintritt und RESUME): sofortiger Abbruch, keine Kaskade (Vorbild).
  • ERL liefert die zuletzt durchlaufene numerische Zeilennummer (Labels zählen nicht; ohne numerische Zeilen: 0).

Runner-Verhalten STOP/CONT (Entscheidung 2026-09-02)

  • END, SYSTEM und das Erreichen des Programmendes beenden den Prozess mit Exit-Code 0.
  • STOP außerhalb der IDE terminiert mit der Meldung STOP in line n (bzw. Modul/Zeile) und Exit-Code ≠ 0. Offene Dateien werden wie bei END geschlossen.
  • CONT ist ein Direktmodus-Konzept der IDE (Phase 5): dort suspendiert STOP die VM (step()-Architektur), CONT setzt fort. Im Kompilat existiert kein CONT.

Offene Punkte

Keine — die Punkte aus Phase 0 (Opcode-Satz, Konvertierungsmatrix, STOP/CONT im Runner) sind oben ausgearbeitet; verbleibende Detailunsicherheiten sind als TODO verify direkt an den betroffenen Zellen markiert und über den Testkorpus abgesichert.