polserver/testsuite/worlddiff/worlddiff.py

243 lines
9.5 KiB
Python
Raw Permalink Normal View History

Refactor Item states to be explicit instead of implicit (#919) * extend tests * more tests for world save * explicit handle the special layers in wornitem * add Location variant and relocate() methods * refactor GottenItem and ensure Chr, not wornitems, is saved * deflake escript watch test * dropitem and undo refactoring * uomod changes * add more item tests (buy, sell, hair...) * use location for undoing trade container properly * rewrite loaddata to use relocate, fixing startup crashes * do remaining location changes * fix CI tests * remove container pointer from item and add wearer() reference * remove layer writes and explicit intrinsic weapon handling * add preparing state for fresh items without serial number * move wornitems to detached state only when it has a serial number * load corpse items into the correct layer * do not set a layer on load before the item is equipped * add more tests to equipped and intrinsic items * remove layer and substitute with the location() * add core-changes summary * mark items as being worn on corpse if they only moved within the corpse * block Preparing from being reached, just to be sure * added integrity sweep check * Reduce direect changes to item location * Add Item::move_into() and rewrite calling sites * Fix container slots * get rid of the slot_index on the item, because Location() already has it * fix race in test leading to macos issues * fix dangling pointer * remove corpse can_equip_list and rely on the GottenItem rules to dress the corpse again * let item->destroy() detach the item * make integrity checks fail the tests * use place_at(item, newpos) for changing item position InWorld * add boat test for item moved out of boat that should not be visible anymore * use place_at() in more places * ensure CreateItemAtLocation really does * fix Claude instructions * remove state we didn't need * fine tuning * reduce test flakiness
2026-08-10 20:17:53 +02:00
#!/usr/bin/env python3
"""Compare two POL world saves after a normalising pass.
Used by the ``shard_test_roundtrip`` ctest: the shard loads a world and saves it again
without touching it, and this script asserts the second save carries the same data as the
first. A raw byte comparison cannot be used - see NORMALISATION below.
worlddiff.py <dir_a> <dir_b> [--verbose]
Exit code 0 if every data file matches, 1 otherwise (with a diff on stdout).
"""
import argparse
import difflib
import os
import sys
# NORMALISATION
#
# Three things differ between two saves of the same world for reasons that have nothing to
# do with what was persisted, so they are normalised away:
#
# 1. Block order, but only in OBJECTHASH_ORDERED below. Every other file is compared in
# file order - see BLOCK ORDER.
#
# 2. The comment header, which carries the core version and object counts.
#
# 3. VOLATILE_KEYS and VOLATILE_PROPS below - values that are not a function of the world
# contents, so two saves of the same world may legitimately disagree about them.
#
# Everything else is compared verbatim, including the order of keys inside a block: the
# writers emit those in a fixed code order, so a change there is a real change.
# BLOCK ORDER
#
# Most of these files are compared in file order, because for them the order means
# something. items.txt and multis.txt are written by walking realm zones
# (savedata.cpp: write_items/write_multis), and the load reads them back in file order into
# the same vectors via add_item_to_world's zone.items.push_back - so file order IS zone
# vector order, which every WorldIterator walk hands to scripts as the result order of
# ListItemsNearLocation and friends. specs/items/08 reworks exactly those insert/erase
# helpers, so this file is the one place that would notice it reordering them.
#
# The exception is the files written by walking objStorageManager.objecthash, an
# unordered_map whose iteration order is an STL implementation detail. It was in fact stable
# across every round trip measured here, but that is not a property worth asserting across
# platforms and standard libraries, so those files are sorted by a stable key instead.
#
# Known soft spot, verified rather than assumed: ONE on-cursor item in the starting save is
# enough to fail this test, legitimately. write_items appends gotten items after its zone
# walk, and a lifted item has been extricated from its zone, so it exists only in that tail.
# Reloading it makes it an ordinary ground item at the holder's feet, and the next save
# writes it in zone order - same bytes, different place in the file. Confirmed by moving a
# block to the tail by hand and round-tripping it: the reload put it back in zone order and
# this comparison reported it.
#
# It is unreachable as the tests are wired today, and the reason is worth knowing before
# anyone rewires them: the round trip starts from what shard_test_2 saved, and shard_test_2
# is a bare `pol` with POLCORE_TEST_RUN=2 and no pyuo client, so it cannot put an item on a
# cursor. Whatever pass 1 left on a cursor was already converted to a ground item when
# shard_test_2 loaded it. Giving pass 2 a client, or re-pointing this test at shard_test_1's
# save, makes the hazard live.
#
# If that happens, do not sort items.txt - that would give up the zone-order signal for the
# whole file to accommodate one block. Mark the item instead: set a CProp on it before the
# save and exempt blocks carrying that CProp from position comparison here, matching them by
# content as a set while every other block stays in file order. The contents do line up
# across the two saves (WriteGottenItem writes the holder's coordinates, which is exactly
# where the reloaded ground item sits), so a per-object exemption is enough.
OBJECTHASH_ORDERED = {
"pcs.txt",
"pcequip.txt",
"npcs.txt",
"npcequip.txt",
}
# Keys dropped from every block before comparing.
VOLATILE_KEYS = {
# The serial allocators are boot history, not world data. GetNextUnusedItemSerial()
# scans upward from the current counter and skips serials already in the objecthash,
# while read_data() clamps the counter back down to the stored value only when the
# stored value is the smaller of the two (uimport.cpp, dave's 3/9/3 comment). So the
# counter ratchets past whatever live serials sit above it: measured on the test shard,
# the first reload after a test pass advanced LastItemSerialNumber by 14, the next by 1,
# and every reload after that by 0, on a world whose files were byte-identical
# throughout. Comparing them would make this test fail on the run that matters - the one
# straight after the test passes - and pass on reruns.
"LastItemSerialNumber",
"LastCharSerialNumber",
}
# CProp names dropped from every block before comparing.
VOLATILE_PROPS = {
"gameclock", # advances between the load and the save
}
# The world data directory also holds .bak (previous save) and .ndt (save in progress)
# copies of each file, and the ds/ datastore directory. Only the .txt files are compared.
class Block:
r"""One `Type [Header]\n{\n\tKey\tValue\n...\n}` element."""
def __init__(self, type_line, lines):
self.type_line = type_line
self.lines = lines
def sort_key(self):
# Serial identifies items/characters; Name identifies storage areas and the like.
# The full body is the final tiebreak so the order is total and stable.
keyed = dict()
for key, value in self.lines:
keyed.setdefault(key, value)
return (
self.type_line,
keyed.get("Serial", ""),
keyed.get("Name", ""),
tuple(self.lines),
)
def render(self):
out = [self.type_line, "{"]
out.extend("\t%s\t%s" % (key, value) for key, value in self.lines)
out.append("}")
return out
def parse(path):
"""Parse a world data file into normalised blocks. Raises on malformed input."""
blocks = []
with open(path, "r", encoding="utf-8", errors="surrogateescape") as f:
lines = f.read().splitlines()
i = 0
while i < len(lines):
line = lines[i]
if not line.strip() or line.startswith("#"):
i += 1
continue
type_line = line
i += 1
if i >= len(lines) or lines[i] != "{":
raise ValueError("%s:%d: expected '{' after %r" % (path, i + 1, type_line))
i += 1
body = []
while i < len(lines) and lines[i] != "}":
entry = lines[i]
i += 1
# blocks may carry comments of their own - resource.txt annotates each pool
# with the config file it came from
if entry.startswith("#") or not entry.strip():
continue
if not entry.startswith("\t"):
raise ValueError("%s:%d: expected a key line, got %r" % (path, i, entry))
key, _, value = entry[1:].partition("\t")
if key in VOLATILE_KEYS:
continue
if key == "CProp" and value.split(" ", 1)[0] in VOLATILE_PROPS:
continue
body.append((key, value))
if i >= len(lines):
raise ValueError("%s: unterminated block %r" % (path, type_line))
i += 1 # the '}'
blocks.append(Block(type_line, body))
return blocks
def canonical(path):
blocks = parse(path)
if os.path.basename(path) in OBJECTHASH_ORDERED:
blocks.sort(key=Block.sort_key)
out = []
for block in blocks:
out.extend(block.render())
return out
def data_files(directory):
return sorted(name for name in os.listdir(directory) if name.endswith(".txt"))
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("dir_a", help="the save the round trip started from")
ap.add_argument("dir_b", help="the save the shard wrote after loading it")
ap.add_argument("--verbose", action="store_true", help="report matching files too")
ap.add_argument(
"--context", type=int, default=3, help="diff context lines (default 3)"
)
args = ap.parse_args()
files_a = data_files(args.dir_a)
files_b = data_files(args.dir_b)
failed = False
only_a = set(files_a) - set(files_b)
only_b = set(files_b) - set(files_a)
for name in sorted(only_a):
print("MISSING: %s was saved before the round trip but not after" % name)
failed = True
for name in sorted(only_b):
print("EXTRA: %s appeared during the round trip" % name)
failed = True
for name in files_a:
if name in only_a:
continue
path_a = os.path.join(args.dir_a, name)
path_b = os.path.join(args.dir_b, name)
try:
canon_a = canonical(path_a)
canon_b = canonical(path_b)
except ValueError as error:
print("PARSE: %s" % error)
failed = True
continue
if canon_a == canon_b:
if args.verbose:
print("ok: %s (%d blocks)" % (name, canon_a.count("{")))
continue
failed = True
print("DIFFERS: %s" % name)
diff = difflib.unified_diff(
canon_a,
canon_b,
fromfile="%s (before)" % name,
tofile="%s (after)" % name,
n=args.context,
lineterm="",
)
for line in diff:
print(" " + line)
if failed:
print("world save round trip FAILED")
return 1
print("world save round trip ok: %d files match" % len(files_a))
return 0
if __name__ == "__main__":
sys.exit(main())