Zum Inhalt springen

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.

Papierschnitt: Ein lächelndes Eichhörnchen sitzt in einer Baumhöhle voller Haselnüsse.

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 SaveFileError an der ersten erkannten Stelle ab.
  • Uninitialisierter Speicher ist kein Referenzwert. gm_alias = 1565081938 beschreibt 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

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.

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!