Add web-based save editor for "Reksio i Czarodzieje"

- Implemented a local web server using http.server to serve the save editor.
- Created a user interface with HTML, CSS, and JavaScript for managing game slots and editing save files.
- Added API endpoints for retrieving slot information, overview, and raw data for editing.
- Implemented functionality for saving changes back to the game files.
- Added tests for ARR and DTA formats to ensure data integrity during round-trip serialization.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Patryk Gensch
2026-08-03 01:18:08 +02:00
co-authored by Codex
commit edd8ceda9e
17 changed files with 2572 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
"""ricsave — czytanie i edycja zapisów gry „Reksio i Czarodzieje" (silnik PIK, 2004).
>>> from ricsave import Game
>>> g = Game("~/Reksio/Reksio i Czarodzieje")
>>> s = g.slot(0) # 0 = żywy stan rozgrywki
>>> s.summary()["location"]
'GULDRYK'
Rdzeń nie ma zależności zewnętrznych. Opis formatu: docs/format-zapisow.md
"""
from . import arr, catalog, dta, schema
from .slot import (
Game,
ItemsView,
SaveError,
SceneView,
Slot,
backup,
copy_slot,
load_game,
new_game,
save_game,
)
__all__ = [
"Game",
"ItemsView",
"SaveError",
"SceneView",
"Slot",
"arr",
"backup",
"catalog",
"copy_slot",
"dta",
"load_game",
"new_game",
"save_game",
"schema",
]
__version__ = "0.1.0"
+93
View File
@@ -0,0 +1,93 @@
"""Obiekt ARRAY silnika PIK — pliki .ARR i .SAV.
Format (little-endian, bez nagłówka):
uint32 count
count × { uint32 type; type==1 ? int32 : (uint32 len; bytes[len]) }
type 1 = INTEGER, type 2 = STRING (cp1250, bez terminatora).
"""
from __future__ import annotations
import struct
from pathlib import Path
ENCODING = "cp1250"
TYPE_INT = 1
TYPE_STR = 2
TYPE_BOOL = 3
TYPE_FLOAT = 4
Value = int | str
class ArrError(ValueError):
"""Plik .ARR nie trzyma się formatu."""
def loads(data: bytes) -> list[Value]:
if len(data) < 4:
raise ArrError(f"plik ma {len(data)} B, minimum to 4 B (samo count)")
(count,) = struct.unpack_from("<I", data, 0)
off = 4
out: list[Value] = []
for i in range(count):
try:
(kind,) = struct.unpack_from("<I", data, off)
off += 4
if kind == TYPE_INT:
(value,) = struct.unpack_from("<i", data, off)
off += 4
out.append(value)
elif kind == TYPE_STR:
(length,) = struct.unpack_from("<I", data, off)
off += 4
raw = data[off : off + length]
if len(raw) != length:
raise ArrError(
f"element {i}: deklarowana długość {length} B, dostępne {len(raw)} B"
)
off += length
out.append(raw.decode(ENCODING))
elif kind == TYPE_BOOL:
(value,) = struct.unpack_from("<i", data, off)
off += 4
out.append(value != 0)
elif kind == TYPE_FLOAT:
(value,) = struct.unpack_from("<i", data, off)
off += 4
out.append(value / 1000) # w Reksio i Czarodzieje dzielone jest przez 1000
else:
raise ArrError(f"element {i} @0x{off - 4:x}: nieznany typ {kind}")
except struct.error as exc:
raise ArrError(f"element {i}: plik urwany @0x{off:x}") from exc
if off != len(data):
raise ArrError(f"{len(data) - off} B śmieci na końcu (po {count} elementach)")
return out
def dumps(values: list[Value]) -> bytes:
parts = [struct.pack("<I", len(values))]
for i, value in enumerate(values):
if isinstance(value, bool):
parts.append(struct.pack("<Ii", TYPE_BOOL, 1 if value else 0))
elif isinstance(value, int):
parts.append(struct.pack("<Ii", TYPE_INT, value))
elif isinstance(value, str):
raw = value.encode(ENCODING)
parts.append(struct.pack("<II", TYPE_STR, len(raw)) + raw)
elif isinstance(value, float):
parts.append(struct.pack("<Ii", TYPE_FLOAT, int(value * 1000))) # w Reksio i Czarodzieje mnożone jest przez 1000
else:
raise ArrError(f"element {i}: typ {type(value).__name__} nieobsługiwany")
return b"".join(parts)
def read(path: str | Path) -> list[Value]:
return loads(Path(path).read_bytes())
def write(path: str | Path, values: list[Value]) -> None:
Path(path).write_bytes(dumps(values))
+122
View File
@@ -0,0 +1,122 @@
"""Manifest slotu zapisu — co, gdzie i pod jaką nazwą.
Wszystkie listy pochodzą wprost ze skryptów gry:
SCENES Game.cnv → G_ARRDATAS (BFITMP125)
CHARACTERS MAINMENU.class:652 → M_ARRCHARACTERS (CONSTRUCTOR)
MINIGAMES MAINMENU.class:652 → M_ARRPIOTR (CONSTRUCTOR)
układ plików slotu → SAVEGAME (:667) i LOADGAME (:672)
"""
from __future__ import annotations
from dataclasses import dataclass
#: Lokacje mające własny plik <NAZWA><slot>.DTA. Kolejność jak w G_ARRDATAS.
SCENES: tuple[str, ...] = (
"PODWIECZOREK1", "PODWIECZOREK2", "PODWIECZOREK3", "TRZYWEJSCIA",
"DOMALCHOMIKA", "GABINETDYR", "HOLGLOWNY", "UNIVFRONT",
"BIALO", "MIASTO", "LUSTRO", "TELEP_PODWORKO", "TELEP_WARSZTAT", "TELEP_KURNIKI",
"PIWNICA", "ALCHEMY", "RATUSZ", "LABIRYNTH", "PIKLIBIA", "DOMSPIEL", "PRZEPASC",
"MIOTLISKO", "KAPTUREK", "CALINECZKA", "DRATEWKA", "SNIEZKA", "KROLEWNA", "JAGA",
"OLBRZYM", "SZREKSIO", "TROL", "NORASZ", "SEZAM", "PRZEDSEZAMEM", "KROLOWA", "SMOK",
"RATUSZIN", "GULDRYK", "KORYTARZ", "SALA", "UNIVBACK", "UNIVPIWNICA", "MIOTSHOP",
"PRZYSTAN", "GABINETSNEJKA", "BARANMIOT", "WMWEJSCIE", "WMWNETRZE", "PIKMIOT",
"SKLEPALCH", "ARRAS", "POMPA",
)
#: Sceny samodzielne z Application.def (PRZYGODA:SCENES) — intro, outro, minigry,
#: dialogi. Nie mają własnego .DTA. G_SARCADEOBJECTS może wskazywać i na nie:
#: _LOADGAME_ sprawdza G_ARRDATAS^FIND() i albo podmienia .DTA w scenie ARCADE,
#: albo robi PRZYGODA^GOTO() do sceny o tej nazwie (Arcade.cnv:1533).
STANDALONE_SCENES: tuple[str, ...] = (
"ARCADE", "INTRO_1", "INTRO_2", "INTRO_3", "INTRO_4", "MAGIC", "HILLS", "LIMBO",
"DIALOGS", "CHOICE", "START", "LEBIODKA", "C_UCIECZKA", "CREDITS",
"OUTRO_1", "OUTRO_2", "OUTRO_3", "OUTRO_4", "OUTRO_5", "OUTRO_6", "OUTRO_7",
"OFERTA", "PRZYLOT", "PRZYLOT_2", "TELE_1", "TELE_2", "KONTROLA",
"BARANDALF", "CAT", "FRED", "KUBKI", "MIOTLY", "LABIRYNT", "DRAGON", "SHOOTER",
)
#: Wszystko, co może legalnie stać w G_SARCADEOBJECTS.
VALID_LOCATIONS: frozenset[str] = frozenset(SCENES) | frozenset(STANDALONE_SCENES)
#: Postacie z historią dialogową <POSTAĆ>_HIST<slot>.ARR.
CHARACTERS: tuple[str, ...] = (
"BUREKTOR", "GULDRYK", "SNEJK", "WALDIMORS", "SPIELMAUSTER", "CHRUMBURAK",
"BARANDALF", "KROLOWA", "KAMIEN", "SMOK", "GASIENICA",
)
#: Minigry z własnym <MINIGRA><slot>.ARR.
MINIGAMES: tuple[str, ...] = (
"LABIRYNT", "MIOTLY", "CAT", "BARANDALF", "DRAGON", "SHOOTER",
)
#: Sloty widoczne w menu gry. Slot 0 to żywy stan rozgrywki, nie pozycja w menu.
MENU_SLOTS: tuple[int, ...] = (1, 2, 3, 4)
LIVE_SLOT = 0
#: Stała liczba slotów ekwipunku — pętle w Arcade.cnv są zahardkodowane na 0..10.
ITEM_SLOTS = 10
#: Wymagana długość SPELLS<slot>.ARR; krótsza → gra po cichu resetuje.
SPELLS_LEN = 17
#: Wymagana długość GAME<slot>.ARR; krótsza → DEFSETS, czyli utrata pozycji.
GAMESETS_LEN = 6
@dataclass(frozen=True)
class Entry:
"""Jeden plik należący do slotu."""
key: str # stabilny identyfikator, np. "scene:GULDRYK"
kind: str # "dta" | "arr"
template: str # nazwa pliku z {slot} do podstawienia
label: str
def filename(self, slot: int) -> str:
return self.template.format(slot=slot)
def manifest() -> list[Entry]:
"""Pełna lista plików tworzących jeden slot (bez miniaturki m_shot)."""
entries: list[Entry] = [
Entry("game", "arr", "GAME{slot}.ARR", "Zmienne globalne rozgrywki"),
Entry("items", "dta", "ITEMS{slot}.DTA", "Ekwipunek"),
Entry("spells", "arr", "SPELLS{slot}.ARR", "Znane czary"),
Entry("cutscenes", "arr", "CUTSCENES{slot}.ARR", "Obejrzane przerywniki"),
Entry("invest", "arr", "INVEST{slot}.ARR", "Karta śledztwa"),
Entry("questions", "arr", "QUESTIONS{slot}.ARR", "Dostępne pytania"),
Entry("questions_asked", "arr", "QUESTIONS_ASKED{slot}.ARR", "Zadane pytania"),
]
entries += [
Entry(f"scene:{name}", "dta", name + "{slot}.DTA", f"Lokacja {name}")
for name in SCENES
]
entries += [
Entry(f"hist:{name}", "arr", name + "_HIST{slot}.ARR", f"Rozmowy: {name}")
for name in CHARACTERS
]
entries += [
Entry(f"minigame:{name}", "arr", name + "{slot}.ARR", f"Minigra {name}")
for name in MINIGAMES
]
return entries
MANIFEST: tuple[Entry, ...] = tuple(manifest())
BY_KEY: dict[str, Entry] = {e.key: e for e in MANIFEST}
def screenshot_filename(slot: int) -> str:
"""Miniaturka zapisu pokazywana w menu — istnieje tylko dla slotów 1..4."""
return f"m_shot{slot}.img"
#: Pliki stanu leżące w common/, ale POZA manifestem slotu — nie są kopiowane przy
#: zapisie/wczytaniu, więc przeciekają między rozgrywkami. Głównie alchemia.
UNSLOTTED_GLOBS: tuple[str, ...] = (
"SETTINGS.ARR", # globalne ustawienia: [?, głośność 0..800]
"SLOT_*.SAV", # utrwalone mikstury (Alchemy.cnv:BEHSAVESLOTS)
"DODOS_*.SAV", # stan dodo (Alchemy.cnv:BEHSAVEDODOS)
)
+301
View File
@@ -0,0 +1,301 @@
"""CLI: ricsave <komenda>. Ścieżkę do gry bierze z --game albo ze zmiennej RIC_GAME."""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from . import catalog, schema, slot as slotmod
from .slot import Game, SaveError
def _game(args) -> Game:
root = args.game or os.environ.get("RIC_GAME")
if not root:
raise SaveError("podaj --game <katalog gry> albo ustaw RIC_GAME")
return Game(root)
def _print_table(headers: list[str], rows: list[list[str]]) -> None:
widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
widths[i] = max(widths[i], len(cell))
line = " ".join(h.ljust(w) for h, w in zip(headers, widths))
print(line)
print(" ".join("-" * w for w in widths))
for row in rows:
print(" ".join(cell.ljust(w) for cell, w in zip(row, widths)))
def cmd_info(args) -> int:
game = _game(args)
print(f"gra: {game.root}")
print(f"common: {game.common}")
print()
rows = []
for number in (catalog.LIVE_SLOT, *catalog.MENU_SLOTS):
s = game.slot(number)
if not s.exists():
rows.append([str(number), "(pusty)", "", "", ""])
continue
info = s.summary()
rows.append([
str(number) + (" (żywy)" if number == catalog.LIVE_SLOT else ""),
info["location"] or "?",
info["previous"] or "?",
"tak" if info["mole_active"] not in (None, "0") else "nie",
str(len(info["items"])),
])
_print_table(["slot", "lokacja", "poprzednia", "kret", "przedm."], rows)
return 0
def cmd_validate(args) -> int:
game = _game(args)
problems = game.slot(args.slot).validate()
if not problems:
print(f"slot {args.slot}: OK")
return 0
print(f"slot {args.slot}: {len(problems)} problem(ów)")
for p in problems:
print(f" - {p}")
return 1
def cmd_dump(args) -> int:
game = _game(args)
s = game.slot(args.slot)
if args.key:
data = {args.key: s.raw(args.key)}
if catalog.BY_KEY[args.key].kind == "dta":
data[args.key] = data[args.key].rows # type: ignore[union-attr]
else:
data = s.to_dict()
json.dump(data, sys.stdout, ensure_ascii=False, indent=2)
print()
return 0
def cmd_scene(args) -> int:
game = _game(args)
view = game.slot(args.slot).scene(args.name)
headers = [c.name for c in view.columns]
rows = [[str(i)] + row for i, row in enumerate(view.table.rows)]
_print_table(["#"] + headers, rows)
if args.explain:
print()
seen = sorted({int(r[2]) for r in view.table.rows if r[2].lstrip("-").isdigit()})
for value in seen:
print(f" TYPE {value:>3} = {schema.describe_object_type(value)}")
return 0
def cmd_items(args) -> int:
game = _game(args)
view = game.slot(args.slot).items
headers = [c.name for c in view.columns]
_print_table(headers, [list(r) for r in view.table.rows])
return 0
def cmd_gamesets(args) -> int:
game = _game(args)
values = game.slot(args.slot).gamesets
rows = []
for i, col in enumerate(schema.GAMESETS):
value = str(values[i]) if i < len(values) else "(BRAK)"
rows.append([str(i), col.name, value, col.note])
_print_table(["#", "zmienna", "wartość", "znaczenie"], rows)
return 0
def cmd_spells(args) -> int:
game = _game(args)
s = game.slot(args.slot)
values = s.spells
rows = []
for i, value in enumerate(values):
rows.append([str(i), str(value), schema.describe_spell_index(i)])
_print_table(["#", "wartość", "znaczenie"], rows)
exp = s.experience
frame = exp // schema.EXP_PER_HOURGLASS_FRAME + 1
print(
f"\ndoświadczenie: {exp}/{schema.EXP_PER_LEVEL}"
f" (klatka klepsydry {frame}/16)"
f" · awansów: {values[schema.LEVEL_INDEX]}"
f" · miga: {'tak' if values[schema.HOURGLASS_BLINKING_INDEX] else 'nie'}"
)
known = s.known_spells()
print("znane czary:", ", ".join(known) if known else "brak")
return 0
def cmd_exp(args) -> int:
game = _game(args)
s = game.slot(args.slot)
before = s.experience
s.experience = args.value
print(f"doświadczenie: {before}{args.value}")
print("zapisano:", ", ".join(s.flush()))
return 0
def cmd_set(args) -> int:
game = _game(args)
s = game.slot(args.slot)
view = s.scene(args.scene)
index = view.find(args.idname) if not args.idname.isdigit() else int(args.idname)
for assignment in args.assignments:
column, _, value = assignment.partition("=")
if not _:
raise SaveError(f"oczekiwano KOLUMNA=WARTOŚĆ, dostałem {assignment!r}")
before = view.get(index, column)
view.set(index, column, value)
print(f"{args.scene}[{index}].{column}: {before}{value}")
s.touch(f"scene:{view.name}")
if args.dry_run:
print("(--dry-run: nic nie zapisano)")
return 0
print("zapisano:", ", ".join(s.flush()))
return 0
def cmd_diff(args) -> int:
game = _game(args)
a, b = game.slot(args.a).to_dict(), game.slot(args.b).to_dict()
differences = 0
for key in sorted(set(a) | set(b)):
if a.get(key) != b.get(key):
differences += 1
print(f"~ {key} ({catalog.BY_KEY[key].label})")
if args.verbose:
left, right = a.get(key), b.get(key)
if isinstance(left, list) and isinstance(right, list):
for i in range(max(len(left), len(right))):
lv = left[i] if i < len(left) else "(brak)"
rv = right[i] if i < len(right) else "(brak)"
if lv != rv:
print(f" [{i}] {lv!r}{rv!r}")
print(f"\nróżnic: {differences} (slot {args.a} vs {args.b})")
return 0
def cmd_copy(args) -> int:
game = _game(args)
files = slotmod.copy_slot(game, args.src, args.dst)
print(f"skopiowano {len(files)} plików: slot {args.src}{args.dst}")
return 0
def cmd_backup(args) -> int:
game = _game(args)
print("kopia:", slotmod.backup(game, args.dest))
return 0
def cmd_newgame(args) -> int:
game = _game(args)
files = slotmod.new_game(game)
print(f"zresetowano żywy stan (slot 0), {len(files)} plików")
return 0
def cmd_serve(args) -> int:
from webapp.server import serve
serve(_game(args), host=args.host, port=args.port)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="ricsave", description=__doc__)
parser.add_argument("--game", help="katalog gry (albo RIC_GAME)")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("info", help="przegląd slotów")
p.set_defaults(func=cmd_info)
p = sub.add_parser("validate", help="sprawdź niezmienniki slotu")
p.add_argument("slot", type=int)
p.set_defaults(func=cmd_validate)
p = sub.add_parser("dump", help="zrzut slotu do JSON")
p.add_argument("slot", type=int)
p.add_argument("--key", help="pojedynczy plik, np. game / items / scene:GULDRYK")
p.set_defaults(func=cmd_dump)
p = sub.add_parser("scene", help="tabela obiektów lokacji")
p.add_argument("slot", type=int)
p.add_argument("name")
p.add_argument("--explain", action="store_true", help="rozwiń kolumnę TYPE")
p.set_defaults(func=cmd_scene)
p = sub.add_parser("items", help="ekwipunek")
p.add_argument("slot", type=int)
p.set_defaults(func=cmd_items)
p = sub.add_parser("gamesets", help="GAME<slot>.ARR z opisem pól")
p.add_argument("slot", type=int)
p.set_defaults(func=cmd_gamesets)
p = sub.add_parser("spells", help="czary i doświadczenie (klepsydra)")
p.add_argument("slot", type=int)
p.set_defaults(func=cmd_spells)
p = sub.add_parser("exp", help="ustaw doświadczenie (0..149)")
p.add_argument("slot", type=int)
p.add_argument("value", type=int)
p.set_defaults(func=cmd_exp)
p = sub.add_parser("set", help="zmień pola obiektu w lokacji")
p.add_argument("slot", type=int)
p.add_argument("scene")
p.add_argument("idname", help="IDNAME obiektu albo numer wiersza")
p.add_argument("assignments", nargs="+", metavar="KOLUMNA=WARTOŚĆ")
p.add_argument("--dry-run", action="store_true")
p.set_defaults(func=cmd_set)
p = sub.add_parser("diff", help="porównaj dwa sloty")
p.add_argument("a", type=int)
p.add_argument("b", type=int)
p.add_argument("-v", "--verbose", action="store_true")
p.set_defaults(func=cmd_diff)
p = sub.add_parser("copy", help="kopiuj slot (0 = żywy stan)")
p.add_argument("src", type=int)
p.add_argument("dst", type=int)
p.set_defaults(func=cmd_copy)
p = sub.add_parser("backup", help="kopia zapasowa całego common/")
p.add_argument("dest", type=Path)
p.set_defaults(func=cmd_backup)
p = sub.add_parser("newgame", help="zresetuj żywy stan z szablonów _DEF")
p.set_defaults(func=cmd_newgame)
p = sub.add_parser("serve", help="uruchom lokalny edytor w przeglądarce")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8765)
p.set_defaults(func=cmd_serve)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return args.func(args)
except SaveError as exc:
print(f"błąd: {exc}", file=sys.stderr)
return 2
except (KeyError, ValueError) as exc:
print(f"błąd: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+88
View File
@@ -0,0 +1,88 @@
"""Obiekt DATABASE silnika PIK — pliki .DTA.
Wiersze rozdzielone CRLF, kolumny pionową kreską, brak nagłówka. Nazwy i typy kolumn
żyją w definicji STRUCT w pliku .cnv — patrz ricsave.schema.
Puste pole tekstowe zapisywane jest jako literalne NULL.
"""
from __future__ import annotations
from pathlib import Path
ENCODING = "cp1250"
ROW_SEP = "\r\n"
COL_SEP = "|"
NULL = "NULL"
class DtaError(ValueError):
"""Plik .DTA nie trzyma się formatu."""
class Table:
"""Tabela .DTA: lista wierszy, każdy to lista surowych stringów.
Świadomie trzymamy surowe stringi — konwersję na int robi dopiero warstwa schematu,
dzięki czemu zapis jest bajt-w-bajt identyczny gdy nic nie zmieniasz.
"""
def __init__(self, rows: list[list[str]], *, trailing_newline: bool = True) -> None:
self.rows = rows
# Pliki *_def.dta nie mają końcowego CRLF, te zapisane przez grę mają.
self.trailing_newline = trailing_newline
@property
def width(self) -> int:
return len(self.rows[0]) if self.rows else 0
@property
def widths(self) -> set[int]:
return {len(row) for row in self.rows}
def check_width(self, expected: int, label: str = "tabela") -> None:
"""Waliduje szerokość wobec znanego schematu STRUCT."""
found = self.widths
if found - {expected}:
raise DtaError(
f"{label}: oczekiwano {expected} kolumn, znaleziono {sorted(found)}"
)
def __len__(self) -> int:
return len(self.rows)
def __getitem__(self, index: int) -> list[str]:
return self.rows[index]
def __repr__(self) -> str:
return f"<Table {len(self.rows)}×{self.width}>"
def loads(data: bytes) -> Table:
text = data.decode(ENCODING)
trailing = text.endswith(ROW_SEP)
body = text[: -len(ROW_SEP)] if trailing else text
if not body:
return Table([], trailing_newline=trailing)
if "\r\n" not in body and "\n" in body:
raise DtaError("wiersze rozdzielone samym LF — to nie jest plik .DTA z gry")
# Bez walidacji szerokości: część statycznych tabel gry (np. fighters.dta) ma
# wiersze różnej długości. Szerokość sprawdzamy tam, gdzie znamy STRUCT —
# przez Table.check_width().
rows = [line.split(COL_SEP) for line in body.split(ROW_SEP)]
return Table(rows, trailing_newline=trailing)
def dumps(table: Table) -> bytes:
text = ROW_SEP.join(COL_SEP.join(row) for row in table.rows)
if table.trailing_newline and table.rows:
text += ROW_SEP
return text.encode(ENCODING)
def read(path: str | Path) -> Table:
return loads(Path(path).read_bytes())
def write(path: str | Path, table: Table) -> None:
Path(path).write_bytes(dumps(table))
+124
View File
@@ -0,0 +1,124 @@
"""Schematy: nazwy kolumn .DTA i znaczenie indeksów w .ARR.
Kolumny .DTA nie są zapisane w pliku — pochodzą z definicji STRUCT w Arcade.cnv
(linie 1455-1489). Znaczenia pól ARR z MAINMENU.class (DEFSETS, SAVEGAME).
"""
from __future__ import annotations
from dataclasses import dataclass
INT = "INTEGER"
STR = "STRING"
@dataclass(frozen=True)
class Column:
name: str
type: str
note: str = ""
#: SOBJECT — model bazy obiektów sceny (<SCENA><slot>.DTA).
SOBJECT: tuple[Column, ...] = (
Column("NAME", STR, "ścieżka do .ANN/.IMG/.SEK względem katalogu sceny"),
Column("IDNAME", STR, "identyfikator logiczny, używany przez skrypty sceny"),
Column("TYPE", INT, "rola wiersza — patrz OBJECT_TYPES"),
Column("SPARAM0", STR),
Column("SPARAM1", STR),
Column("SPARAM2", STR),
Column("IPARAM0", INT, "dla TYPE==2: 1 = widoczny, 0 = zabrany"),
Column("IPARAM1", INT),
Column("IPARAM2", INT),
)
#: SITEM — model ekwipunku (ITEMS<slot>.DTA).
SITEM: tuple[Column, ...] = (
Column("GUID", INT, "indeks slotu 0..9, równy numerowi wiersza"),
Column("NAME", STR, "identyfikator przedmiotu, NULL gdy pusto"),
Column("PARENT", INT, "indeks wiersza w .DTA lokacji, z której podniesiono (-1 = brak)"),
Column("BASE", STR, "lokacja pochodzenia"),
Column("EMPTY", INT, "MYLĄCA NAZWA: 1 = slot ZAJĘTY, 0 = wolny"),
)
DTA_SCHEMAS: dict[str, tuple[Column, ...]] = {
"scene": SOBJECT,
"items": SITEM,
}
#: Kolumna TYPE w SOBJECT — dekoder z Arcade.cnv:__LOAD_DB__.
OBJECT_TYPES: dict[int, str] = {
101: "tło statyczne (.IMG)",
102: "maska ścieżek chodzenia (.SEK)",
103: "aktywacja przejścia: WPATH^SETACTIVE(IPARAM0, IPARAM1, IPARAM2)",
104: "Reksio; IPARAM0 = prędkość maks.",
105: "punkt startowy, zestaw 0; IDNAME = scena skąd, IPARAM0/1 = X/Y",
106: "punkt docelowy, zestaw 0; IPARAM0/1 = X/Y",
107: "tło animowane; IPARAM0/1 = szerokość/wysokość",
108: "muzyka lokacji; IDNAME = plik .WAV",
140: "Kret",
145: "punkt startowy, zestaw 1",
146: "punkt docelowy, zestaw 1",
}
def describe_object_type(value: int) -> str:
if value in OBJECT_TYPES:
return OBJECT_TYPES[value]
if value < 100:
extra = " — IPARAM0 to flaga widoczności" if value == 2 else ""
return f"obiekt sceny (ANNOBJECT), podtyp {value}{extra}"
return f"nieznany typ {value}"
#: GAME<slot>.ARR — 6 elementów, wszystkie zapisane jako STRING.
GAMESETS: tuple[Column, ...] = (
Column("G_SARCADEOBJECTS", STR, "bieżąca lokacja"),
Column("G_SLASTOBJECTS", STR, "poprzednia lokacja — wyznacza punkt wejścia"),
Column("G_SDIALOGCHARACTER", STR, "rozmówca"),
Column("G_SDIALOGRETURN", STR, "dokąd wrócić po rozmowie"),
Column("G_ITKTPM", INT, "losowe 0..7, ustalane raz przy nowej grze"),
Column("G_IKRETACTIVE", INT, "czy Kret towarzyszy Reksiowi (0/1)"),
)
#: Stan fabryczny GAME0.ARR z DEFSETS (indeks 4 jest losowany 0..7).
GAMESETS_DEFAULT: list[str] = ["INTRO_1", "NULL", "BUREKTOR", "NULL", "0", "0"]
#: SPELLS<slot>.ARR — 17 intów. Stan fabryczny z NEWGAME.
SPELLS_DEFAULT: list[int] = [0] * 12 + [64] + [0] * 4
#: Próg doświadczenia na jeden poziom (MAINMENU.class: LEVELUP, BFITMP57/59/61).
EXP_PER_LEVEL = 150
#: Ile klatek animacji klepsydry — klatka = exp // 10 + 1 (BFITMP55).
EXP_PER_HOURGLASS_FRAME = 10
#: Indeksy 0..11 to flagi „czar znany" (0/1). Sześć czarów w wersji zwykłej (0-5)
#: i obronnej (6-11, sufiks _OBR); czar N ma obronę N+6. Nazwy z uchwytów klikania
#: kul w gabinetach — Guldryk.cnv, Gabinetsnejka.cnv, Wmwnetrze.cnv.
SPELL_NAMES: tuple[str, ...] = (
"Sen", "Zamiana w żabę", "Plaga much", "Kula budyniu", "Koła i wiry", "Ciemność",
"Obrona przed Snem", "Obrona przed Zamianą w żabę", "Obrona przed Plagą much", "Obrona przed Kulą budyniu",
"Obrona przed Kołami i wirami", "Obrona przed Ciemnością",
)
#: Indeksy 12..16 — liczniki i flagi stanu klepsydry.
SPELLS_NOTES: dict[int, str] = {
12: "startuje na 64, SUBAT(12,3) przy każdym awansie — nikt tego nie czyta",
13: f"DOŚWIADCZENIE, 0..{EXP_PER_LEVEL - 1}; po przekroczeniu progu reszta przechodzi dalej",
14: "licznik awansów (ile razy klepsydra się przesypała)",
15: "1 → klepsydra/różdżka widoczna w menu (SHOWEXP, M_WAND)",
16: "1 → klepsydra miga, można nauczyć się czaru",
}
#: Indeks doświadczenia w SPELLS<slot>.ARR.
EXP_INDEX = 13
LEVEL_INDEX = 14
HOURGLASS_VISIBLE_INDEX = 15
HOURGLASS_BLINKING_INDEX = 16
def describe_spell_index(index: int) -> str:
if index < len(SPELL_NAMES):
return f"{SPELL_NAMES[index]} (1 = znany)"
return SPELLS_NOTES.get(index, "nieznane")
+387
View File
@@ -0,0 +1,387 @@
"""Model wysokopoziomowy: instalacja gry, slot, widoki na poszczególne pliki."""
from __future__ import annotations
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from . import arr, catalog, dta, schema
class SaveError(RuntimeError):
pass
class Game:
"""Katalog instalacji gry. Cały stan siedzi w podkatalogu common/."""
def __init__(self, root: str | Path) -> None:
self.root = Path(root).expanduser().resolve()
common = self.root / "common"
if not common.is_dir():
# ktoś mógł wskazać od razu na common/
if self.root.name.lower() == "common" and self.root.is_dir():
common = self.root
self.root = self.root.parent
else:
raise SaveError(f"nie znaleziono katalogu common/ w {self.root}")
self.common = common
if not self._resolve("GAME0.ARR").exists():
raise SaveError(f"{common} nie wygląda na katalog gry — brak GAME0.ARR")
def _resolve(self, filename: str) -> Path:
"""Dopasowanie nazwy bez oglądania się na wielkość liter.
Gra działa na Windows i miesza konwencje: ITEMS0.DTA, items_def.dta, m_shot1.img.
Na macOS/Linux trzeba to rozstrzygnąć ręcznie.
"""
direct = self.common / filename
if direct.exists():
return direct
lowered = filename.lower()
for candidate in self.common.iterdir():
if candidate.name.lower() == lowered:
return candidate
return direct # nie istnieje — zwracamy ścieżkę kanoniczną do zapisu
def path(self, entry: catalog.Entry, slot: int) -> Path:
return self._resolve(entry.filename(slot))
def template_path(self, entry: catalog.Entry) -> Path:
"""Ścieżka do fabrycznego szablonu (_DEF) dla danego wpisu."""
if entry.key in ("questions", "questions_asked", "invest") or entry.key.startswith("hist:"):
return self._resolve("QUESTIONS_DEF.ARR")
stem = entry.filename(0)
base, _, ext = stem.rpartition(".")
return self._resolve(f"{base[:-1]}_DEF.{ext}")
def slot(self, number: int) -> "Slot":
return Slot(self, number)
def occupied_slots(self) -> list[int]:
"""Sloty menu, które mają zapis (miniaturka + GAME<n>.ARR)."""
out = []
for n in catalog.MENU_SLOTS:
if self._resolve(f"GAME{n}.ARR").exists():
out.append(n)
return out
@dataclass
class SceneView:
"""Tabela obiektów jednej lokacji, z nazwami kolumn."""
name: str
table: dta.Table
columns: tuple[schema.Column, ...] = schema.SOBJECT
def rows(self) -> list[dict[str, str]]:
return [
{col.name: value for col, value in zip(self.columns, row)}
for row in self.table.rows
]
def find(self, idname: str) -> int:
for i, row in enumerate(self.table.rows):
if row[1] == idname:
return i
raise KeyError(f"{self.name}: brak obiektu o IDNAME={idname!r}")
def get(self, index: int, column: str) -> str:
return self.table.rows[index][self._col(column)]
def set(self, index: int, column: str, value: str | int) -> None:
self.table.rows[index][self._col(column)] = str(value)
def _col(self, column: str) -> int:
for i, col in enumerate(self.columns):
if col.name == column:
return i
raise KeyError(f"nieznana kolumna {column!r}")
@dataclass
class ItemsView:
table: dta.Table
columns: tuple[schema.Column, ...] = schema.SITEM
def rows(self) -> list[dict[str, str]]:
return [
{col.name: value for col, value in zip(self.columns, row)}
for row in self.table.rows
]
def occupied(self) -> list[dict[str, str]]:
# UWAGA: EMPTY == 1 oznacza slot ZAJĘTY (patrz __LOAD_ITEMS__).
return [row for row in self.rows() if row["EMPTY"] == "1"]
def clear(self, guid: int) -> None:
self.table.rows[guid] = [str(guid), "NULL", "0", "NULL", "0"]
def put(self, guid: int, name: str, base: str = "NULL", parent: int = -1) -> None:
self.table.rows[guid] = [str(guid), name, str(parent), base, "1"]
@dataclass
class Slot:
"""Jeden slot zapisu. Slot 0 to żywy stan rozgrywki, nie pozycja z menu."""
game: Game
number: int
_cache: dict[str, object] = field(default_factory=dict, repr=False)
_dirty: set[str] = field(default_factory=set, repr=False)
# ---- surowy dostęp ----------------------------------------------------
def raw(self, key: str):
"""Zawartość pliku o danym kluczu: list[Value] dla .ARR, dta.Table dla .DTA."""
if key not in self._cache:
entry = catalog.BY_KEY[key]
path = self.game.path(entry, self.number)
if not path.exists():
raise SaveError(f"brak pliku {path.name} (slot {self.number})")
self._cache[key] = arr.read(path) if entry.kind == "arr" else dta.read(path)
return self._cache[key]
def touch(self, key: str) -> None:
"""Oznacz plik jako zmieniony — flush() go zapisze."""
self._dirty.add(key)
def flush(self) -> list[str]:
"""Zapisz na dysk tylko to, co zostało dotknięte. Zwraca listę nazw plików."""
written = []
for key in sorted(self._dirty):
entry = catalog.BY_KEY[key]
path = self.game.path(entry, self.number)
value = self._cache[key]
if entry.kind == "arr":
arr.write(path, value) # type: ignore[arg-type]
else:
dta.write(path, value) # type: ignore[arg-type]
written.append(path.name)
self._dirty.clear()
return written
def exists(self) -> bool:
return self.game.path(catalog.BY_KEY["game"], self.number).exists()
# ---- widoki nazwane ---------------------------------------------------
@property
def gamesets(self) -> list:
"""GAME<slot>.ARR — 6 pól, patrz schema.GAMESETS."""
return self.raw("game") # type: ignore[return-value]
@property
def location(self) -> str:
return str(self.gamesets[0])
@location.setter
def location(self, value: str) -> None:
if value not in catalog.VALID_LOCATIONS:
raise SaveError(
f"{value!r} nie jest ani lokacją z G_ARRDATAS, ani sceną z Application.def"
)
self.gamesets[0] = value
self.touch("game")
@property
def spells(self) -> list:
return self.raw("spells") # type: ignore[return-value]
@property
def experience(self) -> int:
"""Doświadczenie (klepsydra) — SPELLS<slot>.ARR[13], próg awansu 150."""
return int(self.spells[schema.EXP_INDEX])
@experience.setter
def experience(self, value: int) -> None:
if not 0 <= value < schema.EXP_PER_LEVEL:
raise SaveError(
f"doświadczenie musi być w 0..{schema.EXP_PER_LEVEL - 1}; "
"gra sama odejmuje próg przy awansie, więc większa wartość zawiśnie"
)
self.spells[schema.EXP_INDEX] = value
self.touch("spells")
def known_spells(self) -> list[str]:
values = self.spells
return [
name for i, name in enumerate(schema.SPELL_NAMES)
if i < len(values) and values[i]
]
@property
def items(self) -> ItemsView:
return ItemsView(self.raw("items")) # type: ignore[arg-type]
def scene(self, name: str) -> SceneView:
name = name.upper()
if name not in catalog.SCENES:
raise SaveError(f"nieznana lokacja {name!r}")
return SceneView(name, self.raw(f"scene:{name}")) # type: ignore[arg-type]
def history(self, character: str) -> list:
character = character.upper()
if character not in catalog.CHARACTERS:
raise SaveError(f"nieznana postać {character!r}")
return self.raw(f"hist:{character}") # type: ignore[return-value]
# ---- diagnostyka ------------------------------------------------------
def validate(self) -> list[str]:
"""Sprawdza niezmienniki, których złamanie gra połyka po cichu."""
problems: list[str] = []
for entry in catalog.MANIFEST:
path = self.game.path(entry, self.number)
if not path.exists():
problems.append(f"brak pliku {path.name} ({entry.label})")
if problems:
return problems
gs = self.gamesets
if len(gs) < catalog.GAMESETS_LEN:
problems.append(
f"GAME{self.number}.ARR ma {len(gs)} elementów zamiast "
f"{catalog.GAMESETS_LEN} — gra zresetuje pozycję do DEFSETS"
)
elif str(gs[0]) not in catalog.VALID_LOCATIONS:
problems.append(
f"bieżąca lokacja {gs[0]!r} nie występuje ani w G_ARRDATAS, "
"ani w PRZYGODA:SCENES — gra nie będzie miała dokąd wejść"
)
sp = self.spells
if len(sp) < catalog.SPELLS_LEN:
problems.append(
f"SPELLS{self.number}.ARR ma {len(sp)} elementów zamiast "
f"{catalog.SPELLS_LEN} — gra dopcha domyślnymi"
)
items = self.items
if len(items.table) != catalog.ITEM_SLOTS:
problems.append(
f"ITEMS{self.number}.DTA ma {len(items.table)} wierszy zamiast "
f"{catalog.ITEM_SLOTS} — pętle w Arcade.cnv są zahardkodowane"
)
if items.table.widths - {len(schema.SITEM)}:
problems.append(
f"ITEMS{self.number}.DTA: kolumny {sorted(items.table.widths)}, "
f"schemat SITEM ma {len(schema.SITEM)}"
)
for name in catalog.SCENES:
table = self.raw(f"scene:{name}")
extra = table.widths - {len(schema.SOBJECT)} # type: ignore[union-attr]
if extra:
problems.append(
f"{name}{self.number}.DTA: wiersze o {sorted(extra)} kolumnach, "
f"schemat SOBJECT ma {len(schema.SOBJECT)}"
)
return problems
def summary(self) -> dict:
gs = self.gamesets
return {
"slot": self.number,
"location": str(gs[0]) if gs else None,
"previous": str(gs[1]) if len(gs) > 1 else None,
"dialog_with": str(gs[2]) if len(gs) > 2 else None,
"mole_active": str(gs[5]) if len(gs) > 5 else None,
"items": [r["NAME"] for r in self.items.occupied()],
"spells": list(self.spells),
"experience": self.experience,
"level": int(self.spells[schema.LEVEL_INDEX]),
"known_spells": self.known_spells(),
}
def to_dict(self) -> dict:
"""Pełny zrzut slotu — nadaje się do diffowania dwóch stanów."""
out: dict[str, object] = {}
for entry in catalog.MANIFEST:
try:
value = self.raw(entry.key)
except SaveError as exc:
out[entry.key] = {"error": str(exc)}
continue
out[entry.key] = value if entry.kind == "arr" else value.rows # type: ignore[union-attr]
return out
# ---- operacje slotowe (odwzorowanie MAINMENU.class) -----------------------
def copy_slot(game: Game, src: int, dst: int) -> list[str]:
"""Kopiuje wszystkie pliki manifestu src → dst. To jest cała treść SAVEGAME/LOADGAME."""
if src == dst:
raise SaveError("źródło i cel to ten sam slot")
copied = []
missing = []
for entry in catalog.MANIFEST:
source = game.path(entry, src)
if not source.exists():
missing.append(source.name)
continue
target = game.common / entry.filename(dst)
existing = game.path(entry, dst)
if existing.exists():
target = existing
shutil.copyfile(source, target)
copied.append(target.name)
if missing:
raise SaveError(f"slot {src} niekompletny, brakuje: {', '.join(missing[:5])}")
return copied
def save_game(game: Game, dst: int) -> list[str]:
"""Odpowiednik SAVEGAME(n): żywy stan (slot 0) → slot menu.
Miniaturki (m_shot<n>.img) nie ruszamy — gra robi ją ze zrzutu ekranu.
"""
if dst not in catalog.MENU_SLOTS:
raise SaveError(f"slot docelowy musi być z {catalog.MENU_SLOTS}")
return copy_slot(game, catalog.LIVE_SLOT, dst)
def load_game(game: Game, src: int) -> list[str]:
"""Odpowiednik LOADGAME(n): slot menu → żywy stan (slot 0).
Po tym gra wystartowana z „Kontynuuj" wejdzie w ten stan.
"""
if src not in catalog.MENU_SLOTS:
raise SaveError(f"slot źródłowy musi być z {catalog.MENU_SLOTS}")
return copy_slot(game, src, catalog.LIVE_SLOT)
def new_game(game: Game) -> list[str]:
"""Odpowiednik NEWGAME: szablony _DEF → slot 0."""
written = []
for entry in catalog.MANIFEST:
target = game.common / entry.filename(catalog.LIVE_SLOT)
existing = game.path(entry, catalog.LIVE_SLOT)
if existing.exists():
target = existing
if entry.key == "cutscenes":
arr.write(target, [])
elif entry.key == "spells":
arr.write(target, list(schema.SPELLS_DEFAULT))
elif entry.key == "game":
arr.write(target, list(schema.GAMESETS_DEFAULT))
else:
template = game.template_path(entry)
if not template.exists():
raise SaveError(f"brak szablonu {template.name} dla {entry.key}")
shutil.copyfile(template, target)
written.append(target.name)
return written
def backup(game: Game, destination: str | Path) -> Path:
"""Kopia całego common/ — rób to zanim cokolwiek zapiszesz."""
dest = Path(destination).expanduser().resolve()
if dest.exists():
raise SaveError(f"{dest} już istnieje")
shutil.copytree(game.common, dest)
return dest