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

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

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Patryk Gensch
2026-08-03 01:18:08 +02:00
co-authored by Codex
commit edd8ceda9e
17 changed files with 2572 additions and 0 deletions
+267
View File
@@ -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 14) |
**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 |
|---|---|
| 011 | 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 (05) i obronnym (611, 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`
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.