163 lines
6.3 KiB
Python
163 lines
6.3 KiB
Python
"""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)
|