9.2 KiB
13 — Script-Engine wählen und Sandbox härten
Type: grilling Status: resolved Blocked by: 01
Question
Rhai oder mlua — und wie wird die gewählte Engine abgedichtet?
Ticket 05 hat die Fakten geliefert und Rhai empfohlen. Die Entscheidung steht noch aus, und sie hängt bewusst an 01: Reden Scripts ausschließlich über das Effekt-Vokabular, ist die Wahl später umkehrbar; reichen sie tiefer in den Game-State, ist sie es nicht. Zu klären:
- Ist das Effekt-Vokabular die einzige Schnittstelle für Scripts? Wenn ja, ist die Engine-Wahl eine Implementierungsentscheidung statt einer Architekturentscheidung.
- Zielgruppe: „gut moddbar für einen kleinen Kreis" spricht für Rhai, „möglichst viele fremde Mods" für Lua. Das ist die Frage, an der die Empfehlung kippt.
- Vertraust du fremden Mods? Bei feindlichen Mods ist Luau der einzige echte Sandbox — zum Preis einer C++17-Toolchain und eines Lua-Dialekts.
- Härtungs-Checkliste für die gewählte Engine festlegen. Bei Rhai mindestens:
FileModuleResolverabschalten,eval()deaktivieren, CPU- und Memory-Limits setzen. Bei mlua: niemalsLua::new(), sondernnew_withmit ausgewählten Libs,load/loadstring/dofile/requireentfernen,ChunkMode::Texterzwingen. - Wie geht das Projekt mit den Rhai-Risiken um — Bus-Faktor 1, Unsoundness-Issue #894 seit 2024,
smartstringunmaintained (RUSTSEC-2026-0249)? Akzeptiert, beobachtet, oder Ausschlusskriterium? - Wann fällt die Entscheidung endgültig — jetzt, oder erst wenn Mod-Stufe (c) gebaut wird?
Ergebnis: gewählte Engine mit Begründung, Härtungs-Checkliste, und die Regel, was Scripts sehen dürfen.
Answer
Begriffe im Glossar.
Die Wahl ist eine Implementierungsentscheidung — deshalb fällt sie leicht
Scripts lesen über eine Handvoll Funktionen und schreiben ausschließlich über Aktionen. Weder
lesen sie direkt im Game-Baum noch halten sie Zustand außerhalb des Vokabulars.
Damit ist die Schnittstelle klein genug, um sie in jeder Engine nachzubauen — genau die Leitplanke aus Ticket 05. Ein Engine-Wechsel kostet die Bindungsschicht, nicht die Architektur.
Die strikte Variante — Script bekommt Daten, gibt Aktionen zurück, liest gar nichts — wäre sauberer gewesen und macht Scripts sinnlos: Was ein Script gegenüber einem Trigger hinzufügt, ist Rechnen („Schaden skaliert mit der Zahl der Untoten im Raum"), und dafür muss es lesen können.
Rhai — unbequem begründet
Für ein unveröffentlichtes Terminal-Roguelike schreibt niemand Mods. Lua gewinnt bei der Vertrautheit deutlich (Factorio, GMod, WoW, Noita, Don't Starve), aber das zahlt sich erst aus, wenn es eine Modder-Gemeinde gibt — und die entsteht nicht durch die Sprachwahl, sondern durch ein Spiel, das Leute spielen wollen. Bis dahin ist der Modder, den es zu bedienen gilt, der Autor selbst, und die Charter nennt „Rust lernen" als halbe Motivation.
Dazu der schärfste Einzelbefund aus 05: Ein Lua-Script hat unter StdLib::ALL_SAFE per io.open eine
echte Datei gelesen — „safe" heißt dort speichersicher, nicht fähigkeitsbeschränkt. Rhai hat Datei-,
Netz- und Prozesszugriff gar nicht erst in der Standard-Engine.
Mods gelten als vertrauenswürdig. Kein Spiel dieser Größenordnung behandelt sie anders — Factorio, Minecraft, Skyrim und Noita vertrauen alle. Der Spieler entpackt einen Ordner in sein Profil, dieselbe Vertrauensentscheidung wie bei jeder heruntergeladenen Software; eine Sandbox schützt ihn nicht davor, sondern verschiebt nur, wo er sie trifft. Damit scheidet Luau samt C++17-Toolchain und Lua-Dialekt aus — der Dialekt, den die vertrauten Lua-Modder gerade nicht können.
Härtung — gegen Unfälle, nicht gegen Absicht
engine.set_module_resolver(DummyModuleResolver::new()); // kein ./*.rhai von der Platte
engine.disable_symbol("eval"); // kein Code aus Strings
engine.set_max_operations(100_000); // CPU pro Aufruf
engine.set_max_call_levels(32); // Rekursion
engine.set_max_string_size(8_000);
engine.set_max_array_size(1_000);
engine.set_max_map_size(1_000);
engine.set_max_expr_depths(64, 32); // Parser-Tiefe
Alle Methodennamen gegen die Rhai-Doku geprüft. on_progress bleibt weg — es ist der Abbruchhaken für
lang laufende Scripts, und set_max_operations deckt denselben Fall mit einem Wert statt mit einer
Callback-Schleife ab.
set_max_operations gehört ausdrücklich nicht in [balance] aus Ticket 14: Ein Mod, der sein
eigenes CPU-Limit hochsetzen kann, invertiert die Härtung.
Ein Script, das an ein Limit stößt, ist ein Eintrags-Fehler nach der Regel aus 04: Warnzeile mit Mod-ID, Script-Name und Zeile, Trigger verworfen, Spiel läuft weiter. Rhais Fehlermeldungen mit Zeile und Spalte sind laut 05 genau dafür da.
Die Schnittstelle — was Scripts sehen dürfen
attribute(wer, name) -> f64 has_tag(wer, tag) -> bool
health(wer) -> f64 has_item(wer, id) -> i64
stamina(wer) -> f64 floor() -> i64
rand() -> f64
Alle Leseoperationen sind an der Bedingungsgrammatik aus 08 gespiegelt — Scripts sehen nichts, was eine Bedingung nicht auch sähe, sie können nur damit rechnen. Das ist die Regel; die Liste selbst wird endgültig, wenn Stufe (c) gebaut wird.
Bewusst gestrichen: turn() und enemy_count(). Für beide gibt es kein benanntes Beispiel, und
enemy_count() deckt sein eigenes Motivbeispiel („Schaden skaliert mit der Zahl der Untoten im Raum")
nicht einmal ab — dafür bräuchte es Iteration. Die Umkehrbarkeit aus Q1 hängt daran, dass diese
Schnittstelle klein bleibt; sie auf Vorrat wachsen zu lassen, während die Stufe verschoben ist, wäre
das Gegenteil.
health und stamina sind eigene Funktionen statt attribute("health"), weil Ticket 01 scharf
trennt: max_health ist ein Attribut, der Lebensstand ist Zustand. Diese Trennung darf im Script-API
nicht verwaschen.
rand() läuft durch den Generator aus Game, und Rhais eigenes rand-Paket wird nicht
registriert. Sonst gilt „Nachladen würfelt nicht neu" aus Ticket 14 für alles außer für Scripts — und
ausgerechnet die interessanten Effekte würfelten beim Laden neu.
Geschrieben wird ausschließlich über Aktionen: Ein Script gibt Aktionen zurück, die in die Warteschlange aus 08 wandern. Damit deckelt der 100-Events-Zähler auch ein Script, das sich selbst aufruft.
Scripts sind die elfte Registry
{ action = "run_script", script = "meinmod:feuerregen" }
run_script als neunte Aktion. Dadurch entstehen keine neuen Platzierungsregeln: Ein Script ist
überall verwendbar, wo eine Aktion stehen darf — an einem Gegenstand, einer Fähigkeit, einer
Questbelohnung, einem ai-Eintrag, einem Raum-Trigger. Dieselbe Bewegung wie core:attack in Ticket
11 und flee in 14: Was wie Engine-Logik aussieht, ist Inhalt.
Die Alternative — Scripts registrieren sich selbst auf Events — wäre genau die Variante, bei der ein Script Zustand außerhalb des Vokabulars hält, und die macht die Engine-Wahl unumkehrbar.
Risiken: beobachtet, mit benanntem Ausstiegssignal
„Beobachten" ohne Kriterium ist ein Wort ohne Folge, deshalb ausgeschrieben:
| Signal | Reaktion |
|---|---|
| Keine Rhai-Release in zwölf Monaten | wechseln — Stand 1.25.1 vom 29.05.2026, frisch, 3,4 Mio. Downloads in 90 Tagen |
| Unsoundness in einem Pfad, den wir tatsächlich nutzen | wechseln — Issue #894 ist das bisher nicht |
smartstring unmaintained (RUSTSEC-2026-0249) |
kein Ausstiegsgrund |
Zu smartstring: Das Repo wurde am 03.05.2026 archiviert, die Advisory kam am 10.08.2026 und ist ein
INFO-Eintrag über fehlende Wartung, keine Verwundbarkeit (genannte Alternativen: compact_str,
smol_str). Rhai 1.25.1 erschien danach ohne Wechsel. cargo audit wird das melden, und das ist die
angemessene Menge Ärger dafür.
Stufe (c) wandert hinter 1.0
Die Entscheidung steht jetzt, die Umsetzung nicht.
Stufen (a) und (b) sind durch 01–03 vollständig beschrieben und liefern bereits zehn Registries,
Trigger, Bedingungen, Affixe und [balance] — sehr viel Moddbarkeit ohne eine Zeile Script. Stufe (c)
ist die einzige, die eine neue Dependency, eine Bindungsschicht und eine Härtungsliste braucht.
Ticket 02 hat die Regel selbst formuliert: „Braucht ein Gegenstand etwas, das Modifikator, Aktion oder Fähigkeit nicht ausdrücken können, ist das eine Lücke im Vokabular." Eine Script-Engine einzubauen, bevor jemand an diese Lücke gestoßen ist, baut die Antwort vor der Frage — und jede Lücke, die ein Script stopfen würde, ist zuerst ein Hinweis, dass das Vokabular sie stopfen sollte.
Die Entscheidung jetzt zu treffen kostet dagegen nichts und legt fest, wogegen das Vokabular gebaut wird. Genau das ist die Leitplanke aus 05.
Folge: Die Destination der Map ändert sich — 1.0 liefert Mod-Stufen (a) und (b), Stufe (c) kommt danach.
Nachtrag an Ticket 01
run_script als neunte Aktion. Der Katalog hat damit: damage, change_attribute,
apply_modifier, remove_modifier, give_item, remove_item, start_quest (08), flee (14),
run_script (13).