Files
TerminalBasic/docs/tbvm-design.md

35 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: Zwei Ebenen (Scoping geklärt 2026-09-04). ON LOCAL ERROR GOTO x setzt einen Handler im aktuellen Frame; sein Sprungziel muss im selben Rumpf liegen. ON ERROR GOTO x ohne LOCAL setzt den modulweiten Handler — auch aus einer Prozedur heraus, und sein Sprungziel liegt dann im Modulrumpf (der Codegenerator löst es über Prozedur 0 auf, die zuerst übersetzt wird). Ein Laufzeitfehler sucht zuerst lokale Handler die Aufrufkette hinauf, dann den modulweiten; beim modulweiten wird bis Frame 0 abgewickelt, weshalb RESUME NEXT dort hinter dem Prozeduraufruf fortsetzt. Der Handler setzt ERR/ERL; RESUME nutzt den gemerkten Anweisungs-Offset. Ein Fehler im Handler ist fatal (keine Kaskaden).

Ausführungsmodell / Unterbrechbarkeit

Die VM läuft auf dem aufrufenden Thread. Vm::run liefert bei STOP, Breakpoint, Einzelschritt, Programmende oder Fehler ein RunEvent an den Runner zurück. Stmt markiert Anweisungsgrenzen; es gibt keine Prüfung nach jedem Opcode.

Die gemeinsame Zustellentscheidung gilt an Anweisungsgrenzen, bei DOEVENTS, während SLEEP und während blockierender Konsoleneingabe. Eine zugestellte Ereignisprozedur beginnt ihre erste Anweisung, bevor ein weiterer Handler sie unterbrechen darf. Forms-Handler laufen normalerweise bis zur Rückkehr; explizites DOEVENTS und blockierendes Warten können andere Handler verschachteln, aber nie dieselbe laufende Forms-Ereignisprozedur. Die Rückkehr aus dem äußersten normalen Forms-Handler arbeitet wartende Forms-Ereignisse geordnet ab. Objekt-Opcodes stellen nur erforderliche synchrone LOAD-Handler zu; deren Rückkehr zieht keine unabhängigen Ereignisse mitten in eine Anweisung vor. Ein offenes Menü sperrt klassische Traps und Forms-Timer, ohne ON/OFF/STOP zu verändern. Beim Menüschließen wird die Zustellbarkeit vor erneutem Warten geprüft.

INPUT, LINE INPUT, INPUT$ und RANDOMIZE ohne Argument teilen einen fortsetzbaren Konsolenleser. Teiltext und Restlänge liegen im aufrufenden Frame; Referenzen und Ausdrucksoperanden bleiben bis zum Abschluss auf dem Stack. Nach jedem Hostereignis kann die VM einen Handler ausführen und danach am Eingabe-Opcode fortsetzen, auch über STOP/CONT und verschachtelte Eingaben hinweg. Fehler-Unwinding und RETURN label verwerfen die abgebrochene Eingabe. Während dieses Lesens gehören ungetrappte Tasten der Konsole.

RtState::eingaben hält Tasten und Maus in Hostreihenfolge, auch beim Übergeben an Dialoge. Die VM nimmt eine Forms-Eingabe samt ihren Handlern vor der nächsten Eingabe an. Konsolentasten bleiben lesbar; Maus ohne Formular oder außerhalb der Bildschirmfläche wird verworfen. KEY ON entnimmt getrappte Tasten. KEY STOP merkt höchstens einen Trap, markiert die betroffenen Eingaben als bereits gemerkt und lässt sie lesbar, auch nach ON. Vor der klassischen Quellenauswahl wird der getrappte Tastenanfang der Queue gemeldet: KEY behält seine Priorität vor TIMER/UEVENT/SIGNAL, ohne eine davor wartende Mauseingabe zu überholen. EOF beendet die Quelle; modale und modellose Formularschleifen arbeiten schon angenommene Ereignisse noch ab.

Host::jetzt_ms ist die einzige Uhr für Ereignisfristen. Die VM fragt sie nur für aktive Zeitquellen, SLEEP-Fristen und zeitabhängige Mausauswertung ab. Der klassische TIMER wird weiterhin bei Anzeigen, expliziter Kooperation und ansonsten alle 64 Anweisungsgrenzen geprüft. Host::warten(Option<u64>) wartet auf ein Ereignis oder die absolute Deadline derselben Uhr; None als Ergebnis meldet eine erreichte Deadline. Ohne Deadline liefert der Host erst ein Ereignis oder explizites Ereignis::Ende. Die VM verwendet weder Instant noch Sleep/Yield als Rückfallebene. Auch modale und nach Programmende sichtbare Forms verwenden diesen Wartepfad. CaptureHost liefert vorbereitete Ereignisse zuerst und rückt beim Warten andernfalls bis zur Deadline vor; ohne Deadline meldet er Eingabeende. Seine Uhr bleibt außerhalb dieser ausdrücklichen Warteoperation oder manueller Zeitänderungen stehen.

tb-ui hat standardmäßig keine Terminalabhängigkeiten. Das Feature terminal schaltet die Module host, screen, signale und die optionalen Dependencies crossterm, ratatui und signal-hook ein. tb-cli aktiviert es explizit; VM, Forms-Modell, FRM-Reader und Dialoge brauchen es nicht. Einbetter müssen die neue Host-Wartemethode implementieren. Dialog-Eingabequeues tragen Eingabe statt nackter Ereignis-Werte, damit KEY-STOP-Markierungen erhalten bleiben; Ereignisse lassen sich mit .into() einreihen.

Der separate Workspace tests/terminalfrei prüft Einbettung ohne Feature-Unifikation mit CLI oder IDE:

cargo run --manifest-path tests/terminalfrei/Cargo.toml --offline
cargo tree --manifest-path tests/terminalfrei/Cargo.toml --edges normal

Der erste Befehl lädt ein FRM-Korpusprogramm und prüft dessen KM-Ausgabe mit CaptureHost. Der zweite Baum darf crossterm, ratatui und signal-hook nicht enthalten.

.tbc-Container (Formatversion 4, 2026-09-05)

tbc build app.mak erzeugt app.tbc; tbc run app.tbc benötigt die BAS-/FRM-/MAK-/Include-Quellen nicht mehr. Dieselbe VM führt Quellprogramme und geladene Kompilate aus. Versionen 13 und unbekannte Versionen werden mit Angabe der gefundenen und unterstützten Version abgewiesen.

Alle Zahlen sind little-endian, Ganzzahlen mit Vorzeichen im Zweierkomplement, f32/f64 nach IEEE 754. bool ist genau ein Byte (0/1). string bedeutet u32 UTF-8-Bytelänge, gefolgt von genau diesen Bytes ohne Abschlussnull. Listen beginnen mit u32 Elementanzahl; [...] bezeichnet die folgenden wiederholten Elemente. Tabellen-IDs sind nullbasiert.

Der Header besteht aus TBC\0 (4 Byte), Version u16 = 4, Flags u16 = 0, Abschnittsanzahl u32 = 9. Darauf folgen neun Einträge mit Kennung (4 ASCII-Bytes), absolutem Datei-Offset u32 und Bytelänge u32. Der Writer schreibt die folgenden Abschnitte in dieser Reihenfolge; der Reader findet sie über die Tabelle.

Kennung Payload in Reihenfolge
MODN Projektname string, Vorgabe-OPTION-BASE u8 (wie Modul 0)
SRCS Modulanzahl u32, [Name string, OPTION BASE u8 (0/1)]; Quellenanzahl u32, [Modul-ID u16, Dateipfad string]
CONS Anzahl u32, [string]; Stringkonstantenpool
TYPS Anzahl u32, [TYPE-Name string, Feldanzahl u32, [TypeInit]]
GLOB Slotanzahl u32, [TypeInit, Debuggername string]
PROC Prozeduranzahl u32, [Prozedurbeschreibung gemäß unten]
DATA Anzahl u32, [Rohtext string, physische Zeile u32]
JMPT Tabellenanzahl u32, [Zielanzahl u32, [Instruktionsindex u32]]
OBJS Objekt-, Ereignis- und Anfangsdaten gemäß unten

Eine PROC-Beschreibung enthält: Name string, Modul-ID u16, Art u8 (0 Hauptprogramm, 1 SUB, 2 FUNCTION, 3 DEF FN), Parameteranzahl u32, [Parametername string, HTy, Array bool, BYREF bool], Rückgabetyp-vorhanden bool, gegebenenfalls HTy, Aufrufparameteranzahl u16, Local-Anzahl u32, [TypeInit, Local-Name string], Instruktionsanzahl u32, Code-Bytelänge u32, Code-Bytes. Die beiden Parameteranzahlen müssen übereinstimmen. Prozedur 0 enthält die zusammengeführten Modulrümpfe; weitere Prozeduren behalten ihre Modul-ID. In Mehrmodulprojekten sind Prozedur-/globale Debuggernamen Modul!Name mit dem aufgelösten Typ-Suffix bei implizit/suffixdeklarierten Variablen. AS-deklarierte Namen bleiben suffixlos. Die Inspektion unterscheidet explizite Suffixe; ohne Suffix muss der Basisname eindeutig sein. Aufrufe und Variablenzugriffe verwenden ausschließlich IDs.

TypeInit belegt immer 5 Byte: Tag u8 und Zusatz u32. Tags: 0 INTEGER, 1 LONG, 2 SINGLE, 3 DOUBLE, 4 CURRENCY, 5 STRING, 6 fester STRING (Zusatz: Länge), 7 UDT (Zusatz: TYPE-ID, maximal 65535), 8 leerer Slot. Sonst ist der Zusatz 0. UDT-Felder dürfen nur frühere TYPE-IDs referenzieren. HTy ist ein u8-Tag mit denselben skalaren Tags 07; nur bei Tag 6 folgen u32 Länge und bei Tag 7 u16 TYPE-ID. Tag 8 bezeichnet FORM, Tag 9 CONTROL. Arrays werden separat im Parametersatz markiert; ihr Local-Slot beginnt leer.

OBJS enthält Objektanzahl u32, [Name string, Klasse u8, Elternname string (leer bei Wurzel), Array bool, Eltern-ID u16 (FFFF = keine)], dann Ereignisanzahl u32, [Objekt-ID u16, Ereignisname string, Prozedur-ID u16], Startformular-ID u16 (FFFF = keines), Anzahl Anfangsinstanzen u32, [Objekt-ID u16, Designindex i32, Eigenschaftsanzahl u32, [Eigenschafts-ID u16, PropertyValue]]. Eltern-IDs zeigen auf frühere Objekte; gleichnamige Controls anderer Formulare bleiben dadurch getrennt. Auch Designinstanzen mit Index ungleich 0 und sämtliche FRM-Anfangswerte sind enthalten. Eigenschaften stehen in aufsteigender ID-Reihenfolge.

PropertyValue beginnt mit Tag u8: 0 Integer (i32), 1 Single (f32), 2 String (string), 3 Boolean (bool), 4 Objekt (bool vorhanden, falls ja u16 Objekt-ID und bool Index-vorhanden, gegebenenfalls i32 Index), 5 Integer-Array (u32 Anzahl, [i32]). Klassen-IDs 018 entsprechen in Reihenfolge Form, CheckBox, ComboBox, CommandButton, DirListBox, DriveListBox, FileListBox, Frame, HScrollBar, Label, ListBox, Menu, OptionButton, PictureBox, TextBox, Timer, VScrollBar, Screen, Spin. Eigenschafts-/Methoden-IDs sind Indizes der jeweiligen Klassentabellen in tb-frontend/src/forms.rs.

Code und Quellorte liegen inline in PROC; es gibt keine CODE-/LINES- oder FORMS-Abschnitte. Source(Datei-ID, Spalte) steht unmittelbar vor Stmt(physische Zeile). Die VM liest diese Zuordnung auch bei einem Sprung direkt auf Stmt. Globale DIM-Anweisungen tragen InitStmt(physische Zeile): Fehlerorte und Debugger bleiben aktiv, Ereignisse werden bis nach den Initialisierungen zurückgestellt. Zeile 0 ist eine synthetische Initialisierungsgrenze. SetErl(BASIC-Label) aktualisiert getrennt davon die numerische Zeilennummer für ERL. Include-Dateien behalten ihren Pfad und physischen Quellort sowie die ID ihres einbindenden Moduls. add_module_breakpoint(module, line) und set_step(true) halten an diesen Grenzen; current_module, current_file und current_source_pos liefern den Halt-/Fehlerort. inspect akzeptiert lokale, im aktuellen Modul sichtbare und explizit qualifizierte Variablennamen.

Der Reader prüft Version, Flags, eindeutige bekannte Abschnitte, Längen/Überlappungen, vollständige Payloads, Typ-/Objekt-/Prozedur-/Quell-IDs, Sprungziele und Anfangseigenschaften vor der Ausführung. Beschädigte Daten liefern einen Ladefehler. Das Format ist kein Sandbox-Format für fremden Programmcode; ein vollständiger Stack-/Kontrollflussverifizierer ist nicht Teil von TBC. Ein gültiges vom Writer erzeugtes Kompilat lässt sich bytegleich laden und erneut serialisieren.

Eigenständige Executables

Geplant, noch nicht implementiert: 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.
  • .BAS/.FRM werden getrennt geparst, semantisch aufgelöst und zu Bytecode- Einheiten übersetzt. Der Projektlink versetzt Slots, Prozeduren, Typen, Strings, DATA und Sprungtabellen. Modulrümpfe laufen in Projektreihenfolge; ihre globalen Initialisierungen liegen vor dem ersten Ereignis. Eindeutige externe Prozeduren werden mit dem DEFtype-Kontext ihres Ursprungs importiert; prozedurlokale DEFtype-Angaben beeinflussen keine anderen Prozeduren. Importierte Konstanten werden im Ursprung gefaltet, auch bei transitiven Modulabhängigkeiten. TYPE-Abhängigkeiten behalten ihr ursprüngliches Layout; interne qualifizierte Typnamen verhindern Konflikte mit abweichenden lokalen Definitionen. Echte lokale Duplikate werden diagnostiziert. SHARED bleibt modullokal. COMMON-Variablen desselben Blocks und derselben vollständigen Variablenidentität teilen einen Slot; Elementtyp, Rang und bekannte Grenzen müssen kompatibel sein. Der IDE-Cache speichert Parsing und unverknüpfte Modulprodukte im vorhandenen Projektcompiler; der Vollcompile nutzt dieselben Schritte mit einem leeren Cache. Details stehen in ide-bedienung.md.
  • 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 Nachmessung nach Phase 3 (2026-09-04, macOS, Apple Silicon, cargo bench -p tb-vm) — die Zustellpunkte des neuen Host (Anzeige nur bei geändertem Bildschirm, Ereignisabholung alle 1024 Anweisungen) kosten nichts Messbares:
  • Compile-Budget: Einzelmodul 508 Zeilen 0,77 ms; Projekt 49 760 Zeilen 53 ms (≈ 950 000 Zeilen/s)

  • VM-Durchsatz: INTEGER-Schleife 11,0 Mio/s, DOUBLE 12,2 Mio/s, SUB-Aufrufe 10,7 Mio/s, String-Lasten 2,4 Mio Runden/s

(Die höheren Zahlen gegenüber 2026-09-02 gehen auf die andere Maschine zurück, nicht auf eine Optimierung — gemessen wurde beide Male derselbe Benchmark.)

  • 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"). Vier Punkte sind dort nicht geregelt; sie wurden am 2026-09-04 entschieden und sind unten als Projektentscheidung gekennzeichnet — sie gelten damit als bewusste Festlegung, nicht als rekonstruiertes Vorbildverhalten. 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). Projektentscheidung 2026-09-04: Die Original-Hilfe regelt diesen Fall nicht. CURRENCY trägt 19 signifikante Stellen, SINGLE nur 7 — eine Promotion nach SINGLE zerstörte genau die Genauigkeit, für die es den Typ gibt. DOUBLE (16 Stellen) ist die nächstbeste Wahl.

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 (Projektentscheidung 2026-09-04) 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. Projektentscheidung 2026-09-04: Die Original-Hilfe nennt keine Schwelle. Gewählt ist der Dezimalexponent < 7 oder ≥ 7 — damit bleibt jeder Wert im Festformat, der sich mit den 7 signifikanten Stellen des Typs verlustfrei schreiben lässt (9999999 fest, 1E+07 exponentiell; .0000001 fest, 1E-08 exponentiell).
  • DOUBLE: wie SINGLE mit bis 16 signifikanten Stellen und D±xx als Exponentmarke.
  • CURRENCY: Festformat mit bis zu 4 Nachkommastellen, nachgestellte Nullen entfallen (2.5000@2.5, 1.10@1.1). Projektentscheidung 2026-09-04: Die Original-Hilfe nennt kein Ausgabeformat. Gewählt ist dieselbe Regel wie bei den übrigen Zahlentypen — nachgestellte Nullen entfallen —, damit PRINT über alle Typen einheitlich wirkt. Wer feste Nachkommastellen braucht, nutzt PRINT USING.
  • 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 (Formatversion 4, 2026-09-05)

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.

Die folgende Tabelle führt jeden serialisierbaren Opcode einzeln auf; Gesamtbytes schließen das Opcode-Byte ein. bool, TypeInit und primitive Breiten sind im Containerabschnitt definiert. CmpOp belegt ein Byte: 0 gleich, 1 ungleich, 2 kleiner, 3 kleiner/gleich, 4 größer, 5 größer/gleich. Nicht aufgeführte Opcode-Bytes sind ungültig.

Byte Instruktion Operanden in Reihenfolge Gesamtbytes
0x00 Stmt line: u32 5
0x01 SetErl label: u32 5
0x02 End 1
0x03 StopInstr 1
0x04 SystemInstr 1
0x05 Unsupported name: u16 3
0x06 Source source: u32, column: u32 9
0x07 InitStmt line: u32 5
0x10 PushInt value: i16 3
0x11 PushLng value: i32 5
0x12 PushSng value: f32 5
0x13 PushDbl value: f64 9
0x14 PushCur value: i64 9
0x15 PushStr pool: u16 3
0x16 Dup 1
0x17 Pop 1
0x18 PushUdtId type: u16 3
0x20 LoadGlobal slot: u16 3
0x21 StoreGlobal slot: u16 3
0x22 LoadLocal slot: u16 3
0x23 StoreLocal slot: u16 3
0x24 LoadRef slot: u16 3
0x25 StoreRef slot: u16 3
0x26 MakeRefGlobal slot: u16 3
0x27 MakeRefLocal slot: u16 3
0x28 MakeRefElem dimensions: u8 2
0x29 MakeRefField field: u16 3
0x30 LoadArr global: bool, slot: u16, dimensions: u8, element: TypeInit 10
0x31 LoadElem dimensions: u8 2
0x32 StoreElem dimensions: u8 2
0x33 DimArr global: bool, slot: u16, dimensions: u8, element: TypeInit 10
0x34 RedimArr global: bool, slot: u16, dimensions: u8, element: TypeInit 10
0x35 EraseSlot global: bool, slot: u16 4
0x36 LoadField field: u16 3
0x37 StoreField field: u16 3
0x38 CopyRec 1
0x39 ArrBound lower: bool 2
0x3A FixStr length: u32 5
0x3B CommonArr global: bool, slot: u16, dimensions: u8, element: TypeInit 10
0x40 AddI2 1
0x41 AddI4 1
0x42 AddR4 1
0x43 AddR8 1
0x44 AddCy 1
0x45 SubI2 1
0x46 SubI4 1
0x47 SubR4 1
0x48 SubR8 1
0x49 SubCy 1
0x4A MulI2 1
0x4B MulI4 1
0x4C MulR4 1
0x4D MulR8 1
0x4E MulCy 1
0x4F NegI2 1
0x50 NegI4 1
0x51 NegR4 1
0x52 NegR8 1
0x53 NegCy 1
0x54 DivR4 1
0x55 DivR8 1
0x56 IDivI2 1
0x57 IDivI4 1
0x58 ModI2 1
0x59 ModI4 1
0x5A PowR8 1
0x5B Concat 1
0x60 ConvI2I4 1
0x61 ConvI2R4 1
0x62 ConvI2R8 1
0x63 ConvI2Cy 1
0x64 ConvI4I2 1
0x65 ConvI4R4 1
0x66 ConvI4R8 1
0x67 ConvI4Cy 1
0x68 ConvR4I2 1
0x69 ConvR4I4 1
0x6A ConvR4R8 1
0x6B ConvR4Cy 1
0x6C ConvR8I2 1
0x6D ConvR8I4 1
0x6E ConvR8R4 1
0x6F ConvR8Cy 1
0x70 ConvCyI2 1
0x71 ConvCyI4 1
0x72 ConvCyR4 1
0x73 ConvCyR8 1
0x80 NotI2 1
0x81 NotI4 1
0x82 AndI2 1
0x83 AndI4 1
0x84 OrI2 1
0x85 OrI4 1
0x86 XorI2 1
0x87 XorI4 1
0x88 EqvI2 1
0x89 EqvI4 1
0x8A ImpI2 1
0x8B ImpI4 1
0x90 CmpI2 compare: CmpOp 2
0x91 CmpI4 compare: CmpOp 2
0x92 CmpR4 compare: CmpOp 2
0x93 CmpR8 compare: CmpOp 2
0x94 CmpCy compare: CmpOp 2
0x95 CmpStr compare: CmpOp 2
0xA0 Jump target: u32 5
0xA1 JumpIfFalse target: u32 5
0xA2 JumpIfTrue target: u32 5
0xA3 Gosub target: u32 5
0xA4 RetGosub 1
0xA5 RetGosubTo target: u32 5
0xA6 OnJump table: u16, gosub: bool 4
0xA7 Run kind: u8 2
0xB0 Call proc: u16, argc: u8 4
0xB1 RetProc 1
0xB2 RetFn 1
0xB3 CallBuiltin builtin: u16, argc: u8 4
0xB4 LoadObjectProperty object: u16, property: u16, indexed: bool 6
0xB5 StoreObjectProperty object: u16, property: u16, indexed: bool 6
0xB6 PushObject object: u16, indexed: bool 4
0xB7 TypeOf class: u8 2
0xB8 ObjectMethod object: u16, method: u16, argc_flags: u8 6
0xB9 ObjectLoad object: u16, unload: bool, indexed: bool 5
0xBA LoadDynamicObjectProperty name: u16 3
0xBB StoreDynamicObjectProperty name: u16 3
0xBC LoadObjectIndexedProperty object: u16, property_flags: u16 5
0xBD ObjectMethodFn object: u16, method: u16, argc_flags: u8 6
0xBE StoreObjectIndexedProperty object: u16, property_flags: u16 5
0xC0 OnErrorGoto target: u32 5
0xC1 OnErrorLocal target: u32 5
0xC2 OnErrorDisable 1
0xC3 OnErrorLocalDisable 1
0xC4 OnErrorResumeNext local: bool 2
0xC5 Resume0 1
0xC6 ResumeNext 1
0xC7 ResumeLabel target: u32 5
0xC8 RaiseError 1
0xC9 LoadErr 1
0xCA LoadErl 1
0xCB SetErr 1
0xD0 ReadData kind: u8 2
0xD1 Restore data: u32 5
0xD2 Input argc: u8, line_mode: bool, prompt: u16, question: bool 6
0xD3 InputFile argc: u8, line_mode: bool 3
0xD4 GetPut put: bool, has_record: bool, kind: u8, extra: u16 6
0xD5 Field count: u8 2
0xD6 LsetRset right: bool 2
0xE0 TrapDefine kind: u8, target: u32 6
0xE1 TrapDisable kind: u8 2
0xE2 TrapSet kind: u8, state: u8 3
0xE3 EventSwitch enabled: bool 2
0xE4 Doevents 1
0xE5 Sleep has_seconds: bool 2

target bezeichnet einen Instruktionsindex innerhalb der aktuellen Prozedur; OnErrorGoto zeigt in Prozedur 0. table referenziert JMPT, proc die Prozedurtabelle, pool/name/prompt den Stringpool. global=true wählt GLOB, sonst einen Local-Slot. Arrayindizes und Dimensionsgrenzen (je lo/hi) liegen auf dem Stack. ArrBound(lower) unterscheidet LBOUND/UBOUND; MakeRef* erzeugt Referenzen, CopyRec eine UDT-Wertkopie. PushUdtId legt eine beim Linken versetzte TYPE-ID als LONG für ISAM auf den Stack.

Call/CallBuiltin konsumieren argc Argumente in Quellreihenfolge. builtin ist die feste ID aus tb-runtime/src/builtins.rs::ids; RetFn transportiert den Funktionswert. ObjectMethod/ObjectMethodFn verwenden klassenlokale Methodenindizes, keine Stringpool-IDs. Das höchste Bit von argc_flags kennzeichnet einen zusätzlichen Control-Arrayindex vor den Argumenten; die unteren 7 Bit enthalten die Argumentanzahl. Bei indizierten Eigenschaften markiert Bit 15 von property_flags den zusätzlichen Control-Arrayindex; die unteren 15 Bit sind die Eigenschafts-ID. Der Eigenschaftsindex liegt anschließend auf dem Stack. indexed bedeutet ansonsten ebenfalls Control-Arrayindex auf dem Stack. Dynamische Eigenschaftszugriffe verwenden den Namen im Stringpool und einen Objektwert vom Stack. TypeOf(class) verwendet die oben genannten Klassen-IDs.

TrapDefine/TrapDisable/TrapSet: kind ist 0 KEY, 1 TIMER, 2 UEVENT, 3 SIGNAL; die Kennung bzw. TIMER-Dauer kommt vom Stack. state ist 0 ON, 1 OFF, 2 STOP. Doevents legt 0 ab; Sleep(true) nimmt Sekunden vom Stack. OnErrorResumeNext(local) wählt Frame- oder Modulhandler. RaiseError und SetErr lesen einen Fehlercode vom Stack; LoadErr/LoadErl legen ihren Wert ab. Run(kind) ist 0 Neustart, 1 numerische Startzeile, 2 Dateiname; bei 1/2 liegt das Ziel auf dem Stack. ReadData(kind) ist 0 STRING, 1 DOUBLE.

Input/InputFile lesen argc Referenzen; bei Dateiinput liegt die Dateinummer darunter. line_mode wählt LINE INPUT, prompt=FFFF bedeutet keinen Prompt, question ergänzt das Fragezeichen. GetPut unterscheidet GET/PUT und eine optionale Recordnummer auf dem Stack. kind ist 0 ohne Variable, 1 INTEGER, 2 LONG, 3 SINGLE, 4 DOUBLE, 5 CURRENCY, 6 fester STRING, 7 UDT, 8 variabler STRING; extra enthält bei 6 die Länge, bei 7 die TYPE-ID, sonst 0. Field konsumiert Dateinummer und count Paare (Länge, Referenz). LsetRset(right) konsumiert Referenz und Wert.

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. Jedes Quellmodul besitzt einen eigenen Modulhandler; GOTO 0 in einer Bibliothek deaktiviert daher keinen Handler des aufrufenden Moduls. … 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. Die vier Stellen, zu denen die Original-Hilfe schweigt, sind am 2026-09-04 als Projektentscheidung festgelegt, an Ort und Stelle begründet und über tests/compat/konvertierung.bas ausführbar verankert. Sollte je eine belastbare Fundstelle auftauchen, die ihnen widerspricht, gilt sie — die Entscheidungen sind Lückenfüller, keine Setzungen gegen das Vorbild.

CommonArr dimensioniert einen leeren COMMON-Array-Slot einmal. Bei bereits vorhandenem Array prüft es Elementtyp und die ausgewerteten Grenzen, ohne Werte oder Handle zu ersetzen (Konflikt: Fehler 13). Statisch erkennbare COMMON-Typ-/Grenzenkonflikte werden bereits beim Linken diagnostiziert.

Inkrementeller Compile (Phase 5, Change 03)

Messung vom 2026-09-06 auf macOS/aarch64, Apple M5 Max. Aufruf: cargo bench -p tb-vm --bench compile; Release mit opt-level=3, LTO und codegen-units=1. Derselbe Generator erzeugt das Referenz-Einzelmodul mit 508 Zeilen und das Projekt mit 20 Modulen / 49.760 Zeilen. Vollcompile und Cacheänderung schließen die Projektauflösung und den Link ein. Cacheänderungen ändern einen privaten Prozedurrumpf; sieben Läufe liefern den Median und prüfen die Zahl tatsächlich neu übersetzter Module. Die Grenzwerte werden nur im expliziten Release-Benchmark geprüft, nicht in normalen Debug-Tests.

Referenz Vollcompile Cacheänderung (Median) Neu / wiederverwendet Budget
Einzelmodul, 508 Zeilen 1,21 ms 0,74 ms 1 / 0 < 50 ms: erfüllt
Projekt, 49.760 Zeilen 103 ms 22,10 ms 1 / 19 < 1.000 ms: erfüllt

Der Vollcompile des Einzelmoduls ist wie im bestehenden Harness der beste von zehn Läufen; das Projekt wird einmal vollständig gemessen. Der Benchmark zählt 335.421 verknüpfte Instruktionen. Diese Werte sind eine lokale Messung, keine hardwareunabhängige Zeitgarantie.