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:
+220
@@ -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
|
||||||
@@ -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<n>.ARR` lub `SPELLS<n>.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<n>.ARR` poza indeksami 12 i 15
|
||||||
|
- `INVEST<n>.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)
|
||||||
@@ -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: `<gra>/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<STRING> | IDNAME<STRING> | TYPE<INTEGER>
|
||||||
|
| SPARAM0<STRING> | SPARAM1<STRING> | SPARAM2<STRING>
|
||||||
|
| IPARAM0<INTEGER> | IPARAM1<INTEGER> | IPARAM2<INTEGER>
|
||||||
|
|
||||||
|
SITEM (ekwipunek): GUID<INTEGER> | NAME<STRING> | PARENT<INTEGER>
|
||||||
|
| BASE<STRING> | EMPTY<INTEGER>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Manifest slotu
|
||||||
|
|
||||||
|
Źródło: `MAINMENU.class` → `SAVEGAME` (linia 667) i `LOADGAME` (linia 672).
|
||||||
|
|
||||||
|
| plik | typ | liczba | co trzyma |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `<SCENA><s>.DTA` | DTA/SOBJECT | 52 | stan obiektów każdej lokacji |
|
||||||
|
| `ITEMS<s>.DTA` | DTA/SITEM | 1 | ekwipunek (10 stałych slotów) |
|
||||||
|
| `GAME<s>.ARR` | ARR | 1 | globalne zmienne rozgrywki (6 pól) |
|
||||||
|
| `SPELLS<s>.ARR` | ARR | 1 | 17 intów — znane czary |
|
||||||
|
| `CUTSCENES<s>.ARR` | ARR | 1 | obejrzane przerywniki |
|
||||||
|
| `INVEST<s>.ARR` | ARR | 1 | stan karty śledztwa (`INVESTIGATION.class`) |
|
||||||
|
| `QUESTIONS<s>.ARR` | ARR | 1 | pytania, które Reksio *może* zadać |
|
||||||
|
| `QUESTIONS_ASKED<s>.ARR` | ARR | 1 | pytania już zadane (globalnie) |
|
||||||
|
| `<POSTAĆ>_HIST<s>.ARR` | ARR | 11 | historia rozmowy z daną postacią |
|
||||||
|
| `<MINIGRA><s>.ARR` | ARR | 6 | postęp minigier |
|
||||||
|
| `m_shot<s>.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_<prefix>_<n>.SAV`, `DODOS_<prefix>.SAV` — stan alchemii (`Alchemy.cnv`),
|
||||||
|
zapisywane bezpośrednio do `$COMMON`, **poza** manifestem slotu → przeżywają
|
||||||
|
wczytanie innego zapisu
|
||||||
|
|
||||||
|
## 4. `GAME<s>.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. `<SCENA><s>.DTA` — kolumna `TYPE` (dekoder z `Arcade.cnv:__LOAD_DB__`)
|
||||||
|
|
||||||
|
| TYPE | rola wiersza |
|
||||||
|
|---|---|
|
||||||
|
| `< 100` | zwykły obiekt sceny → ładowany jako `ANNOBJECT<n>` 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<s>.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 `<SCENA>.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/<POSTAĆ>.arr` (statyczne, małe litery) — pełna pula pytań danej postaci,
|
||||||
|
np. `GULDRYK.arr = ["50","51","52","53"]`
|
||||||
|
- `common/<POSTAĆ>_HIST<s>.ARR` (**w zapisie**) — pytania już zadane tej postaci
|
||||||
|
- `common/QUESTIONS<s>.ARR` (**w zapisie**) — pytania, które Reksio aktualnie *ma*
|
||||||
|
(`ARRBASEQUESTIONS`); zadanie pytania usuwa je stąd i dokłada do `_HIST`
|
||||||
|
- `common/QUESTIONS_ASKED<s>.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<s>.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(<idx>, 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)
|
||||||
|
<SCENA>_DEF.DTA → <SCENA>0.DTA (×52)
|
||||||
|
ITEMS_DEF.DTA → ITEMS0.DTA
|
||||||
|
QUESTIONS_DEF.ARR → QUESTIONS0.ARR, QUESTIONS_ASKED0.ARR,
|
||||||
|
<POSTAĆ>_HIST0.ARR (×11), INVEST0.ARR
|
||||||
|
<MINIGRA>_DEF.ARR → <MINIGRA>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<n> wczytane DO żywych globali
|
||||||
|
globali, potem zapisane (jeśli <6 elementów → DEFSETS)
|
||||||
|
+ SAVE.IMG → m_shot<n>.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.
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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))
|
||||||
@@ -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
@@ -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())
|
||||||
@@ -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))
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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));
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Reksio i Czarodzieje — edytor zapisów</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Reksio i Czarodzieje <span class="muted">— edytor zapisów</span></h1>
|
||||||
|
<div id="root-path" class="muted mono"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav id="slots" aria-label="Sloty"></nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<aside>
|
||||||
|
<input id="filter" type="search" placeholder="filtruj pliki slotu…" autocomplete="off">
|
||||||
|
<ul id="manifest"></ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section id="panel">
|
||||||
|
<p class="muted">Wybierz slot.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast" hidden></div>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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); }
|
||||||
Reference in New Issue
Block a user