cat posts/atlantis-pbem-neu-geschrieben-09-die-spieldatei.md
Atlantis PbeM neu geschrieben (Teil 9): Die Spieldatei – erst treu lesen, später verbessern
148.044 Zeilen Werte ohne einen einzigen Feldnamen: das Spielstandsformat von Atlantis PbeM. Teil 9 portiert Laden und Speichern so treu, dass eine neue Welt Byte für Byte wie die Referenz gespeichert wird – und zeigt, wo ein Legacy-Format seine Wahrheit versteckt.
Nach Teil 8 erzeugt der Python-Port aus einem Seed dieselbe Welt wie die C++-Referenz. Bislang existiert sie allerdings nur im Speicher.
Für ein Spiel, das Zug für Zug per E-Mail läuft, reicht das nicht. Zwischen zwei Zügen muss der komplette Zustand auf die Platte – und später wieder so eingelesen werden, dass das Spiel exakt dort weiterläuft.
Schritt 8 der Roadmap portiert deshalb Laden und Speichern. Das Done-Kriterium:
Ein von der Referenz geschriebenes Spiel lädt in Python und überlebt einen Round-Trip ohne Verlust.
Bei einem alten Dateiformat ist die Versuchung groß, zuerst das Format zu reparieren. Man sieht 150.000 Zeilen ohne Feldnamen und denkt ziemlich schnell an JSON.
Die Roadmap sagt dazu bewusst nur: Das bestehende Format wird zuerst treu gelesen. Ein neues Format ist eine spätere Entscheidung.
Dieser Schritt zeigt ziemlich gut, warum diese Reihenfolge sinnvoll ist. Denn was eine Spieldatei bedeutet, steht längst nicht alles in der Datei.
Ein großer Teil ihrer Spezifikation steckt im Code, der sie liest.
Ein Format ohne Feldnamen
So beginnt game.out für die Welt aus Teil 8:
# reference/snapshot-tests/worldgen/standard/output/game.out
atlantis_game
328197
Wyreth
131072
1
-1
6621
3
404
100
1
2
Zuerst kommt die Magic-Zeile, dann die Engine-Version als gepackte Ganzzahl, der Name des Regelwerks und dessen Version. Danach folgen Jahr, Monat, ein Seed und fünf Zähler.
Anschließend: Fraktionen, Regionen mit ihren Gebäuden und Einheiten und schließlich die Quests.
Das Format ist fast vollständig positional. Ein Wert pro Zeile, vor Listen steht ihre
Länge, Feldnamen gibt es nicht. Gegenstände und andere Codes werden teilweise über Namen
wie IRON, plain oder Fleet gespeichert. Leere Verweise heißen NO_ITEM oder
NO_SKILL, Illusionen bekommen ein i vor die Item-Abkürzung.
Die Welt aus Teil 8 belegt damit 148.044 Zeilen und 546.346 Bytes.
Zur Erinnerung, falls Du hier in die Serie einsteigst: Atlantis PbeM ist ein Strategiespiel per E-Mail. Die Welt besteht aus Hexregionen, in denen Spielerfraktionen ihre Einheiten bewegen, Gebäude benutzen, produzieren und handeln. Praktisch der gesamte dauerhafte Zustand dieser Welt steckt in dieser einen Textdatei.
Der C++-Reader braucht dafür im Wesentlichen zwei Stream-Idiome:
f >> x;
überspringt führenden Whitespace und liest den nächsten Wert.
Und:
std::getline(f >> std::ws, s);
überspringt führenden Whitespace – einschließlich Leerzeilen – und liest anschließend den Rest der Zeile.
Der Python-Port bildet genau diese beiden Arten des Lesens ab:
# src/atlantis_ng/core/savefile.py
def integer(self) -> int:
"""``f >> int``: the next token, which must be an integer."""
self.skip_whitespace()
start = self.pos
token = self.token()
if _INTEGER.fullmatch(token) is None:
self.pos = start
raise SaveFileError(f"line {self.line_number}: expected an integer, found {token!r}")
return int(token)
Die Modellklassen aus
Teil 7 bekommen dazu jeweils
read_in und write_out, analog zu den Readin- und Writeout-Methoden der Referenz.
Schreiben ist dabei der einfache Teil: Werte in der richtigen Reihenfolge in einen Textstream legen.
Lesen ist interessanter.
Denn Readin ist keineswegs einfach die Umkehrung von Writeout.
Laden verändert den Zustand
Ein Ausschnitt aus Unit::Readin:
// reference/unit.cpp
f >> num;
set_name(name);
f >> type;
// ...
f >> guard;
if (guard == GUARD_ADVANCE) guard = GUARD_NONE;
if (guard == GUARD_SET) guard = GUARD_GUARD;
Der Wachstatus wird also nicht einfach aus der Datei übernommen.
GUARD_ADVANCE, ein vorübergehender Status für einen Zug, wird beim Laden zu
GUARD_NONE. GUARD_SET wird zum normalen GUARD_GUARD.
Auch der Name wird nicht bloß zugewiesen. Er läuft durch set_name, das bei Einheiten
wieder die Nummer anhängt.
Dasselbe Muster zieht sich durch das Format.
Die Gebäudesequenz einer Region wird aus den gelesenen Gebäuden neu aufgebaut, statt als
fertiger Zähler aus der Datei übernommen zu werden. Eine Fraktion gilt nach dem Laden als
NPC, wenn ihre Fraktionstypen die dafür verwendeten -1-Werte enthalten. Einheiten mit
unbekannter Fraktion werden verworfen. Deaktivierte Gegenstände verschwinden, ebenso
Fertigkeiten ohne Lerntage. Doppelte Produktionen werden entfernt und gemeldet.
Diese Regeln stehen nicht neben der Formatbeschreibung.
Sie sind Teil der Formatbeschreibung.
Wer game.out direkt durch ein hübscheres Datenmodell ersetzt, ohne vorher den Reader zu
verstehen, läuft Gefahr, genau diese Semantik zu verlieren.
Dann sieht die neue Datei vielleicht sauberer aus – aber eine Einheit behält nach dem Laden plötzlich einen Status, den sie nur für einen Zug besitzen sollte.
Der Reader ist hier die eigentliche Spezifikation.
Wo die Referenz nicht hinsieht
Eine Eigenschaft der Referenz möchte ich trotzdem nicht übernehmen.
Viele der Readin-Methoden lesen Werte über C++-Streams, ohne anschließend den
Streamzustand zu prüfen.
Steht beispielsweise Text an einer Stelle, an der ein Integer erwartet wird, scheitert
die numerische Extraktion, setzt failbit und schreibt bei diesem fehlgeschlagenen
Integer-Parse 0 in die Zielvariable. Der Stream bleibt danach im Fehlerzustand; weitere
formatierte Extraktionen lesen nicht einfach normal weiter.
Die Referenz behandelt diesen Zustand in vielen der verschachtelten Reader aber nicht dort, wo er entsteht. Je nachdem, wo die Datei beschädigt ist, arbeitet der Code dadurch mit Null-, Leer- oder alten Werten weiter, bis vielleicht ein späterer expliziter Check den Ladevorgang doch noch beendet.
Das ist für einen Kompatibilitätsport kein besonders nützliches Verhalten.
Die Datei ist bereits kaputt. Das Einzige, was jetzt wirklich hilft, ist zu erfahren, wo.
Deshalb entstand vor dem Reader Entscheidung 0004:
Ungültige Eingabe wirft dort einen benannten Fehler, wo die Referenz unbemerkt weiterliest.
Die Prüfungen, die die Referenz selbst bewusst macht, bleiben erhalten. Eine falsche
Magic-Zeile, eine inkompatible Engine-Version oder ein anderes Regelwerk führen wie im
Original zu False und der entsprechenden Meldung.
Fehlt dagegen tief in einer Region plötzlich ein Integer, wirft der Python-Port
SaveFileError und nennt die Zeile.
Die Entscheidung ist absichtlich allgemeiner als game.out. Sie gilt auch für die
Spielerdatei und später für die Befehlsdateien.
Damit muss dieselbe Frage beim nächsten Parser nicht wieder von vorn diskutiert werden.
Man hätte die Abweichung auch nur in der Portierungskarte bei game.cpp notieren
können. Eine eigene Entscheidung macht aber sichtbar, dass hier eine Regel für mehrere
Reader entstanden ist.
Zwei Orakel lagen schon im Repository
Ein normaler Round-Trip-Test wäre für diesen Schritt zu schwach.
Wenn Reader und Writer denselben Fehler machen, kann
lesen → schreiben → lesen
perfekt aussehen.
Deshalb braucht der Port Dateien, die die Referenz geschrieben hat.
Und davon gab es bereits reichlich.
Seit Teil 3 liegt im Submodul der Snapshot der Weltgenerierung: game.out und
players.out für Seed 20260822.
Dazu kommen vierzehn gespeicherte Zustände eines laufenden Spiels mit einer Spielerfraktion, von Zug 0 bis Zug 13. Jede dieser Spieldateien hat ungefähr 48.000 Zeilen.
Kein zusätzliches Testprogramm, kein Docker-Build.
Einfach Dateien lesen.
Der schärfste Test kostet dadurch fast nichts:
# tests/core/game/test_files.py
def test_the_new_game_saves_byte_for_byte_what_the_reference_saved(game: Game, worldgen_game: str) -> None:
"""Step 7's world plus this step's writer give the reference's game.out, seed line included."""
assert new_game(game, SEED)
assert save(game) == worldgen_game
Der Python-Port erzeugt die Welt aus Teil 8 und speichert sie.
Das Ergebnis entspricht dem game.out der C++-Referenz Byte für Byte:
alle 148.044 Zeilen, einschließlich der Seed-Zeile.
Das funktioniert, weil Teil 8 nicht nur dieselbe Welt erzeugt, sondern den
Zufallsgenerator bis an dieselbe Stelle bringt. SaveGame schreibt als Seed nämlich
keinen gespeicherten MT-Zustand, sondern zieht:
rng::get_random(10000)
Auch dieser Zufallszug muss also an genau derselben Stelle passieren.
Der Writer bestand diesen Test beim ersten vollständigen Lauf.
Was Laden mit derselben Welt macht
Der zweite Test nimmt dieselbe Richtung umgekehrt.
Er lädt das game.out der Referenz in Python und vergleicht das Ergebnis mit dem
vollständigen Weltdump der Sonde aus Teil 8, also mit dem Zustand unmittelbar nach
NewGame.
Drei Unterschiede bleiben.
Alle drei entstehen beim Laden auch in der C++-Referenz selbst.
Die 163 Lairs hießen direkt nach der Weltgenerierung nur Dummy. Beim Laden läuft ihr
Name durch set_name, das die Objektnummer ergänzt. Danach heißen sie beispielsweise
Dummy [17].
Bei einer Region wird buildingseq von 2 auf 1 zurückgesetzt, weil der Reader den Wert
aus den tatsächlich geladenen Objekten neu berechnet.
Und die beiden NPC-Fraktionen tragen nach dem Laden bei den Fraktionstypen -1 statt
der Werte aus der frisch erzeugten Welt.
Das game.out nach dem ersten Zug des bestehenden Harnesses zeigt dieselben
Umbenennungen der 163 Lairs.
Der Test behandelt diese drei Unterschiede deshalb ausdrücklich als erwartetes Ladeverhalten.
Genau solche Dinge wären verloren gegangen, wenn dieser Schritt direkt mit einem neuen Speicherformat begonnen hätte.
Ein Round-Trip muss nicht dieselben Bytes ergeben
Für einen laufenden Spielstand nimmt der nächste Test Zug 13.
Die Referenzdatei wird geladen, wieder gespeichert, erneut geladen und erneut gespeichert.
Beim ersten Speichern unterscheiden sich zwei Zeilen von der alten Referenzdatei:
Zeile 2 enthält die Engine-Version. Der Port schreibt die aktuelle Patch-Version, genau wie es eine aktuelle Referenz-Engine beim Speichern tun würde.
Zeile 7 enthält den Seed.
Das zweite Speichern unterscheidet sich vom ersten nur noch in der Seed-Zeile.
Das ist wichtig für die Bedeutung von „Round-Trip ohne Verlust“.
OpenGame liest den gespeicherten Wert aus Zeile 7 und seedet damit den
Zufallsgenerator. SaveGame schreibt später wieder einen neuen
get_random(10000)-Wert.
Die Seed-Zeile ist also nicht als unverändert zu konservierendes Feld gedacht. Sie transportiert den Ausgangspunkt für den nächsten Zufallsstrom.
Ein Byte-identischer Load-Save-Round-Trip ist deshalb kein allgemeines Versprechen dieses Formats.
Was erhalten bleiben muss, ist der Spielzustand und die Semantik des weiteren Zufallsstroms.
13 Sekunden für eine Textdatei
Der Reader war beim ersten Lauf korrekt.
Er brauchte 13,4 Sekunden.
Bei einer 546-KB-Datei ist das auffällig genug, um nicht über „Python ist halt langsamer“ hinwegzugehen.
Der Profiler zeigte zwei sehr unspektakuläre Ursachen.
integer() wollte bei einem möglichen Fehler die Zeilennummer melden. Die erste Fassung
berechnete diese Nummer aber bei jedem Integer, noch bevor feststand, ob überhaupt ein
Fehler vorlag:
self.text.count("\n", 0, self.pos)
Immer wieder über denselben immer längeren Text.
Dieser Pfad allein tauchte mit 11,3 Sekunden im Profil auf.
Nach der Korrektur wird die Zeilennummer erst berechnet, wenn tatsächlich eine Exception gebaut werden muss.
Der zweite Hotspot war lookup_item.
In der Datei kommen 14.693 Item-Abkürzungen vor. Für jede davon lief der Port linear durch alle 230 Gegenstände und verglich die Strings ohne Beachtung der Groß-/Kleinschreibung.
Die Lösung ist ein Index:
# src/atlantis_ng/core/data/game_data.py
def lookup_item(self, name: str) -> ItemId | None:
"""The index of the item whose abbreviation (with ``i`` for an illusion) is ``name``; the first of two alike."""
if self._item_index is None:
self._item_index = {}
for index, item in enumerate(self.items):
tag = ("i" if item.type & ItemCategory.ILLUSION else "") + item.abbr
self._item_index.setdefault(ci_key(tag), ItemId(index))
return self._item_index.get(ci_key(name))
setdefault ist hier nicht bloß bequem. Die Referenz nimmt beim linearen Suchen den
ersten passenden Eintrag in Tabellenreihenfolge. setdefault behält beim Aufbau des
Index ebenfalls den ersten.
Der eine Modifier, der eine solche Abkürzung verändern kann, verwirft den Cache wieder.
Danach braucht das Laden ungefähr 0,25 Sekunden, das Speichern rund 0,02 Sekunden.
Die Reihenfolge ist mir an dieser Stelle wichtiger als die Optimierung selbst:
Erst gegen das Orakel korrekt werden. Dann messen. Dann genau die Hotspots ändern, die wirklich Zeit kosten.
players.out: die Seite des Spielleiters
Neben game.out gibt es players.out.
Das ist die Arbeitsdatei des Spielleiters. Für jede Fraktion stehen darin Name, E-Mail-Adresse, Passwort und Einstellungen.
Zwischen zwei Zügen kann der Spielleiter daraus players.in machen und ergänzen:
Faction: new legt einen neuen Spieler an, andere Zeilen können Einheiten erzeugen,
Gegenstände oder Fertigkeiten vergeben und später auch Befehle setzen.
Bevor überhaupt ein Reader existierte, kam wieder der einfachste Test zuerst:
write_players direkt nach new_game erzeugt exakt das players.out des
Referenz-Snapshots.
330 Bytes, Byte für Byte.
Für die Gegenrichtung gab es diesmal keine fertige Fixture. Die Turn-Snapshots zeigen
erst den Zustand nach einem kompletten Zug, nicht direkt nach ReadPlayers.
Also bekam die Modellsonde einen weiteren Modus:
model_probe players N
Zehn verschiedene players.in-Dateien sind direkt in die Sonde eingebettet. Für jedes
Szenario erzeugt die Referenz zunächst dieselbe Welt, liest die Spielerdatei ein und
schreibt anschließend Rückgabewert, Logs, Fraktionen, Report-Zustand, neue Einheiten und
das daraus entstehende players.out als JSON.
Die zehn Fälle decken sämtliche Zeilentypen ab, einschließlich neuer Fraktionen und Fehlermeldungen für ungültige Angaben.
Jedes Szenario läuft in einem eigenen Prozess. Die Referenz besitzt weiterhin prozessweiten Zustand in globalen Tabellen und in der Namensverwaltung; für isolierte Tests ist ein frischer Prozess die sauberste Grenze.
Die zusammengeführte Fixture hat 43.395 Bytes.
Der Python-Port bestand alle zehn Szenarien beim ersten Lauf.
Zwei Details daraus sind trotzdem interessant.
Ein Feld voller Speichermüll
Die Sonde zeigte bei der Wachen-Einheit Nummer 2:
gm_alias = 1565081938
gm_alias ist die Kennung, über die der Spielleiter frisch erzeugte Einheiten später in
weiteren Zeilen derselben Spielerdatei ansprechen kann.
Das Problem: Die C++-Konstruktoren initialisieren dieses Feld nicht.
Die konkrete Zahl 1565081938 hat deshalb keine fachliche Bedeutung. Sie ist lediglich
der Speicherinhalt, den der konkrete Probe-Lauf an dieser Stelle vorgefunden hat – kein
Referenzwert, den ein Port reproduzieren sollte.
Solange nie jemand versucht, eine bereits vorhandene NPC-Einheit über genau diesen zufälligen Alias anzusprechen, fällt das nicht auf.
Der Python-Port macht daraus bewusst definiertes Verhalten:
gm_alias = 0
Und der Vergleich ignoriert gm_alias bei den NPC-Einheiten, für die der Wert in der
Referenz nie sinnvoll initialisiert wurde.
Das ist ein anderer Fall als die vielen seltsamen, aber definierten Verhaltensweisen der letzten Teile.
Speichermüll wird nicht konserviert.
Die letzte Zeile verschwindet
Das zweite Detail ist dagegen reproduzierbares Referenzverhalten.
Die Schleife von ReadPlayers prüft vor jeder Verarbeitung eof():
// reference/game.cpp
while(!f.eof()) {
auto token = parser.get_token();
if (!token) {
f >> parser;
continue;
}
// ...
Der string_parser liest seine nächste Zeile intern über
std::getline(is >> std::ws, input);
Hat die Datei am Ende eine letzte Zeile ohne abschließenden Zeilenumbruch, liefert
getline zwar noch deren Inhalt, setzt beim Erreichen des Dateiendes aber gleichzeitig
eofbit.
Beim nächsten Durchlauf ist while(!f.eof()) deshalb bereits falsch.
Die Zeile liegt im Parser – wird aber nicht mehr verarbeitet.
Das Testszenario unterminated endet mit
Faction: new
Name: Unfinished
wobei hinter Name: Unfinished kein Newline mehr steht.
Die neue Fraktion behält deshalb ihren Standardnamen Faction (3).
Der Python-Port bildet selbst diese Eigenheit nach. SaveReader.input_line liefert
nicht nur die Zeile, sondern gleichzeitig, ob danach das Streamende erreicht ist:
# src/atlantis_ng/core/savefile.py
def input_line(self) -> tuple[str, bool]:
"""``f >> parser``: the next line as :meth:`line` takes it, and whether the stream's end flag is set after it.
The flag is what ``f.eof()`` reports: set once the whitespace before the line, or the
line itself, ran into the end of the text. At the end the line is empty, as ``getline``
leaves the string it could not fill.
"""
self.skip_whitespace()
if self.pos >= len(self.text):
return "", True
end = self.text.find("\n", self.pos)
if end < 0:
line = self.text[self.pos :]
self.pos = len(self.text)
return line, True
line = self.text[self.pos : end]
self.pos = end + 1
return line, False
Ist das ein Bug im C++-Reader?
Sehr wahrscheinlich.
Kann er einen echten Spielzug beeinflussen?
Ja. Ein Spielleiter muss seine Datei nur ohne abschließenden Zeilenumbruch speichern.
Deshalb gehört dieser Effekt zunächst zum Kompatibilitätsziel.
Eine Zeilenart bleibt in diesem Schritt noch offen: Order: mit einem echten
Einheitenbefehl braucht den Befehlsparser aus Schritt 9. Die Prüfungen davor – fehlende
Einheit, falsche Fraktion, fehlender Befehl – sind bereits portiert. Order: quit
ebenfalls.
Der eigentliche Einheitenbefehl endet noch bewusst mit NotImplementedError.
Der State-Dump hatte ein konzeptionelles Problem
Seit Teil 3 ist für das Vergleichs-Harness ein state.json geplant.
Statt bei einer Abweichung nur
game.out, Zeile 84213
zu melden, soll der Dump später sagen können, dass beispielsweise in Region 217 bei Einheit 442 zwölf statt dreizehn Silber liegen.
Der ursprüngliche Plan war:
Die Referenz schreibt game.out, der portierte Reader lädt diese Datei und erzeugt
daraus JSON.
Der Python-Port erzeugt dasselbe JSON direkt aus seinen Objekten.
Klingt logisch.
Funktioniert aber nicht.
Wir haben gerade gesehen, dass Laden den Zustand verändert.
Direkt nach NewGame heißen 163 Lairs Dummy; nach einem Load heißen sie
Dummy [n]. Eine Gebäudesequenz verändert sich. Die NPC-Fraktionstypen ebenfalls.
Ein Dump der geladenen C++-Datei und ein Dump der noch nie geladenen Python-Objekte
würden deshalb schon in Zug 0 Unterschiede melden – obwohl beide Engines exakt dasselbe
game.out geschrieben haben.
Der State-Dump wurde deshalb anders gebaut.
Er ist kein Dump des Objektmodells, sondern ein positionaler Konverter des Dateitexts.
tools/compare/state.py hat 385 Zeilen und benutzt dieselben Leseprimitive wie der
eigentliche Reader. Beide Engines erzeugen state.json aus ihrem jeweils geschriebenen
game.out.
Damit beschreibt der Dump genau das, was auch tatsächlich persistent geworden ist.
Für die Welt aus Teil 8 entstehen daraus ungefähr 3,3 MB JSON mit Feldnamen.
Auch dieser Konverter zeigt noch einmal, wie viel implizites Wissen im Format steckt.
Die Datei verrät beispielsweise nicht, wie viele Fraktionstypen das Regelwerk verwendet.
Faction::Writeout schreibt einfach einen Wert pro Eintrag einer globalen Liste, ohne
vorher einen Zähler zu speichern. Der Konverter muss diese Anzahl deshalb von außen
kennen.
Bei Fertigkeiten hängt das Vorhandensein eines zusätzlichen Erfahrungswerts ebenfalls von einer Regelwerkseinstellung ab, die nicht separat im Dateikopf steht.
Die Planänderung wurde im Journal und in docs/comparison.md festgehalten. Eine eigene
Architekturentscheidung braucht sie nicht: Das Verhalten der Engine ändert sich nicht,
nur der Aufbau eines Testartefakts.
Jetzt spielt der Python-Port im Harness mit
Mit Weltgenerierung, Laden und Speichern kann der Port inzwischen alles, was der
Referenzbefehl new für das Vergleichs-Harness braucht.
Deshalb bekommt das Harness aus Teil 3 seine zweite Engine.
PythonEngine.new erzeugt die Welt im selben Prozess und schreibt:
game.out, players.out, state.json und die Konsolenausgabe.
Das dauert rund 0,6 Sekunden.
run kann die Python-Engine noch nicht – dafür fehlt die Zugabwicklung aus Schritt 11.
Der erste Vergleich von worldgen scheiterte trotzdem.
Nicht an der Welt.
An engine-output.txt.
Die C++-Referenz druckt während der Weltgenerierung Fortschrittspunkte und Leerzeilen,
die in Teil 8 bewusst nicht nachgebaut wurden. Gleichzeitig fehlten dem Port eine echte
Meldung – Making a level... – und das Banner aus main.cpp.
Die Lösung trennt Inhalt und Fortschrittsformatierung.
Die fehlende Meldung wurde ergänzt. Die Python-Engine schreibt Banner und Seed-Zeile wie
main.cpp. Und ausschließlich für die Konsolenausgabe entfernt die Normalisierung
Punktzeilen und Leerzeilen:
# tools/compare/normalize.py
def normalize_console(data: bytes) -> bytes:
"""``normalize_text``, then without the progress: lines of dots and blank lines."""
lines = normalize_text(data).split(b"\n")
return b"".join(line + b"\n" for line in lines if line and not PROGRESS.match(line))
Andere Textartefakte behalten ihre Leerzeilen.
Danach:
$ uv run tools/compare/cli.py compare worldgen reference python
worldgen: reference vs python: equivalent (1 turns, 4 artefacts)
Vier Artefakte: game.out, players.out, state.json und engine-output.txt.
Die beiden state.json sind byteidentisch.
Ein Referenztest hält diesen Vergleich inzwischen auch in der CI fest.
Der Heredoc, zum zweiten Mal
Ein weniger fachlicher Fehler dieses Schritts ist trotzdem erwähnenswert.
Heredocs hatten den Agenten schon in Teil 2 beschäftigt. Diesmal war es eine andere Variante derselben Werkzeugreibung.
Ein schnelles Skript sollte vier Python-Modulen ihre write_out-Methoden hinzufügen.
Das Skript selbst wurde über die Shell transportiert.
Dabei wurden die \n aus dem eingebetteten Python-Code zu echten Zeilenumbrüchen mitten
in String-Literalen.
Das Reparaturskript sollte diese Umbrüche zurückverwandeln – und traf mit derselben Regel zusätzlich Docstrings.
Ergebnis: vier Dateien aus Git wiederherstellen und die Änderung noch einmal ausführen, diesmal mit einem Skript, das nicht durch dieselbe Shell-Quoting-Schicht musste.
Das Journal vermerkt den Fehler ausdrücklich als Wiederholung einer bereits bekannten Werkzeugfalle.
Das Interessante daran ist weniger Bash.
Eine bekannte Falle im Gedächtnis reicht nicht zuverlässig aus. Wirklich wirksam wird sie erst als feste Regel im Arbeitskontext oder wenn das Werkzeug den problematischen Pfad gar nicht mehr anbietet.
Was der Schritt gekostet hat
Vier Pull Requests schließen Schritt 8 ab: Reader und Writer von game.out samt
Entscheidung 0004, die Spielerdatei und ihr Probe-Modus, State-Dump plus zweite
Harness-Engine und schließlich der Abschluss.
Unter src/ sind 1.328 Zeilen hinzugekommen.
Davon entfallen 96 auf den Stream-Reader, 117 auf Laden und Speichern des Spiels, 373
auf die Spielerdatei und 80 auf den Ruleset-Hook für neue Fraktionen. Rund 620 weitere
Zeilen verteilen sich auf die read_in-/write_out-Paare des bestehenden
Objektmodells.
Das Harness bekam den 385-zeiligen State-Konverter und die Python-Engine. Die Modellsonde wuchs um 157 Zeilen.
416 schnelle Tests – 62 davon neu – laufen in ungefähr 20 Sekunden. 13 weitere Tests vergleichen gegen die Referenz.
Die Portierungsabdeckung steigt von 34,9 auf 35,6 Prozent.
Mit market.cpp, production.cpp und quests.cpp sind drei kleinere Dateien jetzt
vollständig portiert. Die großen Modellklassen bleiben offen, weil dort noch Berichte
und Zuglogik fehlen.
Neben Entscheidung 0004 stehen vier bewusste Abweichungen in der Portierungskarte:
Python arbeitet mit Streams statt fest verdrahteten Dateinamen, gm_alias startet
definiert bei 0 statt mit uninitialisiertem Speicher, ein in der Referenz problematischer
Fraktionszeiger wird im Port gesetzt, und Order: wartet noch auf Schritt 9.
Stolperfallen
- Der Reader ist Teil der Formatspezifikation. Reset-Regeln, neu berechnete Zähler und verworfene Einträge stehen nicht im Dateitext. Wer nur den Writer nachbaut oder das Format sofort ersetzt, übersieht genau diese Semantik.
- Ein Round-Trip beweist vor allem, dass Reader und Writer zueinander passen. Für Kompatibilität braucht man zusätzlich Dateien, die das Original geschrieben hat.
- Byteidentität braucht den richtigen Vergleichspunkt. Eine frisch erzeugte Welt kann exakt dieselbe Datei schreiben. Nach einem Load verändern dagegen Loader-Semantik, Engine-Version und Seed den nächsten Save.
- Ungültiger Streamzustand ist kein nützliches Kompatibilitätsziel. Wo die Referenz
eine kaputte Eingabe nicht diagnostiziert, bricht der Port mit
SaveFileErroran der ersten erkannten Stelle ab. - Uninitialisierter Speicher ist kein Referenzwert.
gm_alias = 1565081938beschreibt einen einzelnen Lauf, keine Semantik. Der Port definiert das Feld als 0. - Vergleiche Persistenz mit Persistenz. Ein Dump geladener Objekte ist nicht dasselbe wie ein Dump frisch erzeugter Objekte, wenn Laden selbst Zustand transformiert.
- Erst korrekt, dann messen. Von 13,4 auf 0,25 Sekunden ging es nicht durch einen neuen Parser, sondern durch zwei konkrete Hotspots, die der Profiler sichtbar machte.
- Eine bekannte Werkzeugfalle verschwindet nicht dadurch, dass man sie kennt. Erst eine feste Arbeitsregel oder ein anderer Werkzeugpfad verhindert zuverlässig die Wiederholung.
Der Stand nach Teil 9
Der Python-Port kann jetzt Spielstände der Referenz laden und wieder in deren Format schreiben.
Bei einer frisch erzeugten Welt ist das Ergebnis Byte für Byte identisch mit dem
game.out der Referenz – alle 148.044 Zeilen.
Bei einem bereits laufenden Spiel wird der Zustand semantisch erhalten; die Seed-Zeile wird beim nächsten Speichern entsprechend dem Zufallsstrom neu erzeugt, und ältere Snapshots bekommen beim Schreiben die aktuelle Engine-Patchversion.
Auch die Spielerdatei funktioniert in beide Richtungen. Ihre Zeilentypen sind gegen zehn
Szenarien der Referenz geprüft, bis auf den bewusst auf Schritt 9 verschobenen
Einheitenbefehl hinter Order:.
Das Vergleichs-Harness besitzt jetzt zwei Engines. Für worldgen sind Referenz und
Python-Port über vier Artefakte äquivalent, und ihre state.json stimmen Byte für Byte
überein.
Was noch fehlt, ist klar abgegrenzt.
Order: braucht den Befehlsparser. Zustandsdumps zwischen einzelnen Zugphasen kommen
mit Schritt 11. Und erst dort bekommt die Python-Engine ihren eigentlichen
run-Befehl.
Der nächste Teil nimmt sich deshalb parseorders.cpp vor: den Parser, durch den jeder
Spielerbefehl von MOVE bis CAST läuft.
Gewachsener Code, viele Sonderfälle – und wieder dasselbe Ziel:
Lesbarer werden, ohne ein einziges bisher akzeptiertes oder abgelehntes Kommando versehentlich anders zu behandeln.
Weiterlesen
- Atlantis PbeM neu geschrieben (Teil 8) – die Weltgenerierung, deren exakter Zufallsstrom die Byteidentität der neuen Welt hier möglich macht.
- Atlantis PbeM neu geschrieben (Teil 7) – das Objektmodell, das jetzt lesen und schreiben kann, und die Modellsonde.
- Atlantis PbeM neu geschrieben (Teil 3) – das Harness, in dem der Port jetzt als zweite Engine mitspielt.
- Determinismus testen: Zufall, Zeit & I/O kontrollieren – warum ein Seed allein nur dann hilft, wenn auch der Verbrauch des Zufallsstroms kontrolliert ist.
- Python lernen (Teil 9): Dateien und Speichern – die Grundlagen zu Textdateien, auf denen Laden und Speichern hier aufsetzen.
Sources: game.cpp,
unit.cpp,
aregion.cpp,
faction.cpp
und string_parser.hpp
des Forks,
std::basic_istream::operator>>,
std::ios_base::iostate,
std::getline,
Python cProfile.
Tastenkürzel
- j k
- nach unten / oben scrollen
- n p
- nächster / vorheriger Abschnitt
- g G
- Anfang / Ende des Artikels
- t
- Gliederung zeigen
- ?
- diese Hilfe zeigen
- Esc
- schließen
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.