cat posts/atlantis-pbem-neu-geschrieben-05-von-arrays-zu-datenmodellen.md
Atlantis PbeM neu geschrieben (Teil 5): Von globalen Arrays zu Datenmodellen
5.166 Zeilen C++-Tabellen, vier Enums, die nur per Position dazu passen, und ein Regelwerk, das alles in place umbaut. Teil 5 zeigt, wie die Spieldaten aus der Referenz gedumpt, generiert und gegen sie geprüft werden – und was eine -2 in den Fixtures verriet.
In Teil 4 bekam der Port seinen Zufallsgenerator und die String-Helfer: 637 Zeilen, 1,3 Prozent. Schritt 4 der Roadmap nimmt sich das nächste Fundament vor: die statischen Spieldaten. Gegenstände, Fertigkeiten, Gelände, Rassen, Gebäude und Zauber – alles, was in Atlantis PbeM schon definiert ist, bevor die erste Welt entsteht.
Die Roadmap verlangt dafür zweierlei. gamedata, gamedefs und die Konfiguration des
standard-Regelwerks müssen als Python-Daten vorliegen. Gleichzeitig sollen die
positionsgekoppelten Enums und Arrays durch Strukturen ersetzt werden, bei denen man
erkennen kann, was ein Wert bedeutet.
Das Done-Kriterium bleibt so nüchtern wie beim Zufall: Ein Dump aller Tabellen muss den
C++-Daten für standard entsprechen.
Dieser Schritt lässt den Port zum ersten Mal deutlich wachsen, von 1,3 auf 15,7 Prozent. Er ist auch der erste, bei dem der Agent große Teile des Codes nicht abschreibt, sondern generiert. Und er liefert einen Fund, den der grüne Vergleich selbst nicht finden konnte, weil Referenz-Fixture und Python-Port denselben Fehler enthielten.
Sechzehn Tabellen und vier Enums
gamedata.cpp hat 5.166 Zeilen und besteht zu einem großen Teil aus Datensätzen in
geschweiften Klammern. Sechzehn globale std::vector halten die Spieldaten:
ItemDefs mit 230 Gegenständen, SkillDefs mit 107 Fertigkeiten, ObjectDefs mit 81
Gebäude- und Schiffstypen und TerrainDefs mit 63 Geländearten.
Dazu kommen zwölf weitere Tabellen für Rassen, Monster, Waffen, Rüstungen, Reittiere, Kampfgegenstände, Heilung, Spezialangriffe, Effekte, Zauberreichweiten und Attributmodifikatoren.
Ein Eintrag aus ItemDefs sieht so aus:
// reference/gamedata.cpp
{"leader", "leaders", "LEAD",
ItemType::NOTRANSPORT,
NULL, 0, 0, 0, {{-1, 0}, {-1, 0}, {-1, 0}, {-1, 0}},
NULL, 0, 0, {{-1, 0}, {-1, 0}, {-1, 0}, {-1, 0}},
10, IT_MAN | IT_LEADER, 100, 1,
15, 0, 0, 0, 2,
-1, 0,
-1, 0, 0,
0, NULL, 0,
"", { "", "", "", "" }, 0, 0},
Was die einzelnen Zahlen bedeuten, erkennt man hier nicht. Dafür braucht man den
Kommentar oberhalb der Tabelle und die Klassendefinition in items.h. Selbst -1
bedeutet je nach Feld etwas anderes: mal „kein Verweis“, mal einen echten Zahlenwert mit
besonderer Semantik.
Die Tabellen bilden die Vereinigung der Daten aller acht Regelwerke des Forks. Ein
Regelwerk schnitzt daraus beim Start seine eigene Variante. standard tut das in
ModifyTablesPerRuleset() mit 69 Aufrufen der Helfer aus modify.cpp: Einträge werden
aktiviert, deaktiviert oder verändert – direkt in den globalen Tabellen.
Vier dieser Tabellen sind zusätzlich an anonyme Enums aus gamedata.h gekoppelt:
enum {
I_LEADERS,
I_VIKING,
I_BARBARIAN,
...
I_SILVER,
I_SILVER hat den Wert 19, weil Silber an Position 19 in ItemDefs steht, also der
zwanzigste Eintrag ist. Diese Verbindung steht nirgendwo explizit. Wer im Enum oder in
der Tabelle einen Eintrag an der falschen Stelle ergänzt, verschiebt die Bedeutung aller
folgenden Indizes.
Die Architekturdokumentation des Forks warnt ausdrücklich vor dieser Positionskopplung. Der Compiler kann sie nicht prüfen.
Beim Lesen fiel noch etwas auf: Die Querverweise sind nicht einheitlich. Manche Tabellen
verweisen per Index auf andere Tabellen – etwa Produktionszutaten, Rassen oder
Geländeprodukte. Fertigkeiten, Spezialangriffe und Reichweiten werden dagegen häufig per
String referenziert, beispielsweise pSkill = "MINI" oder special = "fireball".
Im gespeicherten Spielstand landen diese Tabellenindizes später nicht. Dort werden
Abkürzungen und Namen geschrieben. Trotzdem ist die Tabellenreihenfolge Teil des
Verhaltens. Schleifen über NITEMS bestimmen zum Beispiel, in welcher Reihenfolge
Gegenstände in Berichten oder Märkten auftauchen.
Neben diesen Tabellen gibt es GameDefs, das große Konfigurationsobjekt eines
Regelwerks. In der für dieses Projekt gepinnten Referenz umfasst es 167 Einstellungen,
von MAX_SPEED bis NEW_SHIP_JOINS_FLEET_BEHAVIOR. standard/rules.cpp füllt sie für
die Welt „Wyreth“.
Fünf Entscheidungen vor dem Code
Wie in den bisherigen Schritten begann auch dieser mit einem Vorschlag und offenen Fragen.
Wie ersetzen wir die vier positionsgekoppelten Enums? Der Vorschlag war ein
IntEnum pro Tabelle. Die C++-Namen bleiben ohne Präfix erhalten, der Wert bleibt die
Tabellenposition:
I_LEADERS wird ItemId.LEADERS, R_PLAIN wird TerrainId.PLAIN.
Damit bleibt jede C++-Aufrufstelle leicht wiederzufinden. Gleichzeitig kann pyright zwischen den verschiedenen Id-Typen unterscheiden. Ein zusätzlicher Test prüft, dass die generierten Enum-Werte weiterhin zu den Positionen der Tabellen passen – genau die Beziehung, die im C++-Code nur implizit existiert.
Nur Strings als Ids zu verwenden wäre lesbarer als rohe Zahlen, würde aber eine andere Eigenschaft verlieren: Ein Tippfehler wäre dann erst zur Laufzeit sichtbar.
Abschreiben oder generieren? 5.166 Zeilen Tabellen von Hand nach Python zu übertragen wäre wahrscheinlich die größte vermeidbare Fehlerquelle dieses Schritts gewesen. Stattdessen dumpt eine C++-Sonde die Daten als JSON. Ein Generator baut daraus Python-Module mit Dataclass-Literalen.
Die generierten Dateien werden eingecheckt. Ein Test führt den Generator erneut aus und schlägt an, sobald das Ergebnis vom Repository abweicht.
Wo leben die Tabellen? Nicht global. GameData hält alle sechzehn Tabellen. Jedes
Spiel bekommt einen frischen Satz davon, auf den anschließend der Hook des Regelwerks
angewendet wird. Die einzelnen Definitionen bleiben veränderbare Dataclasses, weil
ModifyTablesPerRuleset() genau das tut: vorhandene Einträge in place ändern.
Was wird aus -1? Hier habe ich nachgefragt, weil wir dasselbe Problem schon beim
Zustandsdump aus Teil 3 hatten.
Die Regel lautet: Ein negativer Wert, der einen fehlenden Verweis bedeutet, wird
null. Ein negativer Wert, der selbst eine fachliche Bedeutung hat, bleibt eine Zahl.
NO_ITEM wird also null. Eine Marktmenge von -1, die „unbegrenzt“ bedeutet, bleibt
-1.
Diese Unterscheidung gilt für alle Dumps. Das ist wichtiger als eine möglichst
„pythonische“ Darstellung, denn bei einem späteren Vergleich soll null überall
dasselbe bedeuten.
Braucht diese Architekturentscheidung ein eigenes Dokument? Der Agent empfahl zunächst nein. Das Verhalten der Engine ändert sich schließlich nicht.
Einen Pull Request später las er Entscheidung 0001 noch einmal und fand dort einen
Satz, den er bei seiner Empfehlung übersehen hatte:
How the positional enums are replaced is Step 4's decision and gets its own record.
Damit war die Frage eigentlich längst entschieden. Also entstand
0003-positional-enums-become-generated-intenums.md, zunächst als proposed, dann von
mir im Review angenommen.
Die Sonde: zweimal dumpen
Der erste Pull Request enthält wie schon in Teil 4 noch keinen Python-Port. Stattdessen kommt ein C++-Programm dazu.
tools/reference/gamedata_probe.cpp ersetzt beim Build main.cpp und wird gegen
libengine.a sowie die bereits erzeugten Objektdateien des standard-Regelwerks
gelinkt. Die Tabellen werden dafür nicht noch einmal separat kompiliert. Die Sonde sieht
dieselben Daten wie das Referenz-Binary.
Sie läuft in zwei Modi.
gamedata_probe base schreibt die Tabellen so, wie gamedata.cpp sie definiert.
gamedata_probe standard ruft vorher ModifyTablesPerRuleset() auf und schreibt den
Zustand danach.
Beide Ergebnisse landen als Fixtures im Repository:
gamedata-base.json dient dem Python-Generator als Ausgangspunkt,
gamedata-standard.json ist die Referenz für das Done-Kriterium.
Das Ausgabeformat änderte sich gleich beim ersten Versuch.
nlohmann::json::dump(1) produzierte pro Fixture 489 KB und 33.676 Zeilen, praktisch
jedes Feld in einer eigenen Zeile. Für einen Diff ist das eher lästig.
Die neue Fassung schreibt jeden Tabelleneintrag auf genau eine Zeile. Damit schrumpft eine Fixture auf 312 KB und 735 Zeilen. Ändert sich ein Datensatz, zeigt der Diff direkt diesen Datensatz.
Auch die 167 GameDefs-Felder der Sonde wurden nicht von Hand abgetippt. Ein
Wegwerfskript las gamedefs.h und erzeugte die entsprechenden Zeilen.
Die Übersetzung eines Tabellenverweises nach JSON begann zunächst mit dieser kleinen Funktion:
// tools/reference/gamedata_probe.cpp
// A table index that means "no row" becomes null.
ordered ref(int index) {
if (index < 0) return nullptr;
return index;
}
Vier harmlose Zeilen.
Sie werden später noch wichtig.
Der Vergleich zwischen base und standard zeigt außerdem ziemlich genau, was das
Regelwerk beim Start mit den gemeinsamen Tabellen macht: 46 Tabellenzeilen ändern
sich.
24 der 25 aktivierten Gegenstände ändern ihren Status – der Speer war bereits aktiv.
Dazu kommen zwei Fertigkeiten, 15 Gebäude und fünf Reichweiten, die bei
EASIER_UNDERWORLD Ebenengrenzen überschreiten dürfen.
Eine Änderung betrifft kein Flag:
ModifyItemProductionBooster(I_AXE, I_HAMMER, 1) trägt den Hammer als Produktionswerkzeug
für die Axt ein.
Von den 69 Aufrufen in ModifyTablesPerRuleset() werden mit der normalen
standard-Konfiguration 48 ausgeführt. Die übrigen 21 liegen hinter Schaltern, die für
Wyreth deaktiviert sind.
Generieren statt abschreiben
Die Datentypen selbst sind von Hand geschrieben: pro *Type-Klasse aus den C++-Headern
eine kw_only-Dataclass, mit den Feldern in derselben Reihenfolge. Flags werden
IntFlag, kleine Enums IntEnum, Verweise bekommen den Typ ihrer jeweiligen Id.
Jedes Feld besitzt außerdem den leeren Wert der C++-Tabelle als Default. Dadurch muss ein generierter Datensatz nur die Werte nennen, die tatsächlich gesetzt sind.
Der Anführer von oben sieht in Python so aus:
# src/atlantis_ng/core/data/tables/items.py
# ItemId.LEADERS
ItemType(
name="leader",
plural="leaders",
abbr="LEAD",
flags=ItemFlag.NOTRANSPORT,
weight=10,
type=ItemCategory.MAN | ItemCategory.LEADER,
base_price=100,
combat=1,
walk=15,
speed=2,
),
Aus 34 positionsabhängigen Feldern im C++-Literal werden neun benannte Werte. Wer wissen
will, was die 15 bedeutet, liest jetzt walk=15.
Der Generator tools/gamedata/generate.py hat gut 300 Zeilen. Er liest die vier Enums
aus gamedata.h und erzeugt daraus ids.py. Anschließend lädt er jede Zeile der
Fixture anhand der Typannotationen in die passende Dataclass und rendert das Objekt
wieder als Python-Quelltext.
Default-Werte werden ausgelassen, Flags in ihre benannten Bits zerlegt und alle
erzeugten Module anschließend durch ruff format geschickt.
Das Ergebnis sind 17 Module mit insgesamt 9.030 Zeilen Python für 5.166 Zeilen C++-Tabellen. Der Port ist an dieser Stelle also länger als das Original. Das ist aber kein handgeschriebener Boilerplate: Die zusätzlichen Zeilen machen aus Positionen benannte Felder und aus Zahlen benannte Werte.
Die erste Ausgabe des Generators war trotzdem falsch.
name, plural und abbr fehlten in jedem Literal. Um Default-Werte wegzulassen,
verglich der Generator jedes Feld mit einer Default-Instanz. Diese Default-Instanz musste
er für Pflichtfelder aber mit den Werten des aktuellen Objekts erzeugen.
Damit waren ausgerechnet die Pflichtfelder immer „gleich dem Default“ und verschwanden.
Der Fixture-Vergleich hätte den Fehler beim nächsten Schritt gefunden. Entdeckt wurde er früher, weil der Agent die erste generierte Datei tatsächlich gelesen hat.
Ein zweiter Stolperer war simpler: Die Sonde übernahm aus der Rüstungstabelle das Feld
from. In C++ kein Problem, in Python ein Schlüsselwort. Das Feld heißt im Port deshalb
save_from, passend zum vorhandenen Helfer modify_armor_save_from. Danach wurden beide
Fixtures neu erzeugt.
Jetzt konnte der eigentliche Test laufen: GameData.base() erzeugen, mit dump() in
dieselbe JSON-Struktur wie die Sonde bringen und gegen gamedata-base.json vergleichen.
Alle sechzehn Tabellen stimmten überein.
Was die Tabellen wissen
GameData enthält außerdem die Nachschlagefunktionen aus items.cpp, skills.cpp und
object.cpp: find_item, lookup_skill, parse_object und ihre Verwandten.
Auch hier bleibt merkwürdiges Referenzverhalten zunächst erhalten.
parse_object liefert für ein Schiffs-Item -(item + 1), weil der Build-Befehl Schiffe
und Gebäude im selben Zahlenraum unterscheiden muss. parse_item_category liefert das
negierte IT_*-Bit und für das Wort item sogar -NITEMS.
Schön ist das nicht. Aber die späteren Aufrufer erwarten genau diese Werte.
Beim Schreiben der Tests lagen die Daten dreimal richtig und der Agent dreimal falsch.
find_item("XXXX") sollte nach seiner Erwartung nichts finden. Tatsächlich ist XXXX
die Abkürzung des Amuletts der Unverwundbarkeit.
parse_giveable_item("SILV") sollte bei ausgeschlossenem NOMARKET kein Silber
zurückgeben. Silber trägt aber tatsächlich dieses Flag und das Verhalten der Funktion ist
entsprechend.
Und beim Langboot war die Annahme falsch, welcher Schiffstyp in den Basistabellen deaktiviert ist.
Drei Testerwartungen wurden geändert. Keine Zeile Portcode.
Das ist ein nützlicher Unterschied: Ein fehlschlagender Test sagt zunächst nur, dass Erwartung und Ergebnis nicht zusammenpassen. Er sagt noch nicht, welche Seite falsch ist.
Wood or stone
Der dritte Pull Request portiert modify.cpp. Beim Helfer
ModifyObjectConstruction steht diese Zeile:
// reference/modify.cpp
if ((it < -1 && it != I_WOOD_OR_STONE) || it > (NITEMS -1)) return;
I_WOOD_OR_STONE ist -2.
Ein Gebäude mit diesem Wert wird nicht aus einem bestimmten Gegenstand gebaut, sondern
aus Holz oder Stein – abhängig davon, was in der Region verfügbar ist. Elf Gebäude in
gamedata.cpp benutzen diesen Sentinel, darunter Mine, Farm, Ranch, Timber Yard, Inn und
Quarry.
Und jetzt zurück zu den vier Zeilen aus der Sonde:
if (index < 0) return nullptr;
Jeder negative Tabellenindex wurde zu null.
Auch -2.
Damit behaupteten beide C++-Fixtures bei diesen elf Gebäuden: kein Baumaterial.
Der Python-Generator übernahm genau diese Daten. Anschließend erzeugte
GameData.base().dump() wieder dasselbe null. Der Vergleich war grün.
Das Done-Kriterium aus dem zweiten Pull Request konnte den Fehler gar nicht finden. Seine beiden Seiten stammten aus derselben bereits beschädigten Übersetzung.
Gefunden wurde die -2 erst beim Portieren des Codes, der diesen Wert tatsächlich
verwendet.
Die Sonde behandelt I_WOOD_OR_STONE jetzt ausdrücklich als Ausnahme. -2 bleibt
-2. Die Fixtures wurden neu erzeugt und der Python-Port gibt dem Wert einen Namen:
BuildMaterial.WOOD_OR_STONE
Das Feld hat entsprechend den Typ:
ItemId | BuildMaterial | None
Der Generator verarbeitet diese Union, indem er die möglichen Typen nacheinander
probiert. ItemId(-2) scheitert, BuildMaterial(-2) passt.
Das ist für mich der wichtigste Fund dieses Schritts. Ein Vergleich kann sehr gut beweisen, dass zwei Darstellungen gleich sind. Er kann nicht beweisen, dass die gemeinsame Übersetzung davor korrekt war.
Oder kürzer: Wenn die Vorlage bereits Information verloren hat, kann die Kopie perfekt mit ihr übereinstimmen und trotzdem falsch sein.
GameDefs und der Hook
Der Rest des dritten Pull Requests ist vor allem systematische Portierungsarbeit.
GameDefs wird eine kw_only-Dataclass mit den 167 Feldern der gepinnten Referenz,
jetzt in Python-Schreibweise:
defs.start_money statt Globals->START_MONEY.
Die Feldliste wurde wieder per Wegwerfskript aus gamedefs.h erzeugt, einschließlich
der Kommentare, die im Header direkt am Feld stehen. Die anonymen Enums des C++-Headers
bekommen Namen wie TaxFlag, ReportFlag und MultiplierUse.
rulesets/standard/rules.py liefert die Werte für Wyreth und die sieben
Progressionstabellen.
modify.cpp enthält 85 Helfer – nicht 90, wie die Kick-off-Notiz ursprünglich
geschätzt hatte. Im Python-Port werden daraus Funktionen, die ein GameData-Objekt
verändern:
# src/atlantis_ng/core/data/modify.py
def enable_item(data: GameData, item: ItemId) -> None:
"""Clear ``ItemType::DISABLED``."""
if not _valid(data.items, item):
return
data.items[item].flags &= ~ItemFlag.DISABLED
Auch die Guards und Sonderfälle der Referenz bleiben erhalten.
ModifySkillCost(-1) lehnt den Wert ab, während ModifyItemWeight(-5) das Gewicht auf
0 klemmt. ModifyTerrainRace leert einen Slot, wenn das übergebene Item kein Mensch ist.
ModifySpecialShields akzeptiert sogar NUM_ATTACK_TYPES als Typ, weil die Referenz
diesen Wert für „nicht abwehrbar“ verwendet.
Solche Unterschiede wirken inkonsistent. Für diesen Port sind sie trotzdem zunächst Verhalten. Zehn Tests halten sie fest, damit sie später nicht versehentlich im Namen der Sauberkeit vereinheitlicht werden.
Der Hook rulesets/standard/hooks.py::modify_tables(data, defs) bildet
ModifyTablesPerRuleset() in derselben Reihenfolge nach.
Am Ende steht wieder ein Vergleich gegen die Referenz:
# tests/rulesets/standard/test_standard_data.py
@pytest.mark.parametrize("table", TABLES)
def test_the_standard_tables_are_the_reference_dump(table: str, reference: dict[str, object]) -> None:
"""Done when a dump of all tables matches the C++ data for `standard` (docs/roadmap.md, Step 4)."""
assert standard.game_data().dump()[table] == reference[table]
Alle sechzehn Tabellen stimmen überein. GameDefs stimmt Feld für Feld, ebenso die
Progressionen.
Zusätzliche Tests schalten bewusst Optionen um, die Wyreth normalerweise deaktiviert
hat. So wird beispielsweise geprüft, dass MANIPULATE bei aktivierten Apprentices und
GEMCUTTING bei zusätzlichen Underworld-Ebenen tatsächlich freigeschaltet werden.
Damit ist Schritt 4 abgeschlossen.
Stolperfallen
- Ein grüner Vergleich beweist nur Gleichheit. Die Sonde verwandelte
-2innull, der Generator übernahm es, und der Python-Dump sah anschließend exakt genauso aus. Korrekt war trotzdem keine der beiden Seiten. - Generierter Code muss jemand lesen. Beim ersten Lauf fehlten
name,pluralundabbrüberall. Ein späterer Test hätte das gefunden, ein Blick auf die erste erzeugte Datei fand es früher. - Alte Entscheidungen gehören zum Kontext. Der Agent empfahl zunächst kein ADR für
die Enums, obwohl
0001genau dieses ADR bereits verlangt hatte. Das Problem war nicht die Begründung, sondern dass die ältere Entscheidung bei der Empfehlung nicht berücksichtigt worden war. - Sentinel-Werte verdienen Namen.
BuildMaterial.WOOD_OR_STONEerklärt mehr als-2, undItemId | BuildMaterial | Nonemacht die drei möglichen Fälle auch für pyright sichtbar. - Namen aus C++ sind nicht automatisch gültige Python-Namen.
fromwar der einzige Treffer bei dieser Portierung. Ein Generator sollte solche Fälle mitkeyword.iskeyworderkennen. - Ein roter Test verurteilt nicht automatisch den Port. Bei
XXXX, Silber und dem Langboot war jeweils die Testerwartung falsch. Erst die Referenzdaten entschieden die Frage. - Zählen ist besser als schätzen. Aus den 90 Helfern der Kick-off-Notiz wurden nach dem tatsächlichen Zählen 85. Die fünf machen für das Projekt kaum einen Unterschied; die Gewohnheit, Zahlen zu messen, schon.
Der Stand nach Teil 5
Drei Pull Requests, Schritt 4 abgeschlossen.
core/data/ enthält die sechzehn Tabellen als generierte Module, ihre Dataclasses, die
vier Id-Typen, die Lookup-Funktionen und die 85 Helfer aus modify.cpp.
core/game_defs.py enthält GameDefs; unter rulesets/standard/ liegen die Werte für
Wyreth und der Hook.
Zwei Fixtures aus der C++-Referenz halten fest, wie die Tabellen vor und nach
ModifyTablesPerRuleset() aussehen. Die Tests vergleichen den Python-Port mit beiden.
Die Portierungsabdeckung steht bei 7.537 von 47.949 Zeilen, 15,7 Prozent. 199
schnelle Tests laufen in 2,5 Sekunden. Ein spürbarer Teil davon steckt im
Generator-Test, der siebzehnmal ruff format aufruft.
Die Vergleichsquote bleibt bei 100 Prozent von drei Szenarien, weiterhin Referenz gegen Referenz. Eine spielbare Python-Engine gibt es noch nicht.
In Teil 6 entsteht dafür zum ersten Mal ein großes sichtbares Ergebnis: das Regelbuch.
genrules liest genau die Tabellen und GameDefs, die jetzt portiert sind, und erzeugt
daraus die HTML-Regeln für das Regelwerk.
Das Done-Kriterium: Das Python-Regelbuch muss inhaltlich dem der C++-Referenz entsprechen.
Weiterlesen
- Atlantis PbeM neu geschrieben (Teil 4) – die Sonde als Muster: Testvektoren kommen aus der Referenz, nicht aus dem Kopf.
- Dataclasses: Klassen ohne Boilerplate –
kw_only, Defaults undfield(default_factory=…), wie die Zeilentypen sie benutzen. - Type Hints in der Praxis: mypy, Pyright und Co. – warum
ItemId | BuildMaterial | Nonemehr ist als Dokumentation.
Sources: gamedata.h,
gamedata.cpp,
modify.cpp,
standard/extra.cpp
und docs/architecture.md
des Forks,
Python enum,
Python dataclasses,
nlohmann/json.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.