cat posts/atlantis-pbem-neu-geschrieben-02-den-altcode-verstehen.md
Atlantis PbeM neu geschrieben (Teil 2): Den Altcode verstehen, bevor man ihn anfasst
Bevor eine Zeile portiert wird, braucht es eine Karte: Welche der 71 C++-Dateien tun was, wovon hängen sie ab, und was davon wird portiert, ersetzt oder weggelassen? Teil 2 zeigt, wie der Agent diese Karte baut – und wo er beim ersten Versuch nach Dateinamen ging statt nach Inhalt.
In Teil 1 entstand das
Fundament: Regeln, Roadmap, CI, und die C++-Referenz lässt sich reproduzierbar in Docker
bauen. Die Versuchung ist jetzt groß, endlich mit dem Portieren anzufangen. Die Roadmap
sagt etwas anderes. Schritt 1 heißt „Den Altcode verstehen“, und sein Done-Kriterium ist
ein Dokument: docs/porting-map.md listet jede Datei des standard-Builds mit
Klassifikation und Zielmodul.
Warum ein Dokument, bevor Code entsteht? Weil ein Agent, der eine Datei portiert, ohne ihre Abhängigkeiten zu kennen, leicht plausiblen Code produziert, der an den Rändern nicht mehr zum Rest passt. Außerdem legt die Zuordnung „C++-Datei → Python-Modul“ die Paketstruktur für alle folgenden Schritte fest. Wer das nebenbei entscheidet, trifft dieselbe Entscheidung später immer wieder.
Dieser Teil handelt deshalb noch nicht vom Portieren. Er handelt von einem Werkzeug, das die Dateimenge ohne Compiler bestimmt, einem Test für das Done-Kriterium, zwei Entscheidungsdokumenten und ziemlich viel Lektüre. Dabei stellte der Agent zweimal fest, dass seine erste Zuordnung auf dem Dateinamen statt auf dem Inhalt beruhte.
Erst entscheiden, dann kartieren
Der Auftrag für Schritt 1 begann wie der für Schritt 0: Vorgehen vorschlagen, offene Fragen stellen. Vier Fragen kamen zurück. Eine davon war, ob die Zielmodule bereits vorab in einem Entscheidungsdokument festgelegt oder erst beim Erstellen der Karte bestimmt werden sollten. Meine Antwort:
Zu 1: Wie vorgeschlagen
Zu 2: Vorab als Entscheidungsdokument
Zu 3: Was ist Deine Empfehlung? Vor- und Nachteile?
Zu 4: Einverstanden
Frage 3 betraf Dateien, die gar nicht portiert werden. Braucht jede davon ein eigenes Entscheidungsdokument? Der Agent stellte drei Varianten gegenüber: ein Dokument pro Datei, gar keine zusätzliche Dokumentation oder ein gemeinsames Dokument pro Schritt.
Ein eigenes Dokument für jede ausgelassene Datei wäre maximal nachvollziehbar, aber auch viel Zeremonie für Dinge wie einen interaktiven Editor. Gar nichts festzuhalten wäre das andere Extrem: Ob etwas nur Werkzeug oder doch Teil des beobachtbaren Verhaltens ist, wäre dann genau die stille Annahme, die unsere Regeln eigentlich verhindern sollen. Die Empfehlung war deshalb ein gemeinsames Dokument, in dem jede weggelassene Datei kurz begründet wird. Das habe ich übernommen.
Dieses Muster gefällt mir inzwischen besser als die klassische Frage „Soll ich A oder B?“. Der Agent nennt Optionen, Folgen und seine Empfehlung; ich treffe die Entscheidung. Das dauert selten länger als eine Minute, hinterlässt aber eine Begründung, die später noch nachvollziehbar ist.
Das erste Entscheidungsdokument des Projekts, 0001, legt die Paketstruktur fest. Drei
Eigenschaften des C++-Codes lassen sich nicht sinnvoll eins zu eins nach Python
übertragen.
Die Klasse Game ist über zehn Dateien verteilt: game.cpp, runorders.cpp,
monthorders.cpp, npc.cpp, modify.cpp und weitere. In Python
soll daraus keine ebenso über den Dateibaum verteilte Klasse werden. Dazu kommen
Funktionen wie Game::CreateWorld, deren Implementierung erst das jeweilige Regelwerk
liefert – eine Trennung, die beim C++-Build über unterschiedliche Implementierungen
derselben deklarierten Schnittstelle entsteht. Und schließlich liegen die Spieldaten in
globalen Tabellen, erreichbar über den globalen Zeiger Globals.
Die Entscheidung: Die fachliche Struktur bleibt erhalten, aber Python-Module werden
nach Konzepten benannt statt nach C++-Dateien. Aus aregion.cpp wird zum Beispiel
core/region.py. Game bleibt als zentrales Objekt erhalten, größere
Verhaltensbereiche wandern aber in Module unter core/game/. Der Vertrag zum Regelwerk
wird als Protocol modelliert und Globals nicht mehr global gelesen, sondern als
Abhängigkeit übergeben.
Das Dokument enthielt außerdem eine Tabelle mit der Zuordnung der einzelnen Dateien. Merk Dir die Tabelle. Sie kommt später noch einmal zurück.
Das Inventar ohne Compiler
Welche Dateien gehören überhaupt zum standard-Build?
In Teil 1 hat der Docker-Container das aus den Abhängigkeitsdateien des Compilers ermittelt: 71. Für die Porting-Map reicht diese Lösung aber nicht. Der Test, der später die Vollständigkeit der Karte prüft, soll in der normalen Python-CI laufen und dafür nicht erst die C++-Engine bauen müssen.
Also entstand unter tools/inventory/ ein kleines Werkzeug, das die Dateimenge statisch
ableitet. Es nimmt die Quelldateien der Engine-Bibliothek aus CMakeLists.txt, ergänzt
main.cpp und die fünf Pflichtdateien des standard-Regelwerks und folgt anschließend
rekursiv allen lokalen #include "…"-Zeilen.
Der erste Lauf stürzte ab. In game.cpp steht:
// reference/game.cpp
#ifdef WIN32
#include <memory.h> // Needed for memcpy on windows
#include "io.h" // Needed for access() on windows
#define F_OK 0
#endif
io.h steht in Anführungszeichen, liegt aber nicht im Quellbaum. Unter Windows kommt
der Header aus der Toolchain. Unser einfacher Include-Walker behandelte ihn zunächst wie
eine lokale Datei und suchte vergeblich danach.
Die Regel seitdem: Ein Include wird nur weiterverfolgt, wenn das Ziel tatsächlich im
Repository existiert. Danach lieferte die statische Analyse exakt dieselben 71 Dateien
wie der Compiler im Container. Ein diff der beiden Listen blieb leer. Zusätzlich
entstanden 231 Include-Kanten.
Das Done-Kriterium von Schritt 1 lässt sich damit direkt in pytest ausdrücken:
# tests/tools/test_inventory.py
@needs_reference
def test_porting_map_lists_exactly_the_build_files() -> None:
"""Step 1's done criterion: every file of the standard build is in the map, and nothing else."""
result = check_map(PORTING_MAP, inventory(REFERENCE).files)
assert result.ok, f"missing {result.missing}, extra {result.extra}"
Der Test liest die erste Spalte der Dateitabelle in docs/porting-map.md und vergleicht
sie mit dem statisch bestimmten Inventar. Wird das Submodule später auf einen neuen
Commit gehoben und gehört plötzlich eine weitere Datei zum Build, wird die CI rot, bis
die Karte nachgezogen ist.
Genau so war „messbar“ in der Roadmap gemeint. Nicht: „Die Karte sieht vollständig aus.“ Sondern: Ein Test kann beweisen, dass für jede Datei des Builds ein Eintrag existiert und keine fremde Datei hineingerutscht ist.
Das Werkzeug berechnet außerdem die starken Zusammenhangskomponenten des
Include-Graphen mit Tarjans Algorithmus, in knapp 40 Zeilen Python. Es gibt genau eine
Komponente mit mehr als einer Datei: aregion.h, army.h, battle.h, events.h,
faction.h, object.h und unit.h.
Das ist wenig überraschend das Objektmodell. Regionen enthalten Objekte, Objekte enthalten Einheiten, Einheiten gehören zu Fraktionen, und Kampf- und Ereignisstrukturen referenzieren mehrere dieser Typen zurück. Die Roadmap behandelt dieses Geflecht ohnehin als eigenen Schritt. Der restliche Include-Graph ist nach dem Zusammenfassen dieser Komponente azyklisch.
Lesen, nicht raten
Jetzt kam die eigentliche Arbeit: Für jede Datei brauchten wir eine Aufgabe in einem Satz, eine Klassifikation und ein Zielmodul.
Der Agent hat dafür alle 31 Header vollständig gelesen, ohne Kommentare ungefähr 5.000
Zeilen. Bei den 40 .cpp-Dateien wurden zunächst die definierten Funktionen erfasst und
die Implementierungen dort gelesen, wo der Name allein nicht genug verriet.
Nicht jede der fast 48.000 Codezeilen wurde dabei einzeln studiert. Das wäre auch wenig
sinnvoll gewesen. Die 5.166 Zeilen Datentabellen in gamedata.cpp, die 5.052 Zeilen von
genrules.cpp und 1.717 Ortsnamen in standard/world.cpp mussten nicht Zeile für Zeile
gelesen werden, um ihre Rolle im System zu verstehen. Genau das steht auch im Journal,
damit aus „analysiert“ später nicht versehentlich „vollständig gelesen“ wird.
Der Fork hilft stärker dabei, als ich erwartet hatte. Seine eigene Architekturdoku beschreibt die Trennung zwischen Engine und Regelwerk, die fünf Pflichtdateien jedes Regelwerks und die Turn-Pipeline:
RunGame → PreProcessTurn → ReadPlayers → ReadOrders → RunOrders → WriteWorldEvents →
WriteReport → WritePlayers
Dazu kommen 24 eigene Entscheidungsdokumente. Der Agent durfte diese Dokumentation nutzen, sollte die einzelnen Dateien aber trotzdem anhand ihres Inhalts klassifizieren. Diese Vorgabe war wichtiger, als es zunächst aussah.
Der größte Fund steckt in rng.hpp, dem Zufallszahlengenerator.
Nach Dateiname und Größe – ein Header-only-File mit 499 Zeilen – sieht das zunächst wie
ein Kandidat für „durch die Standardbibliothek ersetzen“ aus. Python hat schließlich
random, und dessen Kern basiert ebenfalls auf dem Mersenne Twister MT19937.
Der Header erklärt ziemlich ausführlich, warum das nicht reicht:
// reference/rng.hpp
// Draws a value in [0, bound), consuming raw generator output exactly as libstdc++'s
// std::uniform_int_distribution does.
//
// WHY THIS IS WRITTEN OUT INSTEAD OF USING THE STANDARD DISTRIBUTION. std::mt19937 is specified
// bit for bit and produces the same stream everywhere. The DISTRIBUTIONS ARE NOT SPECIFIED.
Der entscheidende Unterschied liegt zwischen Generator und Verteilung. MT19937 erzeugt den Rohdatenstrom. Wie daraus beispielsweise eine Zahl zwischen 0 und 5 wird, ist eine zweite Operation – und deren Algorithmus ist bei den C++-Verteilungen nicht so festgelegt, dass verschiedene Standardbibliotheken denselben Rohdatenverbrauch garantieren.
Genau das ist hier relevant. rng.hpp dokumentiert einen Test, bei dem libstdc++ und
libc++ mit demselben MT19937-Zustand für zehn Ziehungen im Bereich 0 bis 5 nicht nur
unterschiedliche Ergebnisse liefern, sondern auch unterschiedlich viele Rohwerte
verbrauchen. Danach ist nicht bloß diese eine Zahl verschieden. Der komplette
Zufallsstrom ist verschoben.
Der Fork hat deshalb die für Atlantis PbeM relevanten Operationen so implementiert,
dass sie das Verhalten von libstdc++ reproduzieren: begrenzte Ganzzahlen, kanonische
Doubles, Normal- und Binomialverteilung, shuffle inklusive der
libstdc++-Optimierung für zwei Indizes pro Ziehung und die gewichtete Auswahl.
Python und die C++-Engine teilen sich damit zwar MT19937 als Grundidee, aber das reicht
für unsere Anforderungen nicht. Schon das Seeding der C++-Engine läuft über
minstd_rand und seed_seq; anschließend unterscheiden sich auch die Algorithmen, die
aus den Rohwerten konkrete Zufallsentscheidungen machen. random.seed(12345) und
seed_random(12345) bedeuten deshalb nicht denselben Generatorzustand und schon gar
nicht dieselbe Folge von Spielentscheidungen.
Wer rng.hpp einfach durch Pythons random ersetzt, bekommt aus demselben sichtbaren
Seed eine andere Welt.
Damit war die Klassifikation eindeutig: rng.hpp wird portiert. Und zwar als Erstes.
Es gab mehrere kleinere Funde derselben Art. graphs.h enthält zwei
Dijkstra-Varianten; eine davon addiert auf jede Kante zusätzlich 1. Ob das ursprünglich
Absicht war, ist für den Port zunächst egal. Es ist beobachtbares Verhalten, also bleibt
es erhalten.
safe_list.h existiert, damit Container während der Iteration Elemente verlieren
können, ohne die laufenden Iteratoren unbrauchbar zu machen. An Stellen, die nur das
aktuelle Element entfernen, lässt sich das in Python beispielsweise durch Iteration
über einen Snapshot wie list(container) abbilden. Entscheidend ist dabei nicht die
C++-Hilfsklasse selbst, sondern welche Elemente anschließend noch in welcher Reihenfolge
besucht werden – denn auch daran können spätere Zufallsziehungen hängen.
ci_string vergleicht Strings ohne Beachtung der Groß- und Kleinschreibung und behandelt
_ dabei wie ein Leerzeichen. Und gamedata.h enthält vier namenlose Enums mit 230
Items, 107 Skills, 81 Objekten und 63 Terrains, die wiederum positionsgekoppelt auf 16
Tabellen in gamedata.cpp verweisen.
Die Dateinamen erzählen davon fast nichts.
Die Karte
Am Ende werden 66 Dateien portiert: 46.746 Zeilen oder 97,5 Prozent des betrachteten Codes.
Vier Dateien beziehungsweise Hilfsbereiche mit zusammen 329 Zeilen werden durch
Python-Mittel ersetzt: der 14-Zeilen-Logger durch logging, safe_list.h durch normale
Python-Container und passende Iterationsmuster, scoped_enum.hpp durch das Verhalten von
IntEnum und die Versionsmakros durch eine Python-Lösung.
Eine Datei entfällt vollständig: edit.cpp, ein interaktiver Editor für Spielstände mit
874 Zeilen.
Und dann kam die Tabelle aus Entscheidungsdokument 0001 zurück.
Dort war economy.cpp unter core/game/ einsortiert und specials.cpp unter
core/spells.py. Nach dem Lesen der Dateien war beides offensichtlich falsch.
economy.cpp enthält keine einzige Game-Methode. Dort liegen ARegion-Methoden für
Bevölkerung, Löhne, Märkte und Migration.
specials.cpp hat wiederum nichts mit Zaubern zu tun. Die Datei enthält Soldier-,
Army- und Battle-Methoden für Spezialangriffe im Kampf.
Der Agent hatte die erste Tabelle nach Dateinamen geschrieben. Zwanzig Minuten grep
hätten beide Fehler verhindert.
Das führte zu einer anderen Frage: Wie korrigiert man ein bereits akzeptiertes Entscheidungsdokument? Unsere Regel lautet, akzeptierte ADRs nicht nachträglich umzuschreiben, sondern eine neue Entscheidung anzulegen, wenn eine alte ersetzt wird.
Der Agent schlug hier etwas anderes vor, und ich fragte noch einmal nach:
Was empfiehlst Du mir?
Seine Empfehlung war eine Korrektur in der Porting-Map mit Verweis auf 0001, aber kein
neues Entscheidungsdokument.
Das ergibt Sinn. 0001 legt Regeln für die Paketstruktur fest. Diese Regeln waren nicht
falsch. Falsch waren zwei Beispielzuordnungen innerhalb der Tabelle. Die eigentliche
Regel – Module nach dem Konzept statt nach dem C++-Dateinamen zu benennen – führt sogar
direkt zur Korrektur: core/economy.py und core/battle.py.
Ein neues ADR wäre erst nötig, wenn wir eine der Architekturentscheidungen selbst
ändern, etwa die Aufteilung des Game-Verhaltens in Funktionsmodule.
Das zweite Entscheidungsdokument, 0002, behandelt alles, was bewusst nicht portiert
wird. Dazu gehören edit.cpp sowie die Unterbefehle map und mapunits mit ihren
Funktionen aus game.cpp, zusammen ungefähr 1.180 Zeilen.
Das Kriterium lautet: Der Code läuft nur auf ausdrücklichen Aufruf, verändert keinen Spielzustand, der später von einem Zug gelesen wird, und erzeugt keine Ausgabe für Spieler.
Drei Grenzfälle sind im Dokument mit beiden möglichen Lesarten festgehalten. Der
Unterbefehl check, ein Syntaxprüfer für Befehlsdateien, wäre nach dem Kriterium
eigentlich verzichtbar. Er benutzt allerdings denselben Parser wie der normale Zug. Ein
checker-Argument wird durch mehr als sechzig Process*Order-Funktionen gereicht.
Diesen Pfad müssen wir ohnehin portieren. Und check gibt dem späteren
Vergleichs-Harness die Möglichkeit, Befehlsdateien durch beide Parser laufen zu lassen,
ohne jedes Mal einen vollständigen Zug zu rechnen.
Also bleibt check.
Der Graph und die Reihenfolge
231 Kanten zwischen 71 Dateien ergeben als Diagramm vor allem ein Knäuel. Verdichtet man sie auf 13 Gruppen – Utilities, Spieldefinitionen, Daten, Objektmodell, Befehle, Kampf, Ereignisse, Game, Weltgenerierung, Berichte, Regelbuch, Regelwerk und Einstieg – wird ein Muster sichtbar, das zunächst falsch herum wirkt.
Untere Schichten wie Daten und Objektmodell zeigen nach oben auf game.
skills.cpp, items.cpp, aregion.cpp, economy.cpp und faction.cpp inkludieren
alle game.h. Nicht weil sie die komplette Spiellogik brauchen, sondern unter anderem
weil dort Globals deklariert ist. Dieser globale Zeiger auf die Konfiguration taucht
im betrachteten Code 1.215-mal auf. game.h wird damit zu einem Header, den 20 Dateien
einbinden, weil er sehr viel kennt.
Im Python-Port wird Globals zur expliziten Abhängigkeit. Ein Modul bekommt die
benötigte Konfiguration übergeben, statt sie über einen globalen Zeiger aus game.h zu
holen. Ein Teil dieser Rückkanten verschwindet damit automatisch.
Aus Include-Graph und Roadmap ergibt sich eine sinnvolle Portierungsreihenfolge: zuerst
Zufallsgenerator und String-Helfer, danach Spieldefinitionen und Datentabellen, dann der
Regelbuch-Generator und anschließend das Objektmodell als zusammenhängender Block.
Darauf bauen Weltgenerierung, Spielstand, Befehle und Berichte auf. Ganz zuletzt folgen
die Zugphasen in der Reihenfolge von Game::RunOrders.
Eine kleine Reibung mit der Roadmap ist bereits sichtbar: Der Regelbuch-Generator ist
Schritt 5 und kommt damit vor dem Objektmodell aus Schritt 6, benötigt für eine Funktion
aber einen Faction-Stub. Das ist kein großes Problem. Es ist nur besser, es jetzt zu
wissen als mitten in Schritt 5.
Stolperfallen
- Dateinamen sind keine Klassifikation.
economy.cppundspecials.cppwaren nach ihrem Namen plausibel einsortiert und nach dem Lesen trotzdem falsch. Bevor ein Entscheidungsdokument Dateien Zielmodulen zuweist, gehört wenigstens die Liste der definierten Funktionen auf den Tisch. - Größe sagt nichts darüber, ob etwas ersetzbar ist. Der 14-Zeilen-Logger wird ersetzt, der 499-Zeilen-Zufallsgenerator vollständig portiert. Entscheidend ist nicht, ob Python etwas mit demselben Namen anbietet, sondern ob das beobachtbare Verhalten übereinstimmen muss.
- Ein Header in Anführungszeichen ist nicht automatisch lokal.
#include "io.h"unter#ifdef WIN32hat den ersten Include-Walk zum Absturz gebracht. Wer Includes statisch auflöst, muss prüfen, ob die Datei im Quellbaum überhaupt existiert. - „Nie bearbeiten, nur ersetzen“ gilt für Entscheidungen, nicht für jeden Tippfehler darin. Zwei falsche Beispielzeilen rechtfertigen kein neues ADR, solange die eigentliche Entscheidung unverändert bleibt. Die konkrete Zuordnung lässt sich in der Karte korrigieren und auf das ursprüngliche Dokument zurückführen.
- Auch Werkzeugreibung gehört zur Arbeit. Zwei lange Heredocs mit der Zeilentabelle
brachen den Shell-Kanal des Clients mit
unexpected EOFab. Eincat > dateiohne Eingabe blockierte 120 Sekunden bis zum Timeout. Der Agent wechselte anschließend auf Skriptdateien und den Editor. Kein Problem der Atlantis-PbeM-Engine, aber ein realer Teil der Arbeit mit dem Agenten. - Grenzfälle gehören mit beiden Lesarten ins Dokument. Ob
checkWerkzeug oder Verhalten ist, lässt sich begründen. Beide Sichtweisen aufzuschreiben und anschließend bewusst zu entscheiden kostet ein paar Sätze. Eine stillschweigende Entscheidung lässt sich später deutlich schlechter rekonstruieren.
Der Stand nach Teil 2
Noch immer keine Zeile portierte Spiellogik. Dafür ist die Karte vollständig: 71 Dateien, jede mit Aufgabe, Klassifikation, Zielmodul und Anmerkung. 66 werden portiert, vier ersetzt, eine entfällt.
Dazu kommen 231 Include-Kanten, eine zyklische Komponente aus sieben Headern, 13 Architekturgruppen, eine daraus abgeleitete Portierungsreihenfolge, zwei akzeptierte Entscheidungsdokumente und neun Tests. Einer davon prüft direkt das Done-Kriterium dieses Roadmap-Schritts. Die Arbeit landete in drei Pull Requests.
Die Portierungsabdeckung steht weiterhin bei 0 Prozent von 47.949 Zeilen. Ab jetzt kann sie sich ändern.
In Teil 3 bauen wir zuerst das Vergleichs-Harness. Die Referenz-Engine spielt definierte
Szenarien mit festem Seed, und wir prüfen, ob das Harness eine absichtlich eingebaute
Abweichung erkennt. Erst wenn wir dem Vergleich vertrauen können, lohnt es sich,
rng.hpp nach Python zu portieren.
Weiterlesen
- Atlantis PbeM neu geschrieben (Teil 1) – Fundament, Regeln und der Docker-Build der Referenz.
- pytest von Null – das Done-Kriterium eines Roadmap-Schritts als Test.
- Determinismus testen: Zufall, Zeit & I/O kontrollieren – warum die Ziehungsfolge des Zufallsgenerators Teil des Verhaltens ist.
- Python lernen (Teil 10): Module und Projektstruktur
– die Grundlagen der Paketstruktur, die
0001festlegt.
Sources: geekblogio/atlantis-pbem-engine,
dessen docs/architecture.md
und rng.hpp,
Tarjans Algorithmus,
Python random,
std::shuffle auf cppreference,
Daniel Lemire: Fast Random Integer Generation in an Interval,
std::uniform_int_distribution auf cppreference.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.