"""flat_kv.py -- readers for the flat 'KEY value' tuning tables and the whitespace-positional tables under Data/, Weapons/, Badges/, Avatars/, GUI/. Two shapes exist: parse_kv(text) KEY value -> {KEY: value} the engine's GlobalConsts loader (loader-prototypes.md M1): the file is stepped through with the Mars::Script tokenizer (mars_data.Script), NOT read by lines. KEY value one token each: `COLOR "48 29 2"` must be quoted; `LIST 1 2 3` is LIST=1 and 2=3 NAME { a block, skipped to its matching `}` } ignored Keys match case-insensitively (_stricmp) and the FIRST occurrence wins: a key is consumed on first sight, later duplicates are "multiply defined" and ignored. A final pair whose value touches EOF (no trailing newline) is DROPPED -- StrategyVars.txt and encounters.txt each lose their last key this way in the shipped data. Files: Data/globals.txt, Data/species.txt, Data/Strategy/StrategyVars.txt, Data/Combat/*.txt, Data/encounters.txt, ... parse_rows(text) tok tok tok ... -> [[tok, ...], ...] one record per line, whitespace separated, quoted tokens may contain spaces; '//' comments. Files: Weapons/_turrets.txt, Weapons/_defaultweapons.txt, Data/Combat/damfx*.txt, Data/Strategy/playercolors.txt, Badges/BadgeTable.txt, Avatars/AvatarTable.txt, GUI/WeaponIconPlacements.txt (read by other engine code; line-based, unchanged) Stdlib only. """ from __future__ import annotations import re from typing import Any from mars_data import AT_END, NO_INPUT, OK, Script, ScriptToken, coerce __all__ = ["parse_kv", "parse_rows", "duplicates", "color", "strip_comment", "split_tokens", "parse_kv_file", "parse_rows_file"] _TOK_RE = re.compile(r'"([^"]*)"|(\S+)') # --- row tables (line-based) ------------------------------------------------ def strip_comment(line: str) -> str: """Remove a trailing // comment, ignoring // inside double quotes.""" in_q = False i = 0 n = len(line) while i < n: c = line[i] if c == '"': in_q = not in_q elif c == "/" and not in_q and line.startswith("//", i): return line[:i] i += 1 return line def split_tokens(line: str) -> list[tuple[str, bool]]: """Split a line into (token, was_quoted) pairs.""" out = [] for m in _TOK_RE.finditer(line): if m.group(1) is not None: out.append((m.group(1), True)) else: out.append((m.group(2), False)) return out def parse_rows(text: str, *, typed: bool = True) -> list[list[Any]]: rows = [] for raw in text.splitlines(): line = strip_comment(raw).strip() if not line: continue toks = split_tokens(line) rows.append([coerce(t) if (typed and not q) else t for t, q in toks]) return rows # --- KEY value tables (the GlobalConsts loader loop) ------------------------- def _steps(text: str, warnings: list): """Yield (line, key, value_text, value_quoted) for every complete pair the loader would consume; record every tolerance in `warnings`.""" sc = Script(text) while True: rc, t = sc.next() if t.key_unterminated: warnings.append(f"line {t.key_line}: unterminated quote runs to end of input") if t.value_unterminated: warnings.append(f"line {t.value_line}: unterminated quote runs to end of input") if rc != OK: if t.key_status != NO_INPUT and t.key != "}": if t.value_status == NO_INPUT or t.key_status == AT_END: warnings.append(f"line {t.key_line}: trailing key '{t.key}' without a value: dropped") else: warnings.append(f"line {t.key_line}: final pair '{t.key}' '{t.value}' touches end of " "input (no trailing newline): dropped") return if t.type == ScriptToken.PAIR: yield t.key_line, t.key, t.value, t.value_quoted elif t.type == ScriptToken.OPEN: warnings.append(f"line {t.key_line}: block '{t.key}' skipped") if sc.skip_block(1) != OK: return # CLOSE at top level: ignored def parse_kv(text: str, *, typed: bool = True, on_dup: str = "first", warnings: list | None = None) -> dict: """KEY value per line -> dict keyed by the first-seen spelling. on_dup: 'first' (engine: first occurrence wins), 'last', 'error'. Pass warnings=[] to collect dropped pairs, duplicates and skipped blocks.""" w = warnings if warnings is not None else [] d: dict = {} spelling: dict[str, str] = {} # folded key -> first spelling for lineno, key, txt, quoted in _steps(text, w): val = coerce(txt) if (typed and not quoted) else txt lk = key.lower() if lk in spelling: if on_dup == "error": raise ValueError(f"line {lineno}: duplicate key {key}") w.append(f"line {lineno}: '{key}' multiply defined (first occurrence kept)") if on_dup == "first": continue d[spelling[lk]] = val continue spelling[lk] = key d[key] = val return d def duplicates(text: str) -> dict[str, list[int]]: """folded key -> line numbers, for keys that appear more than once.""" seen: dict[str, list[int]] = {} for lineno, key, _, _ in _steps(text, []): seen.setdefault(key.lower(), []).append(lineno) return {k: v for k, v in seen.items() if len(v) > 1} def color(value: str) -> tuple: """'r g b' or 'r g b a' -> tuple of numbers.""" return tuple(coerce(t) for t in value.split()) def _read(path) -> str: with open(path, "rb") as f: return f.read().decode("cp1252") def parse_kv_file(path, **kw) -> dict: return parse_kv(_read(path), **kw) def parse_rows_file(path, **kw) -> list: return parse_rows(_read(path), **kw)