commit edd8ceda9e8fc7b4ce9d8e7900a7496431c0e607 Author: Patryk Gensch <43010113+patryk025@users.noreply.github.com> Date: Mon Aug 3 01:18:08 2026 +0200 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 diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..3424bbd Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3ec7d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,220 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..826a7b2 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# ricsave — edytor zapisów „Reksio i Czarodzieje" + +Narzędzie debugowe do zapisów gry z 2004 r. (silnik PIK, `PIKLIB8.dll`). Gra nie ma +jednego pliku zapisu — stan to **76 plików na slot** w katalogu `common/`, kopiowanych +plik-po-pliku. Ta biblioteka czyta oba formaty, waliduje niezmienniki i daje UI. + +Rozpoznanie formatu: **[docs/format-zapisow.md](docs/format-zapisow.md)** — to jest +właściwa dokumentacja, tu tylko obsługa. + +## Start + +```bash +python3 -m venv .venv && .venv/bin/pip install -e ".[dev]" +export RIC_GAME="$HOME/Reksio/Reksio i Czarodzieje" + +ricsave info # przegląd slotów +ricsave serve # edytor w przeglądarce (127.0.0.1:8765) +``` + +Rdzeń (`ricsave/`) nie ma żadnych zależności — serwer chodzi na `http.server` +ze stdliba, front to trzy pliki statyczne bez frameworka. + +**Zrób kopię zanim cokolwiek zapiszesz:** `ricsave backup ~/kopia-common`. + +## CLI + +```bash +ricsave info # które sloty zajęte, gdzie stoisz +ricsave validate 1 # niezmienniki, które gra połyka po cichu +ricsave gamesets 1 # GAME1.ARR z rozwiniętymi nazwami pól +ricsave scene 1 GULDRYK --explain # tabela obiektów lokacji + legenda TYPE +ricsave items 1 # ekwipunek +ricsave dump 1 --key scene:PIWNICA # JSON +ricsave diff 0 1 -v # co się różni między slotami +ricsave set 0 GULDRYK KULA_SEN IPARAM0=1 # punktowa zmiana pola +ricsave copy 1 0 # slot 1 → żywy stan (to samo co LOADGAME) +ricsave newgame # reset żywego stanu z szablonów _DEF +``` + +`diff` jest tu główną bronią: zapisz przed i po jakiejś akcji w grze, porównaj sloty +i od razu widzisz, które pole za nią odpowiada. + +## Biblioteka + +```python +from ricsave import Game + +game = Game("~/Reksio/Reksio i Czarodzieje") +slot = game.slot(0) # 0 = żywy stan, 1..4 = sloty menu + +slot.summary() +slot.validate() # [] gdy OK + +scene = slot.scene("PIWNICA") +i = scene.find("GARNEK") # po IDNAME +scene.set(i, "IPARAM0", 1) +slot.touch("scene:PIWNICA") +slot.flush() # zapisuje tylko dotknięte pliki +``` + +## Model slotów — jedyna rzecz, którą trzeba zrozumieć + +``` +slot 0 ←→ slot 1..4 + ↑ ↑ + | └─ cztery pozycje z menu gry + └─ ŻYWY STAN bieżącej rozgrywki +``` + +Gra w trakcie grania czyta i pisze **wyłącznie slot 0**. Menu „Zapisz"/„Wczytaj" to +zwykłe kopiowanie plików między 0 a n. Praktyczny wniosek: edycja slotu 3 nie zrobi +nic, dopóki nie wczytasz go w grze (albo nie zrobisz `ricsave copy 3 0`). + +## Czego pilnować przy edycji + +- **Nie dodawaj ani nie usuwaj wierszy `.DTA`.** Skrypty scen adresują je po indeksie + (`DBOBJECTS^SELECT(10)`), więc przesunięcie rozjeżdża logikę. `ricsave validate` + i UI blokują zmianę liczby wierszy. +- **Za krótkie `GAME.ARR` lub `SPELLS.ARR` gra połknie po cichu** i zresetuje + do domyślnych — nie zobaczysz błędu, tylko utratę pozycji. +- **`EMPTY = 1` w `ITEMS` oznacza slot ZAJĘTY**, nie pusty. +- Edytuj przy zamkniętej grze — `SAVEGAME` nadpisuje slot bez pytania. + +## Testy + +```bash +RIC_GAME="$HOME/Reksio/Reksio i Czarodzieje" .venv/bin/python -m pytest +``` + +Kluczowe są testy korpusowe: przejeżdżają po wszystkich plikach `.ARR`/`.DTA` +w `common/` i sprawdzają round-trip **bajt w bajt**. Jeśli parser i writer się +rozjadą, edytor po cichu zepsułby zapis — to ta siatka bezpieczeństwa. + +## Układ + +``` +ricsave/ + arr.py ARRAY (.ARR/.SAV) — binarny, int32 + string + dta.py DATABASE (.DTA) — CRLF + pionowa kreska + catalog.py manifest slotu: 52 lokacje, 11 postaci, 6 minigier + schema.py kolumny STRUCT + znaczenia pól ARR + slot.py Game / Slot / widoki / operacje SAVEGAME-LOADGAME-NEWGAME + cli.py +webapp/ + server.py http.server + JSON API + static/ index.html, app.js, style.css +docs/ + format-zapisow.md +``` + +## Co jeszcze nie jest zdekodowane + +- Znaczenie 17 pól `SPELLS.ARR` poza indeksami 12 i 15 +- `INVEST.ARR` — karta śledztwa, ≥8 elementów (`INVESTIGATION.class:622`) +- `IPARAM0/1/2` dla obiektów scen z `TYPE < 100` innym niż 2 — znaczą co innego + w każdej lokacji, trzeba czytać `.cnv` sceny +- `G_ITKTPM` — losowane 0..7 przy nowej grze, rola nieustalona +- Element 0 w `SETTINGS.ARR` (element 1 to głośność 0..800) diff --git a/docs/format-zapisow.md b/docs/format-zapisow.md new file mode 100644 index 0000000..6821436 --- /dev/null +++ b/docs/format-zapisow.md @@ -0,0 +1,267 @@ +# Reksio i Czarodzieje — model zapisu gry + +Rozpoznanie na podstawie zdekodowanych skryptów (`skrypty_zdekodowane/ric`) i plików gry +(`Reksio i Czarodzieje/`). Wersja gry: `Czarodzieje.exe` z 2004-05-19, PIKLIB8.dll. + +## 1. Gdzie leży stan gry + +Wszystko siedzi w jednym katalogu: `/common/` (w skryptach: `$COMMON\`). +Nie ma żadnej dedykowanej klasy `SAVEGAME` ani jednego pliku zapisu — stan to +**~76 osobnych plików na slot**, kopiowanych plik-po-pliku przez `COPYFILE`. + +Sloty są zakodowane **cyfrą doklejoną do nazwy pliku**: + +| sufiks | znaczenie | +|---|---| +| `_DEF` | fabryczny stan początkowy (nie ruszać — to szablon dla „Nowa gra") | +| `0` | **żywy stan bieżącej rozgrywki** — to czyta i zapisuje gra w trakcie grania | +| `1`–`4` | cztery sloty zapisu z menu | + +Kluczowy wniosek dla edytora: **slot 0 to nie jest „pierwszy zapis", tylko working set.** +Wejście do gry po edycji slotu 1 wymaga wczytania go z menu (albo skopiowania `1 → 0` +samemu, co robi rutyna `LOADGAME`). + +## 2. Dwa formaty plików + +### 2.1 `.ARR` / `.SAV` — obiekt `ARRAY` (binarny) + +Little-endian, bez nagłówka i bez wersji: + +``` +uint32 count +count × { + uint32 type // 1 = INTEGER, 2 = STRING + type==1: int32 value + type==2: uint32 len; byte[len] value // bez terminatora, cp1250 +} +``` + +Zweryfikowane na wszystkich 66 plikach `.arr` w `common/` — round-trip bajt w bajt, +występują wyłącznie typy 1 i 2. Pliki `*.SAV` w katalogach scen (`Dragon/DRAGON_0.SAV`, +`Labirynt/LABIRYNT_0.SAV`) to **ten sam format** — to zwykłe `ARRAY^SAVE()`. + +Uwaga: liczby bywają zapisane jako **string** (typ 2), bo silnik konwertuje w locie — +np. `GAME0.ARR` trzyma `G_ITKTPM` jako `"6"`. Nie zakładaj typu po znaczeniu pola. + +### 2.2 `.DTA` — obiekt `DATABASE` (tekstowy) + +Wiersze rozdzielone `\r\n`, kolumny `|`, brak nagłówka, brak cudzysłowów, pusta wartość +to literalne `NULL`. Kodowanie cp1250 (w praktyce w zapisach jest samo ASCII). + +Drobiazg do round-tripu: pliki `*_def.dta` (autorskie) **nie mają** końcowego `\r\n`, +a te zapisane przez grę (`*0.DTA`) **mają**. Parser musi tolerować oba, writer domyślnie +emituje wariant „gry". + +Schemat kolumn nie jest w pliku — siedzi w definicji `STRUCT` w `.cnv` +(`Arcade.cnv:1455-1489`): + +``` +SOBJECT (sceny): NAME | IDNAME | TYPE + | SPARAM0 | SPARAM1 | SPARAM2 + | IPARAM0 | IPARAM1 | IPARAM2 + +SITEM (ekwipunek): GUID | NAME | PARENT + | BASE | EMPTY +``` + +## 3. Manifest slotu + +Źródło: `MAINMENU.class` → `SAVEGAME` (linia 667) i `LOADGAME` (linia 672). + +| plik | typ | liczba | co trzyma | +|---|---|---|---| +| `.DTA` | DTA/SOBJECT | 52 | stan obiektów każdej lokacji | +| `ITEMS.DTA` | DTA/SITEM | 1 | ekwipunek (10 stałych slotów) | +| `GAME.ARR` | ARR | 1 | globalne zmienne rozgrywki (6 pól) | +| `SPELLS.ARR` | ARR | 1 | 17 intów — znane czary | +| `CUTSCENES.ARR` | ARR | 1 | obejrzane przerywniki | +| `INVEST.ARR` | ARR | 1 | stan karty śledztwa (`INVESTIGATION.class`) | +| `QUESTIONS.ARR` | ARR | 1 | pytania, które Reksio *może* zadać | +| `QUESTIONS_ASKED.ARR` | ARR | 1 | pytania już zadane (globalnie) | +| `_HIST.ARR` | ARR | 11 | historia rozmowy z daną postacią | +| `.ARR` | ARR | 6 | postęp minigier | +| `m_shot.img` | IMG | 1 | miniaturka zapisu (tylko sloty 1–4) | + +**52 lokacje** (kolejność z `G_ARRDATAS`, `Game.cnv:BFITMP125`): +`PODWIECZOREK1/2/3, 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` + +**11 postaci dialogowych** (`M_ARRCHARACTERS`): +`BUREKTOR, GULDRYK, SNEJK, WALDIMORS, SPIELMAUSTER, CHRUMBURAK, BARANDALF, +KROLOWA, KAMIEN, SMOK, GASIENICA` + +**6 minigier** (`M_ARRPIOTR`): `LABIRYNT, MIOTLY, CAT, BARANDALF, DRAGON, SHOOTER` + +### Czego `SAVEGAME` **nie** zapisuje + +- `SETTINGS.ARR` — globalne, wspólne dla wszystkich slotów: `[?, głośność 0..800]` +- `Dane/Game/Przygoda/*/​*_0.SAV` — osobne pliki postępu minigier w katalogach scen +- `SLOT__.SAV`, `DODOS_.SAV` — stan alchemii (`Alchemy.cnv`), + zapisywane bezpośrednio do `$COMMON`, **poza** manifestem slotu → przeżywają + wczytanie innego zapisu + +## 4. `GAME.ARR` — 6 pól i cała reszta + +To jest najważniejszy plik: mówi gdzie stoisz. Wszystkie 6 elementów to **stringi**. + +| idx | zmienna globalna | znaczenie | +|---|---|---| +| 0 | `G_SARCADEOBJECTS` | bieżąca lokacja (nazwa z listy 52) | +| 1 | `G_SLASTOBJECTS` | poprzednia lokacja (skąd przyszedłeś → punkt wejścia) | +| 2 | `G_SDIALOGCHARACTER` | z kim rozmowa | +| 3 | `G_SDIALOGRETURN` | dokąd wrócić po rozmowie | +| 4 | `G_ITKTPM` | losowe 0..7, ustalane raz na nową grę (`DEFSETS`) | +| 5 | `G_IKRETACTIVE` | czy Kret towarzyszy Reksiowi (0/1) | + +Domyślne dla nowej gry (`DEFSETS`): `["INTRO_1", "NULL", "BUREKTOR", "NULL", rand(0,7), "0"]`. +Jeśli plik ma <6 elementów, gra po cichu resetuje do domyślnych — czyli **zły rozmiar += utrata pozycji**, a nie crash. + +## 5. `.DTA` — kolumna `TYPE` (dekoder z `Arcade.cnv:__LOAD_DB__`) + +| TYPE | rola wiersza | +|---|---| +| `< 100` | zwykły obiekt sceny → ładowany jako `ANNOBJECT` z pliku `NAME` | +| `101` | tło statyczne (`CANVASOBSERVER^SETBACKGROUND`), `NAME` = `.IMG` | +| `102` | maska ścieżek chodzenia (`WPATH^LOAD`), `NAME` = `.SEK` | +| `103` | aktywacja przejścia: `WPATH^SETACTIVE(IPARAM0, IPARAM1, IPARAM2)` | +| `104` | Reksio; `NAME` = `.ANN`, `IPARAM0` = prędkość maks. | +| `105` / `145` | punkt startowy (zestaw 0 / 1); `IDNAME` = scena skąd, `IPARAM0/1` = X/Y | +| `106` / `146` | punkt docelowy (zestaw 0 / 1); jw. | +| `107` | tło animowane (`ANNBKG`), `IPARAM0/1` = szerokość/wysokość | +| `108` | muzyka lokacji, `IDNAME` = nazwa `.WAV` | +| `140` | Kret (`ANNKRET`), `NAME` = `.ANN` | + +Dla obiektów z `TYPE == 2` `IPARAM0` jest **flagą stanu**: `1` → obiekt widoczny, +`0` → schowany/zabrany (`BFITMP1045` → `1043`/`1044`). Pozostałe `TYPE < 100` są zawsze +pokazywane, więc tam `IPARAM0/1/2` znaczą co innego per scena — to trzeba dekodować +scena po scenie z odpowiedniego `.cnv` (np. `Piklibia.cnv:BEHSAVEMAP` używa `IPARAM0` +jako numeru klatki, a `IPARAM1` jako stanu rycerza). + +## 6. Ekwipunek — `ITEMS.DTA` + +Zawsze dokładnie **10 wierszy**, indeksowanych `GUID` 0..9. Pusty slot to +`n|NULL|0|NULL|0`. Pola: + +- `GUID` — indeks slotu, równy numerowi wiersza +- `NAME` — identyfikator przedmiotu, np. `BUTY_BUT`; grafika ładowana po nazwie +- `PARENT` — indeks wiersza w `.DTA`, z którego przedmiot został podniesiony + (`-1` = znikąd). Przy odkładaniu gra ustawia temu obiektowi `IPARAM0 = 1` +- `BASE` — lokacja pochodzenia (wartość `G_SARCADEOBJECTS` w chwili podniesienia) +- `EMPTY` — mylące: **`1` = slot zajęty**, `0` = wolny (`__LOAD_ITEMS__` pokazuje + przedmiot gdy `EMPTY == 1`) + +## 7. Dialogi + +Trzy warstwy, dwie statyczne i jedna w zapisie: + +- `common/dialogs.dta` (statyczne, 93 wiersze) — `ID | plik_pytania.WAV | plik_odpowiedzi.WAV | trigger`. + Ostatnia kolumna to ID pytania odblokowywanego po zadaniu tego. +- `common/.arr` (statyczne, małe litery) — pełna pula pytań danej postaci, + np. `GULDRYK.arr = ["50","51","52","53"]` +- `common/_HIST.ARR` (**w zapisie**) — pytania już zadane tej postaci +- `common/QUESTIONS.ARR` (**w zapisie**) — pytania, które Reksio aktualnie *ma* + (`ARRBASEQUESTIONS`); zadanie pytania usuwa je stąd i dokłada do `_HIST` +- `common/QUESTIONS_ASKED.ARR` (**w zapisie**) — globalny log zadanych pytań + +ID pytań to stringi, nie liczby (`"11A"`, `"11B"`) — parser nie może ich rzutować na int. + +## 8. Czary i doświadczenie — `SPELLS.ARR` + +17 intów, jedna tablica na czary **i** na klepsydrę. Stan fabryczny (`NEWGAME`): +same zera poza indeksem 12 = 64. + +| idx | znaczenie | +|---|---| +| 0–11 | flagi „czar znany" (0/1) | +| 12 | start 64, `SUBAT(12,3)` przy każdym awansie — **nikt tego nie czyta** | +| 13 | **doświadczenie**, 0..149 | +| 14 | licznik awansów | +| 15 | 1 → klepsydra/różdżka widoczna w menu | +| 16 | 1 → klepsydra miga, można nauczyć się czaru | + +Czary to sześć kul w dwóch wariantach — zwykłym (0–5) i obronnym (6–11, sufiks +`_OBR`); czar `N` ma obronę `N+6`: + +| idx | czar | idx | obrona | gabinet | +|---|---|---|---|---| +| 0 | `KULA_SEN` | 6 | `KULA_SEN_OBR` | Guldryk | +| 1 | `KULA_ZABA` | 7 | `KULA_ZABA_OBR` | Snejk / Wmwnetrze | +| 2 | `KULA_MUCHY` | 8 | `KULA_MUCHY_OBR` | Snejk | +| 3 | `KULA_BUDYN` | 9 | `KULA_BUDYN_OBR` | Guldryk | +| 4 | `KULA_WIRY` | 10 | `KULA_WIRY_OBR` | Wmwnetrze | +| 5 | `KULA_CIEMNOSC` | 11 | `KULA_CIEMNOSC_OBR` | Wmwnetrze / Snejk | + +Scena `MAGIC` czyta indeksy 0..11 do `ARRACTIVESPELLS` (`Magic.cnv:1329`), a nauka +czaru to `CHANGEAT(, 1)` + natychmiastowy `SAVE("$COMMON\SPELLS0.ARR")` +(`Magic.cnv:BFITMP49`, `Guldryk.cnv:BFITMP763`). + +### Mechanika klepsydry (`MAINMENU.class`) + +``` +ADDEXP(n) :737 G_ARRREXSPELLS^ADDAT(13, n) → odśwież klepsydrę → SAVEEXP +SAVEEXP :712 SAVE("$COMMON\SPELLS0.ARR") ← zapis NATYCHMIAST, przy każdym punkcie +BFITMP57 :1022 exp < 150 ? rysuj klatkę : miganie +BFITMP55 :1012 klatka klepsydry = exp / 10 + 1 (16 klatek na 150 exp) +BFITMP56 :1017 PLAY("MIGA") + CHANGEAT(16, 1) +LEVELUP :743 exp w [0,150) → FALSE; inaczej BFITMP61 +BFITMP61 :1042 exp -= 150 (reszta przechodzi dalej); idx14 += 1; idx12 -= 3; + PLAY("PANEL"); CHANGEAT(16, 0) +``` + +Punkty przyznaje `G_MENU^ADDEXP(n)` rozsiane po scenach — ok. 60 wywołań, wartości +10/20/30/40/50. Rozwiązanie zagadki daje 50 (`Arcade.cnv:_SOLVED_`), rozmowa 30 +(`Dialogs.cnv:BFITMP51`). + +Ważne dla edytora: doświadczenie **jest zapisywane od razu do slotu 0**, przy każdym +punkcie, niezależnie od menu „Zapisz". Nie trzeba go szukać nigdzie indziej. + +### Dwa błędy w oryginale + +1. `BFITMP61` zapisuje przez `G_ARRREXSPELLS^SAVE("SPELLS0.DTA")` — **zła nazwa + i złe rozszerzenie**, bez `$COMMON\`. Sam awans nie trafia do `SPELLS0.ARR` tą + drogą; ratuje to dopiero najbliższy `ADDEXP` → `SAVEEXP`. +2. `SAVEEXP` sprawdza `GETSIZE() < 16`, a w ścieżce awaryjnej (`BFITMP49`) tworzy + tablicę **16-elementową**, podczas gdy reszta kodu oczekuje 17 i sięga po + indeks 16. Przy normalnej grze nie wypływa, bo `NEWGAME` tworzy 17. + +## 9. Przepływ Nowa gra / Zapis / Wczytanie + +``` +NEWGAME (MAINMENU.class:657) + _DEF.DTA → 0.DTA (×52) + ITEMS_DEF.DTA → ITEMS0.DTA + QUESTIONS_DEF.ARR → QUESTIONS0.ARR, QUESTIONS_ASKED0.ARR, + _HIST0.ARR (×11), INVEST0.ARR + _DEF.ARR → 0.ARR (×6) + CUTSCENES0.ARR := [] + SPELLS0.ARR := [0×12, 64, 0×4] + GAME0.ARR := DEFSETS + +SAVEGAME(n) (linia 667) LOADGAME(n) (linia 672) + 0 → n dla wszystkich plików n → 0 dla wszystkich plików + + GAME0 budowane z żywych + GAME wczytane DO żywych globali + globali, potem zapisane (jeśli <6 elementów → DEFSETS) + + SAVE.IMG → m_shot.img +``` + +Scena wchodzi w stan po wczytaniu przez procedurę `_LOADGAME_` wystawioną w każdym +`.cnv` sceny (`Arcade.cnv:1533`) — ta tylko robi `GOTO` do `G_SARCADEOBJECTS`, +cała reszta stanu przyjeżdża z plików przy `__INIT__` → `__LOAD_DB__`. + +## 10. Ryzyka przy edycji + +- **Liczba wierszy `.DTA` musi się zgadzać** — kod scen adresuje wiersze po indeksie + (`DBOBJECTS^SELECT(10)`, `BEHSELECTOBJ^RUN(23)`). Dodanie/usunięcie wiersza przesunie + wszystko i rozjedzie logikę. Edytuj wartości, nie strukturę. +- **`ITEMS` zawsze 10 wierszy** — pętle są zahardkodowane na `0..10`. +- **Za krótkie `GAME`/`SPELLS`** → cichy reset do domyślnych, nie błąd. +- Gra trzyma pliki otwarte tylko na czas operacji, ale i tak edytuj przy zamkniętej grze — + `SAVEGAME` nadpisze slot bez pytania. +- Alchemia (`SLOT_*.SAV`, `DODOS_*.SAV`) jest poza slotami — po wczytaniu starego zapisu + zostaje stan z najnowszej rozgrywki. To bug w grze, nie w edytorze. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0f80f8c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ricsave" +version = "0.1.0" +description = "Edytor zapisów gry Reksio i Czarodzieje (silnik PIK, 2004)" +requires-python = ">=3.11" +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest>=8"] + +[project.scripts] +ricsave = "ricsave.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["ricsave", "webapp"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/ricsave/__init__.py b/ricsave/__init__.py new file mode 100644 index 0000000..01e27e8 --- /dev/null +++ b/ricsave/__init__.py @@ -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" diff --git a/ricsave/arr.py b/ricsave/arr.py new file mode 100644 index 0000000..687ea34 --- /dev/null +++ b/ricsave/arr.py @@ -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(" bytes: + parts = [struct.pack(" list[Value]: + return loads(Path(path).read_bytes()) + + +def write(path: str | Path, values: list[Value]) -> None: + Path(path).write_bytes(dumps(values)) diff --git a/ricsave/catalog.py b/ricsave/catalog.py new file mode 100644 index 0000000..9ac5557 --- /dev/null +++ b/ricsave/catalog.py @@ -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 .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ą _HIST.ARR. +CHARACTERS: tuple[str, ...] = ( + "BUREKTOR", "GULDRYK", "SNEJK", "WALDIMORS", "SPIELMAUSTER", "CHRUMBURAK", + "BARANDALF", "KROLOWA", "KAMIEN", "SMOK", "GASIENICA", +) + +#: Minigry z własnym .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.ARR; krótsza → gra po cichu resetuje. +SPELLS_LEN = 17 + +#: Wymagana długość GAME.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) +) diff --git a/ricsave/cli.py b/ricsave/cli.py new file mode 100644 index 0000000..26df1a7 --- /dev/null +++ b/ricsave/cli.py @@ -0,0 +1,301 @@ +"""CLI: ricsave . Ś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 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.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()) diff --git a/ricsave/dta.py b/ricsave/dta.py new file mode 100644 index 0000000..e73ecd8 --- /dev/null +++ b/ricsave/dta.py @@ -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"" + + +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)) diff --git a/ricsave/schema.py b/ricsave/schema.py new file mode 100644 index 0000000..5b68c58 --- /dev/null +++ b/ricsave/schema.py @@ -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 (.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.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.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.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.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") diff --git a/ricsave/slot.py b/ricsave/slot.py new file mode 100644 index 0000000..02a9308 --- /dev/null +++ b/ricsave/slot.py @@ -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.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.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.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.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 diff --git a/tests/test_formats.py b/tests/test_formats.py new file mode 100644 index 0000000..5cf1c7a --- /dev/null +++ b/tests/test_formats.py @@ -0,0 +1,138 @@ +"""Testy formatów. Najważniejszy to round-trip po prawdziwych plikach gry: +jeśli parser + writer nie odtwarzają bajtów, edytor po cichu psuje zapis. + +Ustaw RIC_GAME żeby uruchomić testy korpusowe: + RIC_GAME="$HOME/Reksio/Reksio i Czarodzieje" pytest +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from ricsave import arr, catalog, dta +from ricsave.slot import Game + +GAME_ROOT = os.environ.get("RIC_GAME") +needs_game = pytest.mark.skipif(not GAME_ROOT, reason="brak RIC_GAME") + + +# ---- format ARR ---------------------------------------------------------- + +def test_arr_empty(): + assert arr.dumps([]) == b"\x00\x00\x00\x00" + assert arr.loads(b"\x00\x00\x00\x00") == [] + + +def test_arr_mixed_roundtrip(): + values = [0, -1, 2**31 - 1, "GULDRYK", "", "11A"] + assert arr.loads(arr.dumps(values)) == values + + +def test_arr_rejects_trailing_garbage(): + with pytest.raises(arr.ArrError, match="śmieci"): + arr.loads(b"\x00\x00\x00\x00\xff") + + +def test_arr_rejects_unknown_type(): + with pytest.raises(arr.ArrError, match="nieznany typ"): + arr.loads(b"\x01\x00\x00\x00" + b"\x09\x00\x00\x00") + + +def test_arr_rejects_bool(): + # bool jest podklasą int — bez jawnej kontroli zapisałby się jako 0/1 po cichu + with pytest.raises(arr.ArrError, match="bool"): + arr.dumps([True]) + + +# ---- format DTA ---------------------------------------------------------- + +def test_dta_roundtrip_with_trailing_newline(): + raw = b"0|NULL|0|NULL|0\r\n1|BUTY|3|CAT|1\r\n" + table = dta.loads(raw) + assert len(table) == 2 + assert table[1] == ["1", "BUTY", "3", "CAT", "1"] + assert dta.dumps(table) == raw + + +def test_dta_roundtrip_without_trailing_newline(): + """Pliki *_def.dta nie mają końcowego CRLF — writer musi to uszanować.""" + raw = b"A|B\r\nC|D" + table = dta.loads(raw) + assert table.trailing_newline is False + assert dta.dumps(table) == raw + + +def test_dta_tolerates_ragged_rows_but_check_width_catches_them(): + """fighters.dta ma wiersze 4- i 5-kolumnowe, więc parser nie może być surowy. + Kontrola szerokości należy do warstwy schematu.""" + table = dta.loads(b"A|B|C\r\nD|E\r\n") + assert table.widths == {3, 2} + with pytest.raises(dta.DtaError, match="oczekiwano 3"): + table.check_width(3, "test") + + +def test_dta_rejects_unix_newlines(): + with pytest.raises(dta.DtaError, match="LF"): + dta.loads(b"A|B\nC|D\n") + + +# ---- korpus: prawdziwe pliki gry ---------------------------------------- + +@needs_game +def test_all_arr_files_roundtrip_byte_for_byte(): + common = Game(GAME_ROOT).common + checked = 0 + for path in common.iterdir(): + if path.suffix.lower() not in (".arr", ".sav"): + continue + raw = path.read_bytes() + assert arr.dumps(arr.loads(raw)) == raw, f"round-trip nie wyszedł: {path.name}" + checked += 1 + assert checked > 50, f"sprawdzono tylko {checked} plików .ARR — coś nie tak ze ścieżką" + + +@needs_game +def test_all_dta_files_roundtrip_byte_for_byte(): + common = Game(GAME_ROOT).common + checked = 0 + for path in common.iterdir(): + if path.suffix.lower() != ".dta": + continue + raw = path.read_bytes() + assert dta.dumps(dta.loads(raw)) == raw, f"round-trip nie wyszedł: {path.name}" + checked += 1 + assert checked > 100, f"sprawdzono tylko {checked} plików .DTA" + + +@needs_game +def test_manifest_matches_files_on_disk(): + game = Game(GAME_ROOT) + missing = [ + e.filename(0) for e in catalog.MANIFEST + if not game.path(e, 0).exists() + ] + assert not missing, f"manifest wymienia pliki, których nie ma: {missing}" + + +@needs_game +def test_scene_tables_have_sobject_width(): + slot = Game(GAME_ROOT).slot(0) + for name in catalog.SCENES: + table = slot.raw(f"scene:{name}") + assert table.width == 9, f"{name}: {table.width} kolumn zamiast 9 (SOBJECT)" + + +@needs_game +def test_items_table_shape(): + items = Game(GAME_ROOT).slot(0).items + assert len(items.table) == catalog.ITEM_SLOTS + assert items.table.width == 5 + + +@needs_game +def test_live_slot_validates(): + problems = Game(GAME_ROOT).slot(0).validate() + assert problems == [], problems diff --git a/webapp/server.py b/webapp/server.py new file mode 100644 index 0000000..4ce428c --- /dev/null +++ b/webapp/server.py @@ -0,0 +1,209 @@ +"""Lokalny edytor w przeglądarce. Stdlib http.server — zero zależności. + + ricsave --game "~/Reksio/Reksio i Czarodzieje" serve + +Serwer celowo słucha tylko na 127.0.0.1: daje pełny zapis do katalogu gry. +""" + +from __future__ import annotations + +import json +import webbrowser +from functools import partial +from http.server import HTTPServer, SimpleHTTPRequestHandler +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +from ricsave import catalog, schema, slot as slotmod +from ricsave.slot import Game, SaveError + +STATIC = Path(__file__).parent / "static" + + +class Handler(SimpleHTTPRequestHandler): + game: Game + + def __init__(self, *args, game: Game, **kwargs): + self.game = game + super().__init__(*args, directory=str(STATIC), **kwargs) + + def log_message(self, fmt, *args): # cisza w konsoli + pass + + # ---- API ---- + + def do_GET(self): + url = urlparse(self.path) + if not url.path.startswith("/api/"): + return super().do_GET() + query = parse_qs(url.query) + try: + payload = self._get(url.path, query) + except SaveError as exc: + return self._json({"error": str(exc)}, status=400) + except KeyError as exc: + return self._json({"error": f"nie znaleziono: {exc}"}, status=404) + return self._json(payload) + + def do_POST(self): + url = urlparse(self.path) + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length) or b"{}") + try: + payload = self._post(url.path, body) + except SaveError as exc: + return self._json({"error": str(exc)}, status=400) + except (KeyError, ValueError) as exc: + return self._json({"error": str(exc)}, status=400) + return self._json(payload) + + def _slot_card(self, number: int) -> dict: + s = self.game.slot(number) + card = {"number": number, "live": number == catalog.LIVE_SLOT, "exists": s.exists()} + if card["exists"]: + try: + card.update(s.summary()) + except SaveError as exc: + # slot niekompletny — pokazujemy go, ale nie udajemy że da się otworzyć + card["exists"] = False + card["error"] = str(exc) + return card + + def _get(self, path: str, query: dict) -> dict: + if path == "/api/slots": + return { + "root": str(self.game.root), + "slots": [ + self._slot_card(n) for n in (catalog.LIVE_SLOT, *catalog.MENU_SLOTS) + ], + "scenes": list(catalog.SCENES), + "characters": list(catalog.CHARACTERS), + "minigames": list(catalog.MINIGAMES), + } + + slot = self.game.slot(int(query["slot"][0])) + + if path == "/api/overview": + spells = slot.spells + exp = slot.experience + return { + "summary": slot.summary(), + "problems": slot.validate(), + # Osobny blok zamiast grzebania w summary — front nie zna indeksów + # SPELLS i nie musi liczyć klatki klepsydry sam. + "experience": { + "value": exp, + "threshold": schema.EXP_PER_LEVEL, + "frame": exp // schema.EXP_PER_HOURGLASS_FRAME + 1, + "frames": schema.EXP_PER_LEVEL // schema.EXP_PER_HOURGLASS_FRAME + 1, + "level": int(spells[schema.LEVEL_INDEX]), + "blinking": bool(spells[schema.HOURGLASS_BLINKING_INDEX]), + "known": slot.known_spells(), + }, + "gamesets": [ + { + "index": i, + "name": col.name, + "note": col.note, + "value": slot.gamesets[i] if i < len(slot.gamesets) else None, + } + for i, col in enumerate(schema.GAMESETS) + ], + "spells": [ + {"index": i, "value": v, "note": schema.describe_spell_index(i)} + for i, v in enumerate(slot.spells) + ], + "items": slot.items.rows(), + } + + if path == "/api/scene": + view = slot.scene(query["name"][0]) + return { + "name": view.name, + "columns": [ + {"name": c.name, "type": c.type, "note": c.note} for c in view.columns + ], + "rows": view.table.rows, + "types": { + row[2]: schema.describe_object_type(int(row[2])) + for row in view.table.rows + if row[2].lstrip("-").isdigit() + }, + } + + if path == "/api/raw": + key = query["key"][0] + entry = catalog.BY_KEY[key] + value = slot.raw(key) + return { + "key": key, + "kind": entry.kind, + "label": entry.label, + "value": value if entry.kind == "arr" else value.rows, + } + + if path == "/api/manifest": + return { + "entries": [ + { + "key": e.key, + "kind": e.kind, + "label": e.label, + "file": e.filename(slot.number), + } + for e in catalog.MANIFEST + ] + } + + raise KeyError(path) + + def _post(self, path: str, body: dict) -> dict: + if path == "/api/write": + slot = self.game.slot(int(body["slot"])) + key = body["key"] + entry = catalog.BY_KEY[key] + current = slot.raw(key) + if entry.kind == "arr": + slot._cache[key] = body["value"] + else: + rows = [[str(cell) for cell in row] for row in body["value"]] + if len(rows) != len(current.rows): + raise SaveError( + f"liczba wierszy musi zostać {len(current.rows)} — " + "skrypty scen adresują wiersze po indeksie" + ) + current.rows = rows + slot.touch(key) + return {"written": slot.flush()} + + if path == "/api/copy": + files = slotmod.copy_slot(self.game, int(body["src"]), int(body["dst"])) + return {"copied": len(files)} + + if path == "/api/backup": + return {"path": str(slotmod.backup(self.game, body["dest"]))} + + raise KeyError(path) + + def _json(self, payload: dict, status: int = 200) -> None: + raw = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + +def serve(game: Game, host: str = "127.0.0.1", port: int = 8765, open_browser: bool = True) -> None: + handler = partial(Handler, game=game) + server = HTTPServer((host, port), handler) + url = f"http://{host}:{port}/" + print(f"edytor: {url}") + print(f"gra: {game.common}") + print("Ctrl-C kończy.") + if open_browser: + webbrowser.open(url) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nkoniec") diff --git a/webapp/static/app.js b/webapp/static/app.js new file mode 100644 index 0000000..d93ba58 --- /dev/null +++ b/webapp/static/app.js @@ -0,0 +1,248 @@ +"use strict"; + +const state = { + slot: null, + key: null, // aktualnie otwarty plik z manifestu, null = przegląd + manifest: [], + edits: new Map(), // "wiersz:kolumna" -> nowa wartość +}; + +const $ = (sel) => document.querySelector(sel); +const el = (tag, props = {}, kids = []) => { + const node = Object.assign(document.createElement(tag), props); + for (const kid of [].concat(kids)) { + node.append(kid instanceof Node ? kid : document.createTextNode(kid)); + } + return node; +}; + +async function api(path, body) { + const res = await fetch(path, body ? { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } : undefined); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || res.statusText); + return data; +} + +let toastTimer; +function toast(message, isError = false) { + const node = $("#toast"); + node.textContent = message; + node.classList.toggle("err", isError); + node.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { node.hidden = true; }, 3500); +} + +// ---- sloty ---------------------------------------------------------------- + +async function boot() { + const data = await api("/api/slots"); + $("#root-path").textContent = data.root; + const nav = $("#slots"); + nav.replaceChildren(); + for (const slot of data.slots) { + const label = slot.live ? "slot 0 · żywy stan" : `slot ${slot.number}`; + const sub = slot.exists ? (slot.location || "?") : "pusty"; + const button = el("button", { type: "button", disabled: !slot.exists }, [ + label, el("span", { className: "sub" }, sub), + ]); + button.dataset.slot = String(slot.number); + button.setAttribute("aria-pressed", "false"); + button.onclick = () => selectSlot(slot.number); + nav.append(button); + } + const first = data.slots.find((s) => s.exists); + if (first) selectSlot(first.number); +} + +async function selectSlot(number) { + state.slot = number; + state.key = null; + state.edits.clear(); + document.querySelectorAll("#slots button").forEach((b) => { + b.setAttribute("aria-pressed", String(Number(b.dataset.slot) === number)); + }); + const { entries } = await api(`/api/manifest?slot=${number}`); + state.manifest = entries; + renderManifest(); + renderOverview(); +} + +function renderManifest() { + const needle = $("#filter").value.trim().toLowerCase(); + const list = $("#manifest"); + list.replaceChildren(); + + const overview = el("li", {}, [el("button", { type: "button" }, "Przegląd slotu")]); + overview.firstChild.setAttribute("aria-current", String(state.key === null)); + overview.firstChild.onclick = () => { state.key = null; renderManifest(); renderOverview(); }; + list.append(overview); + + for (const entry of state.manifest) { + const haystack = `${entry.key} ${entry.label} ${entry.file}`.toLowerCase(); + if (needle && !haystack.includes(needle)) continue; + const button = el("button", { type: "button" }, [ + entry.label, el("span", { className: "file mono" }, entry.file), + ]); + button.setAttribute("aria-current", String(state.key === entry.key)); + button.onclick = () => openEntry(entry.key); + list.append(el("li", {}, button)); + } +} + +// ---- przegląd ------------------------------------------------------------- + +async function renderOverview() { + const panel = $("#panel"); + panel.replaceChildren(el("p", { className: "muted" }, "Wczytuję…")); + const data = await api(`/api/overview?slot=${state.slot}`); + panel.replaceChildren(); + + panel.append(el("h2", {}, "Stan")); + if (data.problems.length) { + panel.append(el("ul", { className: "problems" }, + data.problems.map((p) => el("li", {}, p)))); + } else { + panel.append(el("p", { className: "ok" }, "Niezmienniki OK.")); + } + + panel.append(el("h2", {}, "Zmienne globalne — GAME.ARR")); + const grid = el("div", { className: "grid" }); + for (const field of data.gamesets) { + grid.append(el("div", { className: "card" }, [ + el("div", { className: "k mono" }, field.name), + el("div", { className: "v" }, String(field.value ?? "(brak)")), + el("div", { className: "k" }, field.note || ""), + ])); + } + panel.append(grid); + + panel.append(el("h2", {}, "Ekwipunek")); + panel.append(renderReadonlyTable( + ["GUID", "NAME", "PARENT", "BASE", "EMPTY"], + data.items.map((r) => [r.GUID, r.NAME, r.PARENT, r.BASE, r.EMPTY]), + )); + panel.append(el("p", { className: "muted" }, + "EMPTY = 1 oznacza slot zajęty (tak, odwrotnie niż sugeruje nazwa).")); + + panel.append(el("h2", {}, "Czary i doświadczenie — SPELLS.ARR")); + const exp = data.experience; + if (exp) { + panel.append(el("p", { className: "muted" }, + `Klepsydra: ${exp.value}/${exp.threshold} doświadczenia ` + + `(klatka ${exp.frame}/${exp.frames}), awansów: ${exp.level}` + + (exp.blinking ? " — miga, można nauczyć się czaru." : "."))); + panel.append(el("p", { className: "muted" }, + "Znane czary: " + (exp.known.length ? exp.known.join(", ") : "brak"))); + } else { + panel.append(el("p", { className: "problems" }, staleServerNote())); + } + panel.append(renderReadonlyTable( + ["#", "wartość", "znaczenie"], + data.spells.map((s) => [String(s.index), String(s.value), s.note || "—"]), + )); +} + +function staleServerNote() { + // Strona statyczna odświeża się sama, ale server.py ładuje się przy starcie — + // rozjazd wersji objawia się brakiem pól w odpowiedzi. Powiedz to wprost, + // zamiast renderować undefined/NaN. + return "Serwer odpowiada bez bloku doświadczenia — najpewniej działa na starszym " + + "kodzie niż ta strona. Zrestartuj `ricsave serve`."; +} + +function renderReadonlyTable(headers, rows) { + const table = el("table", {}, [ + el("thead", {}, el("tr", {}, headers.map((h) => el("th", {}, h)))), + el("tbody", {}, rows.map((row) => el("tr", {}, row.map((c) => el("td", {}, String(c)))))), + ]); + return el("div", { className: "scroll" }, table); +} + +// ---- edycja pliku --------------------------------------------------------- + +async function openEntry(key) { + state.key = key; + state.edits.clear(); + renderManifest(); + + const panel = $("#panel"); + panel.replaceChildren(el("p", { className: "muted" }, "Wczytuję…")); + const data = await api(`/api/raw?slot=${state.slot}&key=${encodeURIComponent(key)}`); + panel.replaceChildren(); + panel.append(el("h2", {}, `${data.label} — ${key}`)); + + const isDta = data.kind === "dta"; + const columns = isDta + ? (key === "items" + ? ["GUID", "NAME", "PARENT", "BASE", "EMPTY"] + : ["NAME", "IDNAME", "TYPE", "SPARAM0", "SPARAM1", "SPARAM2", "IPARAM0", "IPARAM1", "IPARAM2"]) + : ["wartość"]; + const rows = isDta ? data.value : data.value.map((v) => [v]); + + const save = el("button", { type: "button", disabled: true }, "Zapisz na dysk"); + const revert = el("button", { type: "button", className: "ghost", disabled: true }, "Cofnij zmiany"); + const counter = el("span", { className: "muted" }, `${rows.length} wierszy`); + panel.append(el("div", { className: "actions" }, [save, revert, counter])); + + const body = el("tbody", {}); + rows.forEach((row, r) => { + const cells = [el("td", { className: "idx mono" }, String(r))]; + row.forEach((value, c) => { + const input = el("input", { value: String(value), className: "mono" }); + input.size = Math.max(4, Math.min(40, String(value).length + 2)); + input.oninput = () => { + const id = `${r}:${c}`; + if (input.value === String(value)) state.edits.delete(id); + else state.edits.set(id, input.value); + input.classList.toggle("changed", state.edits.has(id)); + save.disabled = revert.disabled = state.edits.size === 0; + counter.textContent = state.edits.size + ? `${rows.length} wierszy · ${state.edits.size} zmian` + : `${rows.length} wierszy`; + }; + cells.push(el("td", {}, input)); + }); + body.append(el("tr", {}, cells)); + }); + + panel.append(el("div", { className: "scroll" }, el("table", {}, [ + el("thead", {}, el("tr", {}, [el("th", {}, "#"), ...columns.map((h) => el("th", {}, h))])), + body, + ]))); + + if (isDta && key.startsWith("scene:")) { + panel.append(el("p", { className: "muted" }, + "Nie dodawaj ani nie usuwaj wierszy — skrypty scen adresują je po indeksie. " + + "Dla obiektów z TYPE = 2 kolumna IPARAM0 to flaga widoczności (1 = jest, 0 = zabrany).")); + } + + revert.onclick = () => openEntry(key); + save.onclick = async () => { + save.disabled = true; + const next = rows.map((row, r) => row.map((value, c) => { + const edited = state.edits.get(`${r}:${c}`); + if (edited === undefined) return value; + return (!isDta && typeof value === "number") ? Number(edited) : edited; + })); + try { + const res = await api("/api/write", { + slot: state.slot, + key, + value: isDta ? next : next.map((row) => row[0]), + }); + toast(`zapisano: ${res.written.join(", ")}`); + openEntry(key); + } catch (err) { + toast(err.message, true); + save.disabled = false; + } + }; +} + +$("#filter").addEventListener("input", renderManifest); +boot().catch((err) => toast(err.message, true)); diff --git a/webapp/static/index.html b/webapp/static/index.html new file mode 100644 index 0000000..1991fd6 --- /dev/null +++ b/webapp/static/index.html @@ -0,0 +1,31 @@ + + + + + + Reksio i Czarodzieje — edytor zapisów + + + +
+

Reksio i Czarodzieje — edytor zapisów

+
+
+ + + +
+ + +
+

Wybierz slot.

+
+
+ + + + + diff --git a/webapp/static/style.css b/webapp/static/style.css new file mode 100644 index 0000000..a8298b6 --- /dev/null +++ b/webapp/static/style.css @@ -0,0 +1,161 @@ +:root { + --bg: #16130f; + --panel: #201b16; + --line: #3a3129; + --ink: #ece4d8; + --muted: #a2957f; + --accent: #d8a13a; + --warn: #d4674a; + --ok: #7fa650; + color-scheme: dark; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; +} + +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.muted { color: var(--muted); font-weight: 400; } + +header { + padding: 14px 20px; + border-bottom: 1px solid var(--line); + display: flex; + align-items: baseline; + gap: 16px; + flex-wrap: wrap; +} +header h1 { margin: 0; font-size: 17px; font-weight: 600; } + +nav { + display: flex; + gap: 8px; + padding: 12px 20px; + border-bottom: 1px solid var(--line); + flex-wrap: wrap; +} +nav button { + background: var(--panel); + border: 1px solid var(--line); + color: var(--ink); + border-radius: 6px; + padding: 8px 14px; + cursor: pointer; + font: inherit; + text-align: left; +} +nav button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } +nav button .sub { display: block; font-size: 12px; color: var(--muted); } +nav button[disabled] { opacity: .45; cursor: default; } + +main { + display: grid; + grid-template-columns: minmax(200px, 280px) 1fr; + gap: 0; + align-items: start; +} +@media (max-width: 720px) { main { grid-template-columns: 1fr; } } + +aside { + border-right: 1px solid var(--line); + padding: 12px; + position: sticky; + top: 0; + max-height: 100vh; + overflow-y: auto; +} +aside input { + width: 100%; + padding: 7px 9px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 6px; + color: var(--ink); + font: inherit; + margin-bottom: 10px; +} +aside ul { list-style: none; margin: 0; padding: 0; } +aside li button { + width: 100%; + text-align: left; + background: none; + border: 0; + color: var(--ink); + padding: 5px 8px; + border-radius: 5px; + cursor: pointer; + font: inherit; +} +aside li button:hover { background: var(--panel); } +aside li button[aria-current="true"] { background: var(--panel); color: var(--accent); } +aside li .file { display: block; font-size: 11px; color: var(--muted); } + +section#panel { padding: 18px 20px 60px; min-width: 0; } +h2 { font-size: 15px; margin: 22px 0 10px; } +h2:first-child { margin-top: 0; } + +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px 14px; +} +.card .k { font-size: 12px; color: var(--muted); } +.card .v { font-size: 15px; margin-top: 2px; word-break: break-word; } + +.scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 8px; } +table { border-collapse: collapse; width: 100%; font-size: 13px; } +th, td { padding: 5px 9px; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; } +th { background: var(--panel); position: sticky; top: 0; font-weight: 600; } +tbody tr:hover { background: #1c1814; } +td input { + background: transparent; + border: 1px solid transparent; + color: inherit; + font: inherit; + padding: 2px 4px; + border-radius: 4px; + /* szerokość sterowana atrybutem size — inaczej długie ścieżki w NAME są ucinane */ + min-width: 4ch; +} +td input:hover { border-color: var(--line); } +td input:focus { border-color: var(--accent); outline: none; background: var(--bg); } +td input.changed { color: var(--accent); border-color: var(--accent); } +td.idx { color: var(--muted); } + +.actions { display: flex; gap: 8px; margin: 14px 0; flex-wrap: wrap; align-items: center; } +.actions button { + background: var(--accent); + border: 0; + color: #241c0c; + border-radius: 6px; + padding: 8px 16px; + font: inherit; + font-weight: 600; + cursor: pointer; +} +.actions button.ghost { background: none; border: 1px solid var(--line); color: var(--ink); font-weight: 400; } +.actions button[disabled] { opacity: .4; cursor: default; } + +.problems { border-left: 3px solid var(--warn); padding: 8px 12px; background: var(--panel); border-radius: 0 6px 6px 0; } +.problems li { margin: 2px 0; } +.ok { color: var(--ok); } + +#toast { + position: fixed; + bottom: 18px; + left: 50%; + transform: translateX(-50%); + background: var(--panel); + border: 1px solid var(--accent); + border-radius: 8px; + padding: 10px 18px; + box-shadow: 0 6px 24px #0008; +} +#toast.err { border-color: var(--warn); }