testlevel chapters 2-31 overwrite game memory (coin counter and flag rows end after world 8: progress, language, options): level projects, play-tests and the arena use chapters 1 and 32-49, old settings migrate; level tally counts the level's own coins and secrets

This commit is contained in:
MineTec committed 2026-11-07 15:27:08 +01:00
1 parent 987b0104fa
commit d6b852a1b6
35 files changed
+1015 -175

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
# Changelog
## 0.2.1 – changes since 0.1.0
The first test release was 0.1.0 (launcher edition). Everything below is new for testers.
There are two downloads: **full** (launcher + level editor) and **launcher** (mods only).
### Launcher
- **Clearer texts everywhere.** Mod descriptions and options are explained in plain words,
directly under each option instead of only in tooltips.
- **Setup wizard**
- Wrong files, truncated ISO downloads and too little disk space are caught when you
pick the source, not after you click Set up.
- Copy progress shows megabytes ("123 of 568 MB").
- Cancel tells you what happened.
- **Play**
- While the game runs, Play becomes **Quit game**, so a hung game can be stopped from LlamaMod.
- A crash now says "The game crashed" with advice, instead of only "The game has ended".
- Wine: a too-old Wine version gives a warning, and a wrong Wine command is explained.
- **Safer applying**
- Two LlamaMod windows (or the command line) can no longer break each other's apply.
- If LlamaMod is closed in the middle of applying mods, it finishes or rolls back the
apply on the next start and tells you.
- Settings changed in one window show up in the other.
- Old cached mod results are rebuilt after an update.
- **Mods**
- **Mirror mode:** pick the worlds with check boxes.
- **Practice and Free camera:** if two hotkeys use the same key, Apply refuses and says
which ones clash.
- **Boot to level:** a custom start position belongs to its level and no longer breaks
other levels.
- **Intro skip and Boot to level** can now be used together.
- **Test World:** a new gallery with all 253 characters, enemies, animals and objects
of the game, standing still on pedestals (the animal pen is gone).
- **New levels and the Alligator Arena only use TestLevel chapters 1 and 32–49.** On other
chapters the game stored the level's coins over your progress and other game data (chapter 9
broke the world map). Levels and settings on old chapters move to a free one automatically.
- The level tally shows the real coin and secret totals of new levels, the Test World and the
arena instead of the Village's "55".
- **Save games**
- Backups can no longer overwrite each other.
- Import… opens in the backups folder.
- The table fits small (1280×720) screens.
- **Layout:** works on small and laptop screens; the dark theme is easier to read.
### Level editor (full edition)
- **Play-test**
- Shows "Game running" and a **Stop game** button.
- Shift+F5 shows a crosshair where the llama will start.
- Play-test copies are removed when the game ends, and your mod selection comes back.
- **Readable names.** "Coin 3", "Floor 2", "Waypoint 4" instead of internal ids, in the
outliner, inspector, status bar and messages; the outliner filter finds them.
- **Error messages.** Damaged files give one plain sentence with optional details.
- **Geometry**
- Painting or erasing tiles in one drag is one undo step.
- New tile option **No collision** (walk-through decoration).
- **Meshes and export**
- **Export level as glTF/GLB** (textures, vertex colours; optionally only the selection,
or collision as a separate mesh).
- Imported and exported models are **no longer mirrored** compared to Blender. Meshes in
older projects keep their look; switch them to "right" handedness in the inspector to
fix them.
- **Objects**
- **Guards** and **alligators** need a waypoint box around them, or the game removes
them. The editor now sets this up automatically and warns when it is wrong. It also
says which objects can trigger a guard's event bit.
- The start camera no longer starts inside the llama's head on some base levels. Moving
the player start moves its camera and intro fly-by too.
- Snap to floor follows the game's own rule.
- **Object scripts**
- Custom object types can be edited and deleted (with undo).
- **Script language with compiler:** if/else, loops, switch, events, named parameters.
- The script view is much easier to read.
- Opcodes have checked descriptions.
- Narrow docks, small windows and long labels wrap instead of being cut off.
### Known limits
- Tested on Linux (Wine) and in a Windows VM. Real Windows hardware is not tested yet.
- The game draws objects only within about 12 units of the llama.
+1 -1
View File
@@ -76,7 +76,7 @@ created. Object types of other levels can be added later with **Assets → All w
The game has eight worlds – **Village, Jungle at Night, River, Jungle by Day, Mountain, City,
Catacombs, Lab** – each with several chapters. A ninth, hidden world called **TestLevel** has 49
empty chapter slots. New levels go into one of these TestLevel chapters, so they never replace an
empty chapter slots; 19 of them (1 and 32–49) are safe to use. New levels go into one of these TestLevel chapters, so they never replace an
original level: each project gets its own chapter (shown in the New level dialog and in
[Level settings](14-level-settings.md)). In the game they appear on the world-9 map as
"TestLevel – chapter *n*".
+1 -1
View File
@@ -29,7 +29,7 @@ list of recent projects.
| **Level name** | The name LlamaMod and the loading picture show. |
| **Author** | Your name (remembered for the next level). |
| **Base level** | The original level that provides object types, textures, sky, fog and music, shown with its loading picture. Default: Village – Chapter 1. See [Base levels](01-introduction.md#base-levels). |
| **TestLevel chapter** | The chapter of world 9 (TestLevel) the level replaces when it is enabled. The next free chapter is suggested. |
| **TestLevel chapter** | The chapter of world 9 (TestLevel, 32–49 or 1) the level replaces when it is enabled. The next free chapter is suggested. |
| **Start with** | **Empty** (nothing but the base level's sky, fog and music), **Flat floor** (a 16×14 grass floor, player start and an exit) or **Island** (a grass island with trees in the sea, player start and an exit). |
Every new level starts clean: of the base level it keeps only the **start zone** (its fog, colours
+1 -1
View File
@@ -25,7 +25,7 @@ nothing.
| **Description** | A few sentences for the mod list. |
| **Project** | *new level* or *changes to an original level* (read only). |
| **Base level** | The original level the project is built on – it provides the object code, textures, sounds, sky and music (read only). |
| **TestLevel chapter** | New levels only: the chapter of world 9 (TestLevel, 1–49) the level replaces when its mod is on. |
| **TestLevel chapter** | New levels only: the chapter of world 9 (TestLevel: 32–49, or 1) the level replaces when its mod is on. Chapters 2–31 are not offered: the game would store this level's coins over other game data. |
Below the chapter field the dialog lists which chapters other level projects in your `levels`
folder already use, so you can pick a free one. Each level needs its own chapter; chapter 1 is
+7 -4
View File
@@ -39,14 +39,17 @@ Then tick the level in LlamaMod, click **Apply** and **▶ Play**. To get rid of
## Chapters and conflicts
Each new level occupies one of the 49 TestLevel chapters. The editor picks a free chapter for
Each new level occupies one of the TestLevel chapters **32–49** (or chapter 1). The editor picks a free chapter for
new projects, but levels from different people may use the same one. If two active level mods
use the same chapter – or two mods change the same original level – LlamaMod stops applying and
names the files involved. Fix it in the mod's options: every new-level mod has a
**TestLevel chapter** option (1–49).
**TestLevel chapter** option (1, 32–49).
> **Note:** Chapter 1 of the TestLevel is also used by the built-in **Test World** mod. Prefer
> chapters 2 and above for your own levels.
> **Note:** Chapter 1 of the TestLevel is also used by the built-in **Test World** mod. The game
> keeps coins and flags for chapters 2–31 of world 9 in memory that belongs to other things (your
> progress, the text language …), so LlamaMod does not use them: levels saved with such a chapter
> by an older version move to a free chapter from 32 on. With more than 18 new levels, some share a
> chapter – only one of them can be on at a time.
## Updating a shared level
+3 -3
View File
@@ -38,7 +38,7 @@ option:
| Option | Values | Meaning |
|---|---|---|
| TestLevel chapter | 1 – 49 | the chapter of world 9 the level occupies; each level needs its own (chapter 1 is also used by the Test World mod) |
| TestLevel chapter | 1, 32 – 49 | the chapter of world 9 the level occupies; each level needs its own (chapter 1 is also used by the Test World mod). Other chapters would overwrite game data such as your progress, so older levels on chapters 2–31 move to a free one of 32–49 |
Edited original levels have no options – they replace their chapter. See
[Sharing levels](17-sharing.md).
@@ -61,7 +61,7 @@ not want with **Remove…**.
## The Alligator Arena minigame
The **Alligator Arena** mod (Levels) puts a small minigame on a TestLevel chapter (default 8):
The **Alligator Arena** mod (Levels) puts a small minigame on a TestLevel chapter (32–49, default 49):
a walled sand pit where alligators come in waves (wave 1: one, then two at a time). Face an
alligator and **kick** (Action) or **jump and press Action in the air** (the llama dives onto
it). Every hit costs it one life: it blinks, backs off and coins pop out – they count on the
@@ -107,7 +107,7 @@ A minigame on a TestLevel chapter: alligators come in waves into a walled pit. E
| Waves | 1 – 5 | 3 | Wave n brings n alligators (at most 2 at once). |
| Llama energy | 1 – 5 | 4 | Energy cap of the llama in the arena (5 = normal). |
| Coins per hit | 0 – 5 | 1 | Coins that pop out per hit (5 more at the end). |
| TestLevel chapter | 2 – 8 | 8 | Chapter of world 9 the arena replaces. |
| TestLevel chapter | 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49 | 49 | Chapter of world 9 the arena replaces (32–49; chapter 1 is the Test World's). |
#### Test World
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+28
View File
@@ -68,6 +68,31 @@ stein, stein_kante, stein_moos, platte, holz_dunkel, holz_maser, holzbalken, rin
inka, muster_lila, …`), a direct index (int) or a flat colour (`'meer'`, `'rot'`, … or
`(r,g,b)`; appended to the texture table as a flat-colour entry (flags 0x105, page −1)).
Contact sheet with indices: `notes/img/texturen_dorf.png`.
**Texture names on other bases** (2026-11-07). The `TEX` indices are village (t1l1m001) indices; on another
base the same index is a different picture (village 2: index 1 = dark red floor). Names are therefore
resolved per base by picture: `levelbuild.BaseTextures` holds a fingerprint (FNV-1a over page format,
size and pixels of the entry's rectangle) of every named village texture; a name maps to the village index
if the base shows that picture there (village 1: always → outputs unchanged), else to the first base entry
with the same picture (flags 0x104 preferred). Coverage of the 47 names: t1l1m002 38 (missing: the three
flower textures, `wood_grain*`, `wood_brown*`, `wood_rings`, `wood_edge`), t1l1m003 29, t1l1m004 28,
t1l2m001 9, t1l3m001/t1l5m001 0–2.
* Java builder (`LevelBuilder`, test world/arena/jump lab, `fromCd`): a missing picture is **copied from the
village** (CD file loader `wads/t1l1m001.wad`): packed onto a new 256×256 page (same page flags, 1 px
clamped border) and appended as an entry after the base table; flat colours and copies share the
appended range in order of first use. Without a loader (editor brushes: `BrushCompiler` passes none)
the name is rejected: "texture 'flowers' is a village texture this base level does not have …"
(the editor shows that as a brush warning and only offers the names the scene's base has).
`LevelBuilder.baseTextureNames()` lists the names a base has.
* Python `tools/levelbuild.py --base`: same matching (md5 of RGBA rectangle); missing names raise a
KeyError (no copying).
* In game (apptest, display :22, 2026-11-07): Java-built level on t1l1m002 (grass box, stone box, sand,
trees, plus copied `flowers`, `wood_rings`, `wood_grain`), `--editor-import` → level mod, Village
chapter 2: grass green, stone grey, trees bark + leaves, copied textures correct
(`notes/img/editor/village2_levelbuild_textures.jpg`, `…_textures2.jpg`). Before: grass = dark red
(`village2_levelbuild_start_camera.jpg`).
* Editor (display :22): on a t1l1m002 scene the brush swatches and the texture grid show the base's own
pictures for the names, without the names it lacks (`notes/img/editor/village2_texture_picker.jpg`).
**Every triangle shows a whole texture tile** (the engine has no free UVs) – so
everything is split into 1×1 pieces; narrow/flat walls show the texture squashed.
@@ -190,6 +215,9 @@ loop: if g21 == 3 ; game state "running"
49 chapter slots, table 0x572420) and calls `lama-mod an`. For this, `lama-mod` now also takes
symlinks from mod folders (`find … -type f -o -type l`). Checked: chapter 2 on the map is
selectable and loads the test world.
* **Only chapters 1 and 32–49 are safe** for world 9: the per-chapter coin counter and flag byte of
the other chapters overwrite game globals (chapter 9 destroys the furthest progress), see
[testlevel-chapters.md](testlevel-chapters.md).
* The selection map shows no background for world 9 (only text) and 772 as the coin target
(coin table 0x5722f4 only has worlds 1–8) – cosmetic.
+306
View File
@@ -0,0 +1,306 @@
# Level building with `tools/levelbuild.py`
As of: 2026-11-06 (exit + parkour reworked). Builds complete level WADs from a level description (Python script).
The base is always an original level (default: village `t1l1m001.wad`): its strat code/object models
(STPC), textures (TEXT), sound etc. are taken over; **geometry (TRAK), tile grid,
vertex colours, objects and waypoints (MAP) are generated completely from scratch.**
Result checked in game: `out/testwelt.wad` (= installed test world, see below).
## Invocation
```
python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad # build
python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad --install # + install as TestLevel
python3 tools/levelbuild.py levels/testwelt.py out/t.wad --set start=ziel # pass variables to the script
python3 tools/levelbuild.py --types # placeable strat types of the base level
python3 tools/levelbuild.py --textures tex.png # texture contact sheet + name list
python3 tools/texsheet.py WAD out.png [--all] [--only 1,2,3] [--size 96]
```
`--install` copies to `~/.wine-lama/mods/testlevel/wads/T1L9M001.wad` (creates
`.orig` once) and creates the chapter symlinks 2..49 (see "Exit"). Test in game: `tools/lamatest.py run out/x.wad outdir` (see testharness.md).
## Level script
```python
LEVEL_ARGS = dict(name='Testwelt', seed=7) # NAME chunk, random seed (texture rotation)
def build(L): # L = levelbuild.Level
L.sea(0, 0, 88, 72, y=-2.0, kill=(6, 4, 80, 66)) # sea + death plane
L.box(14, 12, 26, 24, -0.4, 0, top='stein', side='stein')
L.stairs(26, 18, 29, 22, 0, 1.5, 3, '+x')
L.player(20, 14.5, rot=0)
L.checkpoint(17, 18.5)
L.exit(70.5, 12.5)
L.strat('Bunny', 66, 34)
L.coins([(27, 20), (28, 20)])
def report(L): # optional, after building
...
```
`L.opts` contains the `--set` values (strings).
### Coordinates
* Game coordinates as for strats: **x towards east, y up, z = grid row (positive,
"south")**, 1 unit = 1 grid cell. Village grid: 96×96 → everything must lie within x,z ∈ [0,96).
* Angles in **degrees**, 0° = facing +z, 90° = facing +x (checked in game).
* Internally converted into export space (z mirrored); winding order is corrected
automatically (`_orient`), you only specify the desired outward normal.
### Geometry building blocks
| Method | Description |
|---|---|
| `floor(x0,z0,x1,z1,y, tex, rot='random', coll=True)` | horizontal surface (subdivided into 1×1 tiles) |
| `box(x0,z0,x1,z1,y0,y1, top, side, side_top=None, bottom=None, sides='nesw', side_coll=None)` | cuboid; `side_top` = texture of the topmost wall row (e.g. `'graskante'`), `side_coll=False` = walls without collision (low plinths walkable) |
| `platform(x,z,w,d,y, top, side, thick=0.5, side_top='graskante', bottom='erde')` | floating platform, top edge y |
| `pillar(x,z,y0,y1,size, side, top)` | pillar around a centre point |
| `ramp(x0,z0,x1,z1, ya, yb, '+x', top, side, base)` | ramp, rises in direction `+x/-x/+z/-z` |
| `stairs(x0,z0,x1,z1, y0, y1, n, '+x', top, side, base, walkable=True)` | stairs; `walkable`: invisible slope as collision |
| `fence(pts, y, height)` | fence (posts + rail) along a point list |
| `tree(x,z,y, height, crown)` | blocky tree |
| `sea(x0,z0,x1,z1, y, tex='meer', kill=(...))` | water surface without collision + death plane |
| `quad([BL,BR,TR,TL], tex, coll, rot)` | single quad (corners seen from the front) |
| `collider(pts, normal)` | invisible collision surface |
| `obj(path, offset, scale, tex)` | OBJ file (game coordinates), `usemtl tex_N` or texture name |
| `height_at(x,z)` | height of the topmost walkable surface |
Textures: name from `TEX` (village: `gras, gras2, gras_dunkel, blumen, sand, erde, graskante,
stein, stein_kante, stein_moos, platte, holz_dunkel, holz_maser, holzbalken, rinde, kiste,
inka, muster_lila, …`), a direct index (int) or a flat colour (`'meer'`, `'rot'`, … or
`(r,g,b)`; appended to the texture table as a flat-colour entry (flags 0x105, page −1)).
Contact sheet with indices: `notes/img/texturen_dorf.png`.
**Texture names on other bases** (2026-11-07). The `TEX` indices are village (t1l1m001) indices; on another
base the same index is a different picture (village 2: index 1 = dark red floor). Names are therefore
resolved per base by picture: `levelbuild.BaseTextures` holds a fingerprint (FNV-1a over page format,
size and pixels of the entry's rectangle) of every named village texture; a name maps to the village index
if the base shows that picture there (village 1: always → outputs unchanged), else to the first base entry
with the same picture (flags 0x104 preferred). Coverage of the 47 names: t1l1m002 38 (missing: the three
flower textures, `wood_grain*`, `wood_brown*`, `wood_rings`, `wood_edge`), t1l1m003 29, t1l1m004 28,
t1l2m001 9, t1l3m001/t1l5m001 0–2.
* Java builder (`LevelBuilder`, test world/arena/jump lab, `fromCd`): a missing picture is **copied from the
village** (CD file loader `wads/t1l1m001.wad`): packed onto a new 256×256 page (same page flags, 1 px
clamped border) and appended as an entry after the base table; flat colours and copies share the
appended range in order of first use. Without a loader (editor brushes: `BrushCompiler` passes none)
the name is rejected: "texture 'flowers' is a village texture this base level does not have …"
(the editor shows that as a brush warning and only offers the names the scene's base has).
`LevelBuilder.baseTextureNames()` lists the names a base has.
* Python `tools/levelbuild.py --base`: same matching (md5 of RGBA rectangle); missing names raise a
KeyError (no copying).
* In game (apptest, display :22, 2026-11-07): Java-built level on t1l1m002 (grass box, stone box, sand,
trees, plus copied `flowers`, `wood_rings`, `wood_grain`), `--editor-import` → level mod, Village
chapter 2: grass green, stone grey, trees bark + leaves, copied textures correct
(`notes/img/editor/village2_levelbuild_textures.jpg`, `…_textures2.jpg`). Before: grass = dark red
(`village2_levelbuild_start_camera.jpg`).
* Editor (display :22): on a t1l1m002 scene the brush swatches and the texture grid show the base's own
pictures for the names, without the names it lacks (`notes/img/editor/village2_texture_picker.jpg`).
**Every triangle shows a whole texture tile** (the engine has no free UVs) – so
everything is split into 1×1 pieces; narrow/flat walls show the texture squashed.
### Lighting (vertex colours)
`L.sun = dict(azimuth=215, elevation=50, color=(1,.93,.8), strength=.62, sky=(.5,.55,.66),
ground=(.3,.28,.24), ao=.55, ao_radius=1.3, shadow=.35)` – per vertex:
sky/ground hemisphere light + sun (Lambert) with **shadows** (ray towards the sun against all
cuboids) + **ambient occlusion** (24 rays, range `ao_radius`). Cuboids, pillars,
stairs, platforms register as shadow casters (`solids`). 1.0 = texture colour
(vertex colour 0x80), max. 2.0. `tint=(r,g,b)` multiplies per building block.
### Objects (strats)
`L.strat(name, x, z, y=None, rot=None, params=None, wp='relative', variant=0, hidden=False)`
* `y=None`: place on the topmost walkable surface at (x,z).
* Type fields (nvars, slots, flags, extra memory) and parameters are copied from the **first
original instance** of the type (`params=[…]` overrides; count must match).
* `variant`: types with several code entry points (e.g. SmashPot: 2 spawn stubs).
* **Waypoints** (`wp`): `'relative'` = move the original's waypoint chain along (offset to the
object is kept, y is set to the floor if there is one), `'here'` = all points on
the object, `'none'` = none (−1), or your own list `[(x,z[,y[,deg[,unk2]]]), …]` (other extra
fields of the original waypoints are taken over). Chains are renumbered (no references into the
old level).
* `player(x,z,rot)` (strat `Llama`), `checkpoint(x,z)` (`CheckPointPost`), `exit(x,z)`
(`LevelDoor`), `coins([(x,z),…])`.
* Types that are in the village code but not placed there, via `Strats.BORROWED` with type fields
from another level: currently `RubyIdol` (from t1l1m002).
* `poolExtra` is set to max(original, number of strats + 80); the original's parameter pool
is kept, new parameters are appended.
### What `build()` generates
1. All triangles are distributed onto tiles by (grid cell, height band of 10 units from y = −3);
tile origin = cell centre/band centre (collision d must be within ±8).
2. **Collision**: every quad with `coll=True` becomes a collision polygon; floors only in their
own cell, walls/slopes additionally in neighbouring cells up to 0.3 distance (the engine only checks
the tiles of the cell the player stands in). Unused helper vertices make sure
the tile box encloses the collision polygons.
3. Identical tile models are merged (vertex colours are stored per tile in MAP).
4. **Death plane**: original model 0 of the village (invisible, material 0x1e) in every cell
of the `kill` area that is not completely covered by floor.
5. MAP: zone grid = 0, zone 0 sees everything (mask 0xffffffff), no tile swaps (alt block
empty), tile blocks A/B/models/vertex colours consistent, strats + waypoints new, light/
environment zones (90-byte block) and rest unchanged.
6. TEXT: appended flat colours. NAME: `LEVEL_ARGS['name']`.
7. Cross-check: MAP is fully parsed again after writing.
## Findings from the game tests (lamatest, Xvfb)
* **Scale**: llama ≈ 0.6 units tall, walks ≈ 1.1 units/s (coin measuring track, see below; the
0.85 noted earlier was too low), needs ~1 s to accelerate. A jump (X) clears
0.35 high edges, 0.5 not. **With a run-up it flies ~1.3 far** (horizontally as fast as
when walking), onto +0.2 it lands after ~1.05; from standing much shorter (0.5 gap not
cleared). → Parkour: gaps 0.3 with +0.2, platforms before/after ≥ 1.5 long (after
landing there must be room for the next take-off), larger heights via stairs/ramps.
Measuring level: `levels/sprunglabor.py` (`--set spur=a..g|m`; `m` = coin measuring track: a coin every 0.25 →
coin counter × 0.25 = distance walked; `f` = long jump over water).
* **No step logic**: even a 0.12 high edge *with wall collision* blocks the llama.
Without wall collision (`side_coll=False`) it walks up onto low plinths; stairs with
an invisible slope (`stairs(walkable=True)`) and ramps are walkable (checked).
* **Controls are camera-relative** (Up = camera view direction). The start camera is behind
the llama; if there is an obstacle right behind it (e.g. a post), it moves aside →
keep the area behind start points clear.
* Rot 0° = facing +z, 90° = facing +x.
* **Death plane works**: falling into the sea → health gone, respawn at the last
checkpoint (or start).
* Player waypoint chain = start camera positions (the game takes the first node within 2.5 cells
of the llama, see `editor.model.cmd.PlayerStart`): `player()` gives the llama ONE node 2 cells
behind it, 0.5 above, unk2 = 0 (default camera distance/height) – like the editor's player start.
Until 2026-11-07 it copied the prototype's 14-node chain (first node 8 behind = out of range, the
rest on the llama): on village 2 bases (node 2 has unk2 ≠ 0) that put the camera into the llama's
head. Waypoint lists take an optional 5th value unk2: `[(x,z,y,deg,unk2)]`.
In game (test world, apptest 2026-11-07): the start camera is behind the llama at the default
distance (`notes/img/editor/testworld_start_camera.jpg`). Village 2 base (levelbuild floor + player on
t1l1m002, `--editor-import` as an edit project, boot-to-level 1/2): camera behind the llama, not in its
head (`notes/img/editor/village2_levelbuild_start_camera.jpg`).
* **LevelDoor** (exit) – solved, see section "Exit (LevelDoor)" below.
* Sizes that work: TRAK 0.6–1.0 MB, up to 1100 models, 5200 tiles, WAD up to 6.9 MB
(originals: TRAK ≤ 1 MB, ≤ 5800 tiles, WAD ≤ 6.85 MB). Test world: 628 models,
2474 tiles, TRAK 646 KB, WAD 6.3 MB.
## Exit (LevelDoor) – how it works
Strat bytecode (village, STPC 0x430332, 3 parameters, invisible, no model), decoded via the
VM `FUN_0054d180` (opcode < 0x45: table 0x5796bc with 16-bit operand, otherwise 0x578ff4+op·4):
```
g65,g66,g67 = param 0,1,2 ; tribe, world, chapter of the next level (20.12)
loop: if g21 == 3 ; game state "running"
and player.floor.material == 16 ; property 0x11 = *(strat+0xc0)+4 = material byte of the
; collision polygon below the player (FUN_00403020)
and floor(player.x) == floor(door.x) and floor(player.z) == floor(door.z) ; same grid cell
-> op 0x192 (FUN_0054d620): level change(param), g1 += 1, end
yield
```
* So the door is only a trigger; in the village there is a floor polygon with
**material 16** in the doorway (8 of them in the village, also in all other levels). Our test world had
material 0 everywhere → nothing happened. **Fix:** mosaic in the gate with `mat=16` (`box(..., mat=TUER_MAT)`),
door in the same grid cell, the player's path leads right through it.
* **Only tiles of the door cell count** (cell list, FUN_004036d0), not the height (t1l3m002–4: floor
3 units below the door). **Village 1 & co.:** the door cell initially contains *no* material 16 –
it is in the **swap model** of the tile (MAP tile swap, WFPC 0x10000): the RubyIdolDoor in
the same cell swaps tile 420 from model 48 to 49 when opening (op 0x11a/0x11b), and only
model 49 has the exit pad (42.25–42.75 / 49.23–49.50, y 3.25). Checked in game
(2026-11-07): model 49 inserted + door removed, llama at 42.5/3.5/49.37 → immediately the summary
"Dorf Kapitel 1"; original (door closed) → nothing. Affects 8 of the 31 original doors (Ruby,
PressurePad, Lab doors); the pad at 43.5/48.45 (cell 43,48), on the other hand, is a house entrance
(zone teleport). t1l3m003: 2 of the 4 doors (cells 71,48/71,49) have no pad at all.
* Second path (strat code at 0x4252aa, player logic [likely]): floor material 16 and cell =
g104/g105 (set by the LevelDoor at start, Llama sets them to −1) → level change(g101..g103).
* Parameters in the originals: (1, world, chapter+1) = next chapter; in the last chapter of a
world **(7,7,7)** = "world completed" → back to the selection map, unlock the next world.
* `FUN_0054d620`: remembers the current level (0x585008..14), sets the next (0x584eb4/b8/bc), shows the
summary (coins x/55, secrets) with "EINGABETASTE = speichern / ESC = weiter" (Enter = save / Esc = continue). Progress
0x585018/19 (max. world/chapter, in KSV +0x42/+0x43): if you are in the highest unlocked chapter,
the next chapter is unlocked; with (7,7,7) the next world.
* **(7,7,7) in world 9 crashes**: unlocks "world 10" → after saving, the
map cursor is on world 10, world name pointer 0x57bd40[9] = NULL → page fault at 0x41B5CC.
* **Solution for the test world: parameters (1,9,1)** – after the summary chapter 1 is reloaded (no
crash, tested with saving). Side effect: chapter 2 is unlocked (and from
chapter n, chapter n+1). If its WAD is missing, the selection crashes ("Couldnt load wad").
That is why `levelbuild.py --install` (function `kapitel_links()`) creates in the mod
**symlinks T1L9M002..049.wad → T1L9M001.wad and T192..T1949.COR → T191.COR** (world 9 has
49 chapter slots, table 0x572420) and calls `lama-mod an`. For this, `lama-mod` now also takes
symlinks from mod folders (`find … -type f -o -type l`). Checked: chapter 2 on the map is
selectable and loads the test world.
* The selection map shows no background for world 9 (only text) and 772 as the coin target
(coin table 0x5722f4 only has worlds 1–8) – cosmetic.
## The test world (`levels/testwelt.py` → `out/testwelt.wad`, installed as T1L9M001.wad)
**Rebuilt compactly on 2026-11-06** ("everything closer together"): L-shaped island, main part 32.5×23.5
(x 11..43.5, z 9.5..33) + strip below the parkour end (x 43.5..49, z 9.5..20.5), previously
42×34 (x 10..52, z 8..42). Gallery, enclosure and parkour adjoin each other directly, no empty
meadows any more. NAME chunk "Testwelt" (not shown anywhere in the game).
Preview images (phone, 1280 px): `notes/img/vorschau/` (01–03 Blender renderings with
labels, 04–11 game shots, 12 montage). Older images `notes/img/testwelt_*.jpg` still show
the old, sprawling layout.
| Area | old | new |
|---|---|---|
| Start square | x 14..22, z 11..19 (8×8) | x 15..21, z 12..19 (6×7) |
| Gallery | z 22..38, columns 2.5, rows z 25/28.5/32/35.5 | x 14.5..32.5, z 20.5..32, columns 1.9, rows z 22.5/25.2/27.9/30.6 (spacing 2.7) |
| Enclosure | x 39..49, z 24..35 | x 33..42, z 20.8..30 (directly east of the gallery) |
| Parkour | x 22..53.6 | x 21..52.6 (only shifted by −1 in x, same dimensions) |
| Exit | LevelDoor (51.5/15.5) | LevelDoor (50.5/15.5), cell 50/15, mosaic with material 16 |
* **Start square** (x 15..21, z 12..19): stone square with Inca mosaic, 4 Inca pillars, flower beds,
trees, 2 coins, checkpoint (16.1/17.4). The llama starts at (18, 14.6) facing south
(gallery directly behind it, parkour stairs on the left of the picture).
* **Gallery** (z 20.5..32): 4 rows, each object on a flat plinth (0.9 wide, 0.08 high,
walkable), gap 1.0 between plinths, sand path (0.6) in front of each row, coloured post at the
start of the row (yellow/grey/blue/red), main path north–south at x 15.6..16.3:
1. Collectables: Coin, ChargeBooster, RubyIdol, HealthFull, HealthGenerator, ChacaChargePotion, Secret
2. Crates/pots: SmashPot (2 variants), Jar, ObjectDontDropInBox, WaterReboundPoints, CheatTeleport
3. Characters: TipoVillage, ChacaVillage, PachaGivingInfo, KidOnLlama, Wompy
4. Mechanics: PressurePadDoor, RubyIdolDoor, SwitchPlatformPadVillage, AppearingPlatformsVillage,
RisingColumns, SinkingStatue, FallingTree, CheckPointPost
* **Enclosure** (x 33..42, z 20.8..30, fence, entrance on the west side z 20.8..24): Bunny ×2, Ladybird,
FlockOfBirds, VillagerHerder; viewing pedestal (0.6) with stairs on the west side between
gallery rows 2 and 3.
* **Parkour** (starts at the east edge of the square, **everything on the line z = 15.5 going east**,
so it can be done without rotating the camera – rotating via A/S or Left/Right was not
reproducible in scripts). Geometry is written in the script as "old x + PX" (PX = −1):
stairs 0→0.8 · **stop step** +0.3 (no gap: the llama runs into it and stands still) ·
2 gaps of 0.3 with +0.2 each onto 1.5 long wooden blocks · beam 0.5 wide · middle platform (1.5) with
checkpoint · sand ramp 1.5→2.4 · **stop step** +0.3 · 3 stepping stones (1.5 long, gaps 0.3,
+0.2 each) · narrow beam 0.45 · stairs 3.3→4.0 · goal platform (4.0; x 47.6..52.6, juts out over
the coast) with pillars, parapet and gate; below it a mosaic with material 16, LevelDoor at
(50.5 / 15.5). 18 coins along the way. Below the parkour there is meadow (falling down is harmless).
* **Scripted run** (start → goal → exit → summary), old version 4/4, compact version 2/2
runs successful (summary 18/55 coins); the .seq is time-based/relative and stayed unchanged:
```
python3 tools/levelbuild.py levels/testwelt.py out/testwelt_parkour.wad --set start=parkour
lamatest.py run out/testwelt_parkour.wad OUTDIR --seq "$(cat levels/testwelt_parkour.seq)"
```
(`run:T:x@t,…` holds Up for T s and presses X at fixed times; the stop steps reset the
timing error. Shots (`shot@`) block ~0.4 s → never shortly before a jump.)
* Checked in game: loading without a crash (many runs), all 31 gallery objects appear
(coin/secret/… react: coin counter, "GEHEIMNIS!"), the llama walks through gallery/enclosure,
up the stairs, jumps onto platform B and onto stepping stone 1, up the ramp, stairs to the
goal platform, sea = respawn, complete parkour by script, exit → summary → chapter 1 again.
Images: current `notes/img/vorschau/` (compact layout); `notes/img/testwelt_*.jpg` =
old sprawling layout (1–6 + montage even still with the old parkour).
* Start point for tests is switchable: `--set start=platz|reihe1..4|weg1..4|gehege|podest|parkour|pk1..pk5|ziel|ziel2`
(`podest` = on the viewing pedestal, looking west over the gallery);
exit parameters `--set tuer=1,9,1`.
### Village types not placed
| Type | Reason |
|---|---|
| Llama | is the player (start) |
| LevelDoor | exit at the end of the parkour (needs a floor with material 16, see above) |
| CameraSide | camera zone, invisible |
| CutSceneCameraDirectional | cutscene camera, invisible |
| CutSceneTrigger | starts a cutscene |
No placeable type crashed the game (no bisection needed).
Note: mechanics objects (doors, pillars, statue) belong to specific geometry /
tile swaps in the village; in the gallery they stand "bare" (e.g. a floating RubyIdolDoor sign).
Types that only exist in the code (BouncingCoin, Sparkles, RubyIdolStatue …) have no
original instance with type fields and are not offered (exception: RubyIdol via `Strats.BORROWED`).
## Preview renderings (Blender)
`/usr/bin/blender` (Debian 3.0.1): **Cycles renders only black** (even the default scene), OCIO
is missing ("Color management: fallback", only the "Standard" view). **Eevee works**, but needs an
X display → own `Xvfb :7` (never :0, :5 belongs to lamatest). Procedure (scripts in
`tools/vorschau/`: `strats_json.py` (call from levelbau/), `render_level.py`, `label.py`):
1. `geom_export.py out/testwelt.wad GE` → level.obj/mtl + page_NN.png
2. Strat positions: run the level script with `levelbuild.Level` (without `L.build`), `L.strats` as JSON
3. `DISPLAY=:7 STRATS=strats.json VIEWS=schraeg,schraeg2,oben blender -b -P render_level.py -- GE r 64`:
own OBJ reader (the 3.0 importer does not know vertex colours), OBJ (= game space since
2026-11-07) → Blender z up (y_blender = −game z, winding kept), material = texture (closest) × vertex colour, a little emission,
sun + sky, markers for strats (coins gold, llama start orange cone, gallery objects
in row colour, animals white, exit purple ring); writes PNG + JSON per view with
image coordinates of the area names.
4. PIL: scale to 1280 px, labels (Start/Galerie/Gehege/Parkour/Ziel), JPG q85.
+3 -1
View File
@@ -313,7 +313,9 @@ Decoded from FUN_0041c640 (load) / FUN_0041c820 (save); details in `save/Ksv.jav
| 0xD0–0xD9 | options: effects volume, music volume, 0x80, speaker mode, 6 more option bytes |
Chapters per world (table 0x572420): 4, 3, 4, 3, 3, 3, 5, 5, 49. World 9 has no row in the
per-chapter arrays. The KSV files shipped on the CD are empty (0x00 then 0xCC). Selecting an empty
per-chapter arrays. Its coin counter/flag byte for chapters 2–31 land on other globals (chapter 9 =
furthest progress): mods use only chapters 1 and 32–49 (`save/TestLevelChapters`,
[testlevel-chapters.md](testlevel-chapters.md)). The KSV files shipped on the CD are empty (0x00 then 0xCC). Selecting an empty
slot and "new game" immediately writes a zeroed file.
## Tests done (2026-11-06)
+10 -8
View File
@@ -11,11 +11,12 @@ Registered in `ModRegistry` (one line). Test-only: `ArenaTestMod` (src/test, scr
| `waves` | 3 (1–5) | wave n has min(n, 2) alligators (3 waves = 5 alligators) |
| `energy` | 4 (1–5) | llama energy cap (5 = game normal) |
| `coins` | 1 (0–5) | EnemyCoins per hit (+5 on defeat, +5 bonus at the exit) |
| `chapter` | 8 (2–8) | TestLevel chapter; a chapter already written by another mod → apply error |
| `chapter` | 49 (32–49) | TestLevel chapter; a chapter already written by another mod → apply error; a stored 2–8 falls back to 49 |
Chapter ≤ 8 on purpose: `levelRecord` (coin counter) lives at `0x584F1C[world·6 + chapter]`; for
world 9 chapter 9 that is 0x585018 = furthest progress (unverified risk for level projects on
chapters ≥ 9, see "Issues").
Only chapters 32–49: the coin counter `0x584F1C[world·6 + chapter]` and the flag byte
`0x584EC8[…]` of world-9 chapters 2–31 overwrite other globals (chapter 8 = last played map,
chapter 9 = furthest progress – verified, see [testlevel-chapters.md](testlevel-chapters.md)).
The tally total (LNFO) is 2 placed coins + alligators · ((lives − 1) · coins + 5) + 5 bonus.
Script options (`Arena`, tests/`ArenaTestMod`): `probe=true` (event probe 1.6 ahead of the llama),
`master=false` (no alligators), `start=x,z,rot`, `debug=true` (HUD coin counter shows
@@ -72,7 +73,9 @@ per type (nVars/slots/typeFlags/extra), then `LevelBuilder` builds the level.
* `spawn` copies the strat's matrix/translation block, not `x/y/z`: after `self.x = …` the child
appears at the old place unless the spawner yields first.
* Energy 0: the game's "Weiter / Beenden" screen; Weiter restarts the level (all strats fresh).
* The level tally after the exit shows "Münzen gesammelt 0 / 55" (not our coin counter).
* The level tally after the exit showed "Münzen gesammelt 0 / 55" on chapter 8: 0 = the coin slot
after endLevel wrote the last played map into it, 55 = LNFO of the Village base. Fixed: chapter
32–49 and recounted LNFO ("2 / 12" for 1 alligator with 1 life).
## Verified in game (apptest :24, Wine 6, load 3–9, 2026-11-07)
@@ -101,7 +104,6 @@ mod's chapter → IOException; probe option.
## Issues / limits
* Loading picture and tally use the TestLevel texts ("TestLevel Kapitel 8").
* Loading picture and tally use the TestLevel texts ("TestLevel Kapitel 49").
* The exit target is the arena itself (1, 9, chapter): it restarts after the tally.
* Level projects on TestLevel chapters ≥ 9 may write the coin counter over furthest progress
(0x585018) – not verified.
* Exiting chapter 49 unlocks "chapter 50" (progress 9/50); the map caps the cursor at 49 (checked).
+16 -6
View File
@@ -35,6 +35,16 @@ properties). Language/syntax: `notes/strat-language.md`. Disassembler/assembler:
getter FUN_00550e60 and setter FUN_005509f0 share the numbering; ids without a setter are read-only
(a write would go to address 0).
## Spawning and collision (engine facts, found by the arena, 2026-11-07)
* `spawn<params, vars, slots, typeFlags, extra>` (0xe0, FUN_0054bfc0) takes the same fields as a MAP
strat record: params = MAP parameter count, vars = local variables, slots, typeFlags = MAP type flags
(collision setup), `extra` = words of the collision-bone area.
* `spawn` copies the spawner's matrix/translation block (+0x20…), not its `x/y/z` properties: after
`self.x = …` the child still appears at the old place. Set the position, **yield**, then spawn.
* A MAP-placed type that uses `collisionOn` needs collision type flags in its MAP record (0x28000 like
Coin); with typeFlags 0 the game crashes (page fault in the heap allocator FUN_0041ae20).
## Sources
* groove.exe handler tables, decompiled with Ghidra 12.2 (headless, all 0x1b2 + 0x45 entries and
@@ -642,7 +652,7 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p
| 143 | `camScale` | yes | yes | low | DAT_006D9D30, camera value initialised to 1.0 (zoom/fov factor?). |
| 144 | `levelHeaderC` | yes | no | low | Level header (DAT_00584648)+0x0C ushort << 12. |
| 145 | `slopeSteepness` | yes | no | medium | 1.0 - floor normal y (DAT_0057DDA4) << 12: 0 on flat ground (FUN_004054c0). |
| 147 | `levelTableByte` | yes | no | low | Byte table 0x585024[map + (chapter + tribe*10)*4] << 12 (per-level constant). |
| 147 | `levelTableByte` | yes | no | low | Byte table 0x585024[map + (world + tribe*10)*4] << 12 (FUN_0041af40(tribe, world, map)); bss, never written by the game (always 0) – but world-9 coin counters of TestLevel chapters 22–31 land in its tribe-1 part (see testlevel-chapters.md). |
| 149 | `flagEC_1` | yes | no | low | Flag +0xEC & 0x1. |
| 150 | `flagEC_8000000` | yes | no | low | Flag +0xEC & 0x8000000. |
| 151 | `cheat5` | yes | no | medium | Cheat toggle bits DAT_00584710 & 0x20 (button-sequence cheats, FUN_0041a0xx). |
@@ -660,10 +670,10 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p
| 165 | `instanceData1` | yes | yes | low | Map-instance record (+0xF0)+0x28 -> word 1. |
| 166 | `floorCollisionOn` | yes | no | medium | Flag +0xEC & 0x8000 (floor collision, ops 0xa5/0xa6). |
| 169 | `lives` | yes | yes | low | DAT_00584E6C (fixed, starts at 5.0, saved per slot) - lives or max health. |
| 171 | `saveProgressA` | yes | no | low | DAT_00585008 << 12 (saved progress byte). |
| 172 | `progressWorld` | yes | no | low | DAT_0058500C << 12 (saved; world of the furthest/selected level, 8 = finale). |
| 173 | `progressChapter` | yes | no | low | DAT_00585010 << 12 (saved; chapter, 6 with world 8 = last level). |
| 174 | `saveProgressB` | yes | no | low | DAT_00585014 << 12 (saved progress byte). |
| 171 | `saveProgressA` | yes | no | low | DAT_00585008 << 12: last played tribe (KSV +0x3E, set by endLevel). |
| 172 | `progressWorld` | yes | no | low | DAT_0058500C << 12: last played world (KSV +0x3F, set by endLevel; 8 = finale). |
| 173 | `progressChapter` | yes | no | low | DAT_00585010 << 12: last played chapter (KSV +0x40; 6 with world 8 = last level). |
| 174 | `saveProgressB` | yes | no | low | DAT_00585014 << 12: last played map (KSV +0x41). |
| 175 | `unk_6da308` | yes | no | low | DAT_006DA308, no other reference. |
| 178 | `cheat4` | yes | no | medium | DAT_00584710 & 0x10 (cheat toggle). |
| 179 | `hudFlags` | yes | yes | medium | DAT_006DA2E0 HUD element enable mask (default 0x3F000; 0x2000 timer, 0x8000/0x10000/0x20000/0x40000 counters). |
@@ -676,7 +686,7 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p
| 187 | `hitDamage` | yes | yes | medium | +0x140: damage dealt on contact (copied into the other strat's +0x144). |
| 188 | `damageTaken` | yes | no | medium | +0x144: damage received from the last contact (player cleared to 0 each frame). |
| 189 | `health2` | yes | yes | medium | DAT_00584E70 = health (same as 92, settable). |
| 190 | `levelRecord` | yes | yes | low | Per-level saved word DAT_00584F1C[chapter + world*6] (best/collected value for the current level; HUD shows it). |
| 190 | `levelRecord` | yes | yes | medium | Coin counter of the current level: u32 DAT_00584F1C[chapter + world*6] (coins << 12; HUD and tally show it, zeroed on level start, KSV keeps worlds 0–8 as u16). World 9 has no row: TestLevel chapter c hits 0x584FF4 + 4c (chapter 9 = furthest progress 0x585018) – only chapters 1 and 32–49 are free (testlevel-chapters.md). |
| 191 | `counterC` | yes | yes | low | DAT_00584FFC: persistent HUD counter (icon 0x150), saved. |
| 192 | `counterA` | yes | yes | low | DAT_00584FF4: persistent HUD counter (icon 0x184, displayed up to 999) - probably coins. |
| 201 | `distToPlayerSq` | yes | no | high | +0x148: cached squared distance to the player ((d>>6)^2 sum), updated each frame when tracking (op 0x30). |
+75
View File
@@ -0,0 +1,75 @@
# TestLevel chapters: which ones are safe (2026-11-07)
World 9 ("TestLevel") has 49 chapter slots (table 0x572420), but the game's per-chapter arrays only
have rows for worlds 0–8. Indexing them with world 9 runs into other globals. Code:
`save/TestLevelChapters` (usable = chapter **1** and **32–49**).
## The arrays (index world·6 + chapter)
| Array | Where it is used |
|---|---|
| u32 coins 0x584F1C (coins << 12) | `levelRecord` (strat var 0xBE) get/set, HUD (FUN_00426bf0), tally (FUN_0040dd60), endLevel (FUN_0054d620: "all coins" check), **zeroed on every level start** (FUN_0041cba0), KSV keeps 54 entries as u16 |
| u8 flags 0x584EC8 (bit 0 collectible, bit 1 gold coin) | collectible picked up (HUD, strat var 0xFA rising) → \|=1; endLevel → \|=2 when coins == static total 0x5722F4[world·5 + chapter] (garbage for world 9, often 0) or cheat 0x584710&8 → \|=3; gold count FUN_0041cd70 (worlds 1–8 only) |
Coin total table 0x5722F4 and the selection map only know worlds 1–8 (map shows garbage targets
for world 9 – cosmetic).
## What world-9 chapter c hits
Coin slot 0x584FF4 + 4c, flag byte 0x584EFE + c (Ghidra references 0x584EF0–0x585240):
| c | coin slot | flag byte |
|---|---|---|
| 0 | 0x584FF4 counter var 0xC0 (HUD) | free |
| **1** | 0x584FF8 free (only cleared by new game) | free |
| 2 | 0x584FFC counter var 0xBF (HUD icon 0x150) | free |
| 3 | 0x585000 HUD flag (FUN_00427c40) | free |
| 4 | 0x585004 var 0x80 | free |
| 5–8 | 0x585008–14 last played tribe/world/chapter/map (strat vars 0xAB–0xAE, KSV +0x3E–41) | free |
| 6–9 | | 0x584F04 **language index** (u32; \|=2 → text table out of range) |
| 9 | 0x585018 **furthest progress** (world, chapter bytes; KSV +0x42/43) | |
| 10–13 | 0x58501C/20 free | 0x584F08 option (KSV +0xD6) |
| 12 | 0x585024 start of byte table (var 0x93, tribe 0) | |
| 14–21 | 0x585028–48 free | 0x584F0C / 0x584F10 (var 0x5B) |
| 22–25 | 0x58504C–58 table 0x585024, tribe 1 (read by var 0x93 in original levels) | free |
| 26–29 | table, tribe 1 | 0x584F18 option (KSV +0xD8) |
| 30–31 | table, tribe 1 (world 8/9) | coin slots of world 0 (never read) |
| **32–49** | table, tribes 2/3 (never used: tribe is 1, 4 only in the attract demo) | coin slots of world 0 |
## Verified in game (apptest :24, 2026-11-07)
* **Chapter 9** (arena test mod, 1 alligator, 2 coins collected): pause → "Beenden und zur
Auswahlkarte" → the selection map shows world "°p", "Kapitel 0", coin target 14384: furthest
progress 0x585018 was replaced by the coin counter (2 << 12 → world 0, chapter 32).
![chapter 9: broken map](img/mods/testlevel_ch9_map_broken.jpg)
* **Chapter 49** (same run): tally "Münzen gesammelt 2 / 12, Geheimnisse 0 / 0" (LNFO recounted),
save written with progress (9, 50) – the map caps the cursor at chapter 49, no crash; map fine.
![chapter 49 tally](img/mods/testlevel_ch49_tally.jpg)
* Before (arena on chapter 8, arena agent): tally "0 / 55": the collected value is the coin slot
after endLevel copied the level ids into 0x585008–14 (chapter 8 = last played map = 0), the total
is LNFO of the Village base.
## Tally totals (LNFO)
Chunk LNFO = u32 coins, u32 secrets → 0x5FF070 / 0x5FF060 (FUN_0042AB10; the tally shows the values
of the level just finished, kept in 0x5FF064 / 0x5FF06C). Originals: e.g. t1l3m004 40 = 20 Coin +
4 Coin5·5; levels with CoinLine/CoinCircle/enemies count those too. `mods/levels/LevelTally` recounts
Coin (1) + Coin5 (5) and Secret strats for new level projects and the Test World (20 coins, 0
secrets); the arena adds its dynamic coins (alligator hit/death coins + 5 exit bonus).
## Migration
* Level projects: stored chapter 1 or 32–49 kept (first file wins), others get the first free of
32–49; more than 18 projects share chapters (only one of them can be on – apply error otherwise).
Play-tests/exports of an old project: `migrate` (2..19 → 32..49, 20..31 → 32..43).
* The chapter options are choices (level: 1, 32–49; arena: 32–49, default 49); a stored old value is
invalid and falls back to the default (ModOptions).
* Editor: New level / Level settings spinners only offer usable chapters.
![chapter option](img/mods/testlevel_chapter_option.jpg)
## Not fixed
* The map lets the player pick any unlocked world-9 chapter; empty ones are linked to an installed
world-9 WAD (ModManager), so playing e.g. chapter 9 from the map still clobbers progress. Unlocking
chapter 32+ unlocks 2–31 on the map too.
@@ -17,6 +17,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.GridLayout;
@@ -38,6 +39,7 @@ import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.SpinnerListModel;
/** Level → Settings: name, chapter, grid size, death plane, WFPC flags, object pool (one undo step). */
final class LevelSettingsDialog {
@@ -80,9 +82,13 @@ final class LevelSettingsDialog {
"Base level",
new JLabel(st.base() + " (object code, textures, sounds)"),
"Fixed when the project is created (the project stores only the differences to it)");
JSpinner chapter = Ui.number(Math.max(1, st.targetChapter()), 1, 49, 1, "0", v -> {});
JSpinner chapter = new JSpinner(new SpinnerListModel(TestLevelChapters.all()));
chapter.setValue(TestLevelChapters.migrate(st.targetChapter()));
JLabel used = new JLabel(usedChapters(ctx, st.targetChapter()));
f.row("TestLevel chapter", chapter, "New levels replace this chapter of world 9 (TestLevel)");
f.row(
"TestLevel chapter",
chapter,
"New levels replace this chapter of world 9 (TestLevel): 32–49, or 1 (the Test World's)");
f.full(used);
chapter.setEnabled(st.isNewLevel());
@@ -17,6 +17,7 @@ import com.eliasmueller.llamamod.platform.Config;
import com.eliasmueller.llamamod.platform.GameLauncher;
import com.eliasmueller.llamamod.platform.Json;
import com.eliasmueller.llamamod.platform.Progress;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import com.eliasmueller.llamamod.save.Worlds;
import java.io.IOException;
import java.nio.file.Files;
@@ -50,7 +51,7 @@ public final class PlaytestRunner {
/** World/chapter of a project: TestLevel chapter, or the original's place; null if not on the map. */
public static int[] target(Document d) {
if (d.isNewLevel()) {
return new int[] {9, Math.max(1, d.scene().settings().targetChapter())};
return new int[] {9, TestLevelChapters.migrate(d.scene().settings().targetChapter())};
}
Matcher m = Pattern.compile("t1l(\\d)m(\\d{3})").matcher(d.base().toLowerCase(Locale.ROOT));
if (m.matches()) {
@@ -4,6 +4,7 @@ import com.eliasmueller.llamamod.editor.io.ProjectIO;
import com.eliasmueller.llamamod.editor.io.ProjectInfo;
import com.eliasmueller.llamamod.editor.io.Templates;
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
@@ -28,7 +29,7 @@ import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import javax.swing.SpinnerListModel;
import javax.swing.UIManager;
/** New level: name, base level (object types, textures, sky, music), template, TestLevel chapter. */
@@ -51,7 +52,8 @@ final class NewLevelDialog extends JDialog {
base.setSelectedItem(l);
}
}
JSpinner chapter = new JSpinner(new SpinnerNumberModel(nextFreeChapter(w), 1, 49, 1));
JSpinner chapter = new JSpinner(new SpinnerListModel(TestLevelChapters.all()));
chapter.setValue(nextFreeChapter(w));
PicturePanel pic = new PicturePanel(300, 170);
Runnable updatePic = () -> {
OriginalLevels.Level l = (OriginalLevels.Level) base.getSelectedItem();
@@ -75,7 +77,8 @@ final class NewLevelDialog extends JDialog {
row,
"TestLevel chapter",
chapter,
"The level replaces this chapter of world 9 (TestLevel) when it is enabled");
"The level replaces this chapter of world 9 (TestLevel) when it is enabled (32–49; 1 is the Test"
+ " World's)");
JPanel templates = new JPanel();
templates.setLayout(new BoxLayout(templates, BoxLayout.Y_AXIS));
ButtonGroup g = new ButtonGroup();
@@ -161,7 +164,7 @@ final class NewLevelDialog extends JDialog {
return row + 1;
}
/** First TestLevel chapter from 2 that no project in the levels folder uses. */
/** First usable TestLevel chapter from 32 that no project in the levels folder uses. */
static int nextFreeChapter(EditorWindow w) {
Set<Integer> used = new HashSet<>();
for (Path p : LevelProjects.list(w.env().levelsDir())) {
@@ -174,11 +177,7 @@ final class NewLevelDialog extends JDialog {
// skip broken files
}
}
int c = 2;
while (used.contains(c) && c < 49) {
c++;
}
return c;
return TestLevelChapters.pick(TestLevelChapters.FIRST, used);
}
Result result() {
@@ -15,6 +15,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.platform.Json;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.File;
@@ -592,7 +593,7 @@ public final class ProjectController {
? String.format(
Locale.ROOT,
"T1L9M%03d",
Math.max(1, d.scene().settings().targetChapter()))
TestLevelChapters.migrate(d.scene().settings().targetChapter()))
: d.base();
fc.setSelectedFile(new File(fc.getCurrentDirectory(), stem + ".wad"));
if (fc.showSaveDialog(w) != JFileChooser.APPROVE_OPTION) {
@@ -1,6 +1,8 @@
package com.eliasmueller.llamamod.format.stpcmerge;
import com.eliasmueller.llamamod.format.TextChunk;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
@@ -87,6 +89,41 @@ public final class TextMerge {
}
}
/**
* TEXT chunk {@code orig} with the pages, entries and animations of {@code tex} (usually
* {@code Textures.of(orig)} extended by {@link #merge}); palettes and the rest are kept.
*/
public static byte[] rebuild(byte[] orig, Textures tex) {
Textures old = Textures.of(orig);
int head = 8 + 8 * old.entries.size();
for (Anim a : old.anims) {
head += 3 + a.data().length;
}
TextChunk tc = TextChunk.parse(orig);
byte[] rest = Arrays.copyOfRange(tc.tail, head, tc.tail.length);
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
.order(ByteOrder.LITTLE_ENDIAN);
b.putInt(tex.entries.size());
for (TextChunk.TextureEntry e : tex.entries) {
b.putShort((short) e.flags())
.put((byte) e.page())
.put((byte) e.page2())
.put((byte) e.u0())
.put((byte) e.u1())
.put((byte) e.v0())
.put((byte) e.v1());
}
b.putInt(tex.anims.size());
for (Anim a : tex.anims) {
b.putShort((short) a.texture()).put((byte) a.type()).put(a.data());
}
b.put(rest);
tc.pages.clear();
tc.pages.addAll(tex.pages);
tc.tail = Arrays.copyOf(b.array(), b.position());
return tc.build();
}
/** Result: map[srcIndex] = target index (−1 = not needed), counts of added items. */
public record Result(int[] map, int addedPages, int addedEntries, int addedAnims, int reusedEntries) {}
@@ -3,7 +3,6 @@ package com.eliasmueller.llamamod.mods.arena;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.TextChunk;
import com.eliasmueller.llamamod.format.Wad;
import com.eliasmueller.llamamod.format.stpcmerge.DialogMerge;
import com.eliasmueller.llamamod.format.stpcmerge.SoundMerge;
@@ -106,10 +105,6 @@ final class ArenaImport {
StpcMerge.Analysis tgt = new StpcMerge.Analysis(new StpcImage(wad.require("STPC"), lv.wfpc()), troots);
byte[] text = wad.require("TEXT");
TextMerge.Textures tex = TextMerge.Textures.of(text);
int origTail = 8 + 8 * tex.entries.size();
for (TextMerge.Anim a : tex.anims) {
origTail += 3 + a.data().length;
}
List<SoundMerge.Sample> snd = SoundMerge.parse(wad.get("SMPC"));
DialogMerge.Texts texts = wad.get("LGPC") == null ? null : DialogMerge.Texts.parse(wad.get("LGPC"));
DialogMerge.Speech speech = DialogMerge.Speech.parse(wad.get("SRPC"));
@@ -143,40 +138,13 @@ final class ArenaImport {
Wad out = new Wad(wad.chunks());
out.set("STPC", res.stpc());
out.set("MAP", map.build());
out.set("TEXT", text(text, tex, origTail));
out.set("TEXT", TextMerge.rebuild(text, tex));
if (wad.get("SMPC") != null) {
out.set("SMPC", SoundMerge.build(snd));
}
return new Merged(out.toBytes(), codes, res.stats());
}
/** TEXT chunk with the (extended) pages, entries and animations of tex; the rest is kept. */
private static byte[] text(byte[] orig, TextMerge.Textures tex, int origTailHead) {
TextChunk tc = TextChunk.parse(orig);
byte[] rest = Arrays.copyOfRange(tc.tail, origTailHead, tc.tail.length);
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
.order(ByteOrder.LITTLE_ENDIAN);
b.putInt(tex.entries.size());
for (TextChunk.TextureEntry e : tex.entries) {
b.putShort((short) e.flags())
.put((byte) e.page())
.put((byte) e.page2())
.put((byte) e.u0())
.put((byte) e.u1())
.put((byte) e.v0())
.put((byte) e.v1());
}
b.putInt(tex.anims.size());
for (TextMerge.Anim a : tex.anims) {
b.putShort((short) a.texture()).put((byte) a.type()).put(a.data());
}
b.put(rest);
tc.pages.clear();
tc.pages.addAll(tex.pages);
tc.tail = Arrays.copyOf(b.array(), b.position());
return tc.build();
}
private static void putI32(byte[] d, int o, int v) {
ByteBuffer.wrap(d, o, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(v);
}
@@ -5,6 +5,8 @@ import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.levels.LevelTally;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
@@ -53,7 +55,29 @@ public final class ArenaMod implements Mod {
ModOption.integer(
"energy", "Llama energy", "Energy cap of the llama in the arena (5 = normal).", 4, 1, 5),
ModOption.integer("coins", "Coins per hit", "Coins that pop out per hit (5 more at the end).", 1, 0, 5),
ModOption.integer("chapter", "TestLevel chapter", "Chapter of world 9 the arena replaces.", 8, 2, 8));
chapterOption());
}
static final int DEFAULT_CHAPTER = TestLevelChapters.LAST;
/** TestLevel chapters 32–49 ({@link TestLevelChapters}); a stored 2–8 falls back to 49. */
static ModOption chapterOption() {
ModOption.Choice[] cs = new ModOption.Choice[TestLevelChapters.LAST - TestLevelChapters.FIRST + 1];
for (int c = TestLevelChapters.FIRST; c <= TestLevelChapters.LAST; c++) {
cs[c - TestLevelChapters.FIRST] = ModOption.of(Integer.toString(c), Integer.toString(c));
}
return ModOption.choice(
"chapter",
"TestLevel chapter",
"Chapter of world 9 the arena replaces (32–49; chapter 1 is the Test World's).",
Integer.toString(DEFAULT_CHAPTER),
cs);
}
/** Coins the arena can give beyond its placed ones: hit and death coins of every alligator, exit bonus. */
static int dynamicCoins(ArenaScripts.Settings s) {
int perGator = (s.lives() - 1) * s.coinsPerHit() + 5;
return ArenaScripts.totalAlligators(s.waves()) * perGator + 5;
}
/** After Test World (50) and level projects (60), so a clash with them is reported here. */
@@ -95,7 +119,8 @@ public final class ArenaMod implements Mod {
}
ctx.progress(0.0, "building alligator arena");
byte[] base = ctx.read(LevelBuilder.BASE_WAD);
byte[] wad = new Arena(settings(ctx))
ArenaScripts.Settings settings = settings(ctx);
byte[] wad = new Arena(settings)
.buildWad(
base,
rel -> {
@@ -106,7 +131,7 @@ public final class ArenaMod implements Mod {
}
},
scriptOpts);
ctx.write(target, wad);
ctx.write(target, LevelTally.counted(wad, dynamicCoins(settings)));
ctx.progress(0.9, "loading picture");
ctx.write("T19" + chapter + ".COR", loadingPicture(chapter));
ctx.progress(1.0, "arena done");
@@ -10,6 +10,7 @@ import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.boottolevel.BootToLevelMod;
import com.eliasmueller.llamamod.mods.boottolevel.StartOverride;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -129,14 +130,22 @@ public final class LevelProjectMod implements Mod {
if (!info.isNewLevel()) {
return List.of();
}
return List.of(ModOption.integer(
"chapter",
"TestLevel chapter",
"Chapter of world 9 (TestLevel) the level replaces. Chapter 1 is also used by the Test World mod;"
+ " each level needs its own chapter. Unlock it on the Save games tab.",
defaultChapter,
1,
49));
return List.of(chapterOption(
"Chapter of world 9 (TestLevel) the level replaces: 32–49, or 1 (the Test World's chapter). Each"
+ " level needs its own chapter. Unlock it on the Save games tab.",
defaultChapter));
}
/**
* TestLevel chapter choice: the usable chapters ({@link TestLevelChapters}); a stored value
* outside them (2–31 from older versions) falls back to {@code def}.
*/
private static ModOption chapterOption(String description, int def) {
ModOption.Choice[] cs = TestLevelChapters.all().stream()
.map(c -> ModOption.of(
Integer.toString(c), c == TestLevelChapters.TEST_WORLD ? "1 (Test World)" : "" + c))
.toArray(ModOption.Choice[]::new);
return ModOption.choice("chapter", "TestLevel chapter", description, Integer.toString(def), cs);
}
/** CD path of the WAD this mod writes. */
@@ -160,7 +169,8 @@ public final class LevelProjectMod implements Mod {
throw new IOException("Level \"" + name() + "\": " + target + " is already changed by another mod ("
+ (info.isNewLevel()
? "Test World or another level on TestLevel chapter " + chapter
+ "). Choose a free chapter in the options of \"" + name() + "\""
+ "). Choose a free chapter in the options of \"" + name()
+ "\" or turn one of them off"
: "two mods change the same level). Turn one of them off")
+ ".");
}
@@ -174,6 +184,9 @@ public final class LevelProjectMod implements Mod {
throw new IOException("Level \"" + name() + "\": " + bad + ". Move the Llama onto walkable ground in"
+ " the level editor.");
}
if (info.isNewLevel()) {
wad = LevelTally.counted(wad);
}
ctx.write(target, wad);
if (info.isNewLevel()) {
ctx.progress(0.9, "loading picture");
@@ -5,18 +5,21 @@ import com.eliasmueller.llamamod.editor.io.ProjectInfo;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModSource;
import com.eliasmueller.llamamod.platform.AppPaths;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* One mod per {@code .llamalevel} file in {@code <data>/levels/} (category "levels"). New levels
* get a default TestLevel chapter: their stored target, or the next free one if another project
* already uses it. Temporary play-test copies in {@code levels/.playtest/} are listed too, with
* get a default TestLevel chapter: their stored target, or the next free usable one if another
* project already uses it or it is not usable ({@link TestLevelChapters}; old projects on 2–31). Temporary play-test copies in {@code levels/.playtest/} are listed too, with
* ids {@code playtest-…}.
*/
public final class LevelProjectSource implements ModSource {
@@ -32,57 +35,57 @@ public final class LevelProjectSource implements ModSource {
@Override
public List<Mod> mods() {
List<Mod> out = new ArrayList<>();
Set<Integer> used = new HashSet<>();
List<Path> levels = LevelProjects.list(dir);
List<Path> tests = LevelProjects.list(LevelProjects.playtestDir(dir));
Set<String> bases = new HashSet<>();
levels.forEach(p -> bases.add(LevelProjectMod.baseId(p, false)));
tests.forEach(p -> bases.add(LevelProjectMod.baseId(p, true)));
Set<String> ids = new HashSet<>();
for (Path p : levels) {
add(p, false, used, bases, ids, out);
List<Entry> all = new ArrayList<>();
levels.forEach(p -> read(p, false, all));
tests.forEach(p -> read(p, true, all));
// usable stored chapters first (first file wins), so a moved old project never displaces them
Map<Entry, Integer> chapters = new HashMap<>();
Set<Integer> used = new HashSet<>();
for (Entry e : all) {
int c = e.info.targetChapter();
if (e.info.isNewLevel() && !e.playtest && TestLevelChapters.usable(c) && used.add(c)) {
chapters.put(e, c);
}
}
for (Path p : tests) {
add(p, true, used, bases, ids, out);
List<Mod> out = new ArrayList<>();
Set<String> ids = new HashSet<>();
for (Entry e : all) {
int chapter = 0;
if (e.info.isNewLevel() && e.playtest) {
chapter = TestLevelChapters.migrate(e.info.targetChapter());
} else if (e.info.isNewLevel()) {
chapter = chapters.computeIfAbsent(e, k -> TestLevelChapters.pick(k.info.targetChapter(), used));
used.add(chapter);
}
// names differing only in case or punctuation share an id: later ones get a suffix no other file has
String base = LevelProjectMod.baseId(e.file, e.playtest);
String id = base;
if (!ids.add(id)) {
int i = 2;
while (bases.contains(base + "-" + i) || ids.contains(base + "-" + i)) {
i++;
}
id = base + "-" + i;
ids.add(id);
}
out.add(new LevelProjectMod(e.file, e.data, e.info, chapter, id, e.playtest));
}
return out;
}
private static void add(
Path p, boolean playtest, Set<Integer> used, Set<String> bases, Set<String> ids, List<Mod> out) {
byte[] d;
ProjectInfo info;
private record Entry(Path file, boolean playtest, byte[] data, ProjectInfo info) {}
private static void read(Path p, boolean playtest, List<Entry> out) {
try {
d = Files.readAllBytes(p);
info = ProjectIO.info(d);
byte[] d = Files.readAllBytes(p);
out.add(new Entry(p, playtest, d, ProjectIO.info(d)));
} catch (IOException | RuntimeException e) {
return;
// unreadable or broken: not listed
}
int chapter = 0;
if (info.isNewLevel() && playtest) {
chapter = Math.max(1, Math.min(49, info.targetChapter()));
} else if (info.isNewLevel()) {
chapter = info.targetChapter();
if (chapter < 1 || chapter > 49 || used.contains(chapter)) {
chapter = 2;
while (used.contains(chapter) && chapter < 49) {
chapter++;
}
}
used.add(chapter);
}
// names differing only in case or punctuation share an id: later ones get a suffix no other file has
String base = LevelProjectMod.baseId(p, playtest);
String id = base;
if (!ids.add(id)) {
int i = 2;
while (bases.contains(base + "-" + i) || ids.contains(base + "-" + i)) {
i++;
}
id = base + "-" + i;
ids.add(id);
}
out.add(new LevelProjectMod(p, d, info, chapter, id, playtest));
}
}
@@ -0,0 +1,58 @@
package com.eliasmueller.llamamod.mods.levels;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Strats;
import com.eliasmueller.llamamod.format.Wad;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.List;
import java.util.Map;
/**
* The level tally's totals ("Münzen gesammelt n / total", "Geheimnisse entdeckt n / total"): chunk
* LNFO = u32 coins, u32 secrets (→ 0x5ff070 / 0x5ff060, FUN_0042ab10). Levels built on a base keep
* the base's numbers (Village: 55, 2) unless they are recounted here.
*/
public final class LevelTally {
private LevelTally() {}
/**
* Placed coins (Coin = 1, Coin5 = 5) and Secret strats of a level WAD. Other coin sources
* (CoinLine, CoinCircle, enemies' EnemyCoins …) are not counted.
*/
public static int[] count(byte[] wad) {
Level lv = Level.parse(wad);
Map<String, List<MapChunk.Strat>> t = Strats.levelTypes(lv.wad().require("STPC"), lv.wfpc(), lv.map());
int coins = n(t, "Coin") + 5 * n(t, "Coin5");
return new int[] {coins, n(t, "Secret")};
}
private static int n(Map<String, List<MapChunk.Strat>> t, String type) {
List<MapChunk.Strat> l = t.get(type);
return l == null ? 0 : l.size();
}
/** {@code wad} with LNFO set to {@link #count} plus {@code extraCoins}; unchanged without LNFO. */
public static byte[] counted(byte[] wad, int extraCoins) {
int[] c = count(wad);
return withTotals(wad, c[0] + extraCoins, c[1]);
}
public static byte[] counted(byte[] wad) {
return counted(wad, 0);
}
/** {@code wad} with LNFO = (coins, secrets); unchanged if it has no LNFO chunk. */
public static byte[] withTotals(byte[] wad, int coins, int secrets) {
Wad w = Wad.parse(wad);
byte[] old = w.get("LNFO");
if (old == null || old.length < 8) {
return wad;
}
byte[] ln = old.clone();
ByteBuffer.wrap(ln).order(ByteOrder.LITTLE_ENDIAN).putInt(0, coins).putInt(4, secrets);
w.set("LNFO", ln);
return w.toBytes();
}
}
@@ -412,10 +412,6 @@ public final class Gallery {
StpcMerge.Analysis tgt = new StpcMerge.Analysis(ti, roots);
byte[] text = wad.require("TEXT");
TextMerge.Textures tex = TextMerge.Textures.of(text);
int origTail = 8 + 8 * tex.entries.size();
for (TextMerge.Anim a : tex.anims) {
origTail += 3 + a.data().length;
}
List<SoundMerge.Sample> snd = SoundMerge.parse(wad.get("SMPC"));
DialogMerge.Texts texts = wad.get("LGPC") == null ? null : DialogMerge.Texts.parse(wad.get("LGPC"));
DialogMerge.Speech speech = DialogMerge.Speech.parse(wad.get("SRPC"));
@@ -463,34 +459,7 @@ public final class Gallery {
Wad out = new Wad(wad.chunks());
out.set("STPC", res.stpc());
out.set("MAP", map.build());
out.set("TEXT", text(text, tex, origTail));
out.set("TEXT", TextMerge.rebuild(text, tex));
return new Merged(out.toBytes(), codes, res.stats());
}
/** TEXT chunk with the (extended) pages, entries and animations of tex; the rest is kept. */
private static byte[] text(byte[] orig, TextMerge.Textures tex, int origTailHead) {
TextChunk tc = TextChunk.parse(orig);
byte[] rest = Arrays.copyOfRange(tc.tail, origTailHead, tc.tail.length);
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
.order(java.nio.ByteOrder.LITTLE_ENDIAN);
b.putInt(tex.entries.size());
for (TextChunk.TextureEntry e : tex.entries) {
b.putShort((short) e.flags())
.put((byte) e.page())
.put((byte) e.page2())
.put((byte) e.u0())
.put((byte) e.u1())
.put((byte) e.v0())
.put((byte) e.v1());
}
b.putInt(tex.anims.size());
for (TextMerge.Anim a : tex.anims) {
b.putShort((short) a.texture()).put((byte) a.type()).put(a.data());
}
b.put(rest);
tc.pages.clear();
tc.pages.addAll(tex.pages);
tc.tail = Arrays.copyOf(b.array(), b.position());
return tc.build();
}
}
@@ -4,6 +4,7 @@ import com.eliasmueller.llamamod.format.CorEncoder;
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.levels.LevelTally;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
@@ -59,7 +60,7 @@ public final class TestWorldMod implements Mod {
}
},
Map.of());
ctx.write(OUT_WAD, wad);
ctx.write(OUT_WAD, LevelTally.counted(wad));
ctx.progress(0.9, "loading picture");
ctx.write(LOADING_PICTURE, loadingPicture());
ctx.progress(1.0, "test world done");
@@ -0,0 +1,76 @@
package com.eliasmueller.llamamod.save;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* TestLevel (world 9) chapters a mod level may occupy. The game has per-chapter arrays only for
* worlds 1–8: the coin counter u32 0x584F1C[world·6 + chapter] and the flag byte
* 0x584EC8[world·6 + chapter]. For world 9 both run into other globals, which the game zeroes on
* level start and overwrites with coins or flags:
*
* <pre>
* coin slot 0x584FF4 + 4c: c=0 counter var 0xC0, 1 unused, 2 counter var 0xBF, 3 HUD flag 0x585000,
* 4 var 0x80, 5..8 last played tribe/world/chapter/map (0x585008..14), 9 furthest progress
* (0x585018: zeroed on level start, coins make it garbage), 10..21 unused, 22..31 byte table
* 0x585024 (strat var 0x93, tribe 1), 32..49 the same table for tribes 2/3 (never used).
* flag byte 0x584EFE + c: 0..5 unused, 6..9 language index 0x584F04 (|=2 → wrong texts/crash),
* 10..13 option 0x584F08, 14..21 0x584F0C/0x584F10, 22..25 unused, 26..29 option 0x584F18,
* 30..49 coin slots of world 0 (never read).
* </pre>
*
* Safe: chapter 1 (Test World) and 32–49.
*/
public final class TestLevelChapters {
private TestLevelChapters() {}
/** The Test World's chapter. */
public static final int TEST_WORLD = 1;
/** First and last chapter for other mod levels. */
public static final int FIRST = 32;
public static final int LAST = 49;
public static boolean usable(int chapter) {
return chapter == TEST_WORLD || chapter >= FIRST && chapter <= LAST;
}
/** All usable chapters: 1, 32..49. */
public static List<Integer> all() {
List<Integer> out = new ArrayList<>();
out.add(TEST_WORLD);
for (int c = FIRST; c <= LAST; c++) {
out.add(c);
}
return out;
}
/**
* A usable chapter for a stored one: unchanged if usable, else 2..19 → 32..49, 20..31 → 32..43,
* anything else → 32 (deterministic, for play-tests and exports of old projects).
*/
public static int migrate(int chapter) {
if (usable(chapter)) {
return chapter;
}
return chapter >= 2 && chapter < FIRST ? FIRST + (chapter - 2) % (LAST - FIRST + 1) : FIRST;
}
/**
* {@code wanted} if usable and not in {@code used}, else the first free chapter of 32..49; when
* all are taken, {@link #migrate}({@code wanted}) (shared: only one of those levels can be on).
*/
public static int pick(int wanted, Collection<Integer> used) {
if (usable(wanted) && !used.contains(wanted)) {
return wanted;
}
for (int c = FIRST; c <= LAST; c++) {
if (!used.contains(c)) {
return c;
}
}
return migrate(wanted);
}
}
@@ -76,7 +76,10 @@ class PlaytestLifecycleTest {
assertNotNull(t);
assertTrue(t.playtest());
assertTrue(t.name().endsWith("(play-test)"));
assertEquals("4", t.options().getFirst().defaultValue(), "own chapter, not shifted by the saved level");
assertEquals(
"34",
t.options().getFirst().defaultValue(),
"own chapter (old 4 → 34), not shifted by the saved level");
assertFalse(mod("level-mine").playtest());
cfg.enabledMods = new ArrayList<>(List.of("fog", "playtest-mine"));
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Strats;
@@ -13,8 +14,11 @@ import com.eliasmueller.llamamod.format.stpcmerge.StratCode;
import com.eliasmueller.llamamod.format.strat.StratCompiler;
import com.eliasmueller.llamamod.format.strat.StratListing;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.ModOptions;
import com.eliasmueller.llamamod.mods.ModRegistry;
import com.eliasmueller.llamamod.mods.TestModContext;
import com.eliasmueller.llamamod.platform.Config;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -89,11 +93,11 @@ class ArenaTest {
void builtLevel() throws IOException {
Path cd = GameData.cd();
ArenaMod mod = new ArenaMod();
TestModContext ctx = new TestModContext(cd, mod, Map.of("lives", "7", "chapter", "5"), null);
TestModContext ctx = new TestModContext(cd, mod, Map.of("lives", "7", "chapter", "40"), null);
mod.apply(ctx);
byte[] wad = ctx.written.get("wads/T1L9M005.wad");
byte[] wad = ctx.written.get("wads/T1L9M040.wad");
assertNotNull(wad, "written: " + ctx.written.keySet());
assertNotNull(ctx.written.get("T195.COR"));
assertNotNull(ctx.written.get("T1940.COR"));
Level lv = Level.parse(wad);
Map<String, List<MapChunk.Strat>> t = types(lv);
@@ -101,6 +105,9 @@ class ArenaTest {
assertEquals(1, t.get("ArenaMaster").size());
assertEquals(1, t.get("CheckPointPost").size());
assertEquals(2, t.get("Coin").size());
// tally total: 2 placed + 5 alligators · (6 hit coins + 5 death coins) + 5 exit bonus
assertEquals(2 + 5 * (6 + 5) + 5, Bytes.u32(lv.wad().require("LNFO"), 0));
assertEquals(0, Bytes.u32(lv.wad().require("LNFO"), 4));
assertTrue(
!t.containsKey("LevelDoor") && !t.containsKey("ArenaGator"),
t.keySet().toString());
@@ -140,9 +147,9 @@ class ArenaTest {
Path cd = GameData.cd();
ArenaMod mod = new ArenaMod();
TestModContext ctx = new TestModContext(cd, mod, Map.of(), null);
ctx.write("wads/T1L9M008.wad", new byte[] {1, 2, 3});
ctx.write("wads/T1L9M049.wad", new byte[] {1, 2, 3});
IOException e = assertThrows(IOException.class, () -> mod.apply(ctx));
assertTrue(e.getMessage().contains("chapter 8"), e.getMessage());
assertTrue(e.getMessage().contains("chapter 49"), e.getMessage());
}
@Test
@@ -151,9 +158,27 @@ class ArenaTest {
ArenaMod mod = new ArenaMod();
TestModContext ctx = new TestModContext(cd, mod, Map.of(), null);
ArenaMod.apply(ctx, Map.of("probe", "true", "master", "false"));
Level lv = Level.parse(ctx.written.get("wads/T1L9M008.wad"));
Level lv = Level.parse(ctx.written.get("wads/T1L9M049.wad"));
Map<String, List<MapChunk.Strat>> t = types(lv);
assertEquals(1, t.get("ArenaProbe").size());
assertTrue(!t.containsKey("ArenaMaster"), new ArrayList<>(t.keySet()).toString());
}
@Test
void chapterIsUsableOnly() {
ModOption o = new ArenaMod()
.options().stream()
.filter(x -> x.key().equals("chapter"))
.findFirst()
.orElseThrow();
assertEquals("49", o.defaultValue());
assertEquals("32", o.normalize("32"));
for (String bad : List.of("1", "8", "9", "31", "50")) {
assertThrows(IllegalArgumentException.class, () -> o.normalize(bad), bad);
}
// a stored chapter from the old range 2..8 falls back to the default
Config cfg = new Config();
cfg.modOptions.put("arena", Map.of("chapter", "8"));
assertEquals("49", ModOptions.resolve(new ArenaMod(), cfg).get("chapter"));
}
}
@@ -12,7 +12,7 @@ import java.util.Map;
/**
* The arena with level script options for in-game tests (not registered): {@code
* APPTEST_CP=build/classes/java/test APPTEST_JAVA_OPTS=-Dllamamod.extraMods=<this class>}, option
* {@code script} = {@code key=value;key=value} (probe=true, start=x,z,rot).
* {@code script} = {@code key=value;key=value} (probe=true, start=x,z,rot); {@code chapter} 1–49.
*/
public final class ArenaTestMod implements Mod {
@Override
@@ -32,7 +32,14 @@ public final class ArenaTestMod implements Mod {
@Override
public List<ModOption> options() {
List<ModOption> o = new ArrayList<>(new ArenaMod().options());
List<ModOption> o = new ArrayList<>();
for (ModOption x : new ArenaMod().options()) {
// any chapter, to reproduce the per-chapter memory clashes of unusable ones
o.add(
x.key().equals("chapter")
? ModOption.integer("chapter", x.label(), x.description(), ArenaMod.DEFAULT_CHAPTER, 1, 49)
: x);
}
o.add(ModOption.string("script", "Script options", "key=value;…", ""));
return o;
}
@@ -10,10 +10,13 @@ import com.eliasmueller.llamamod.editor.io.ProjectIO;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.History;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModOptions;
import com.eliasmueller.llamamod.mods.TestModContext;
import com.eliasmueller.llamamod.platform.Config;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -38,10 +41,10 @@ class LevelProjectModTest {
ProjectIO.save(edit, dir.resolve("village edit.llamalevel"), cd);
Project a = Project.create(cd, "new", "t1l1m001");
a.scene().settings().prop("name").setRaw(a.scene().settings(), "Level A");
a.scene().settings().prop("targetChapter").setRaw(a.scene().settings(), 3);
a.scene().settings().prop("targetChapter").setRaw(a.scene().settings(), 40);
ProjectIO.save(a, dir.resolve("a.llamalevel"), cd);
Project b = Project.create(cd, "new", "t1l1m001");
b.scene().settings().prop("targetChapter").setRaw(b.scene().settings(), 3);
b.scene().settings().prop("targetChapter").setRaw(b.scene().settings(), 40);
ProjectIO.save(b, dir.resolve("b.llamalevel"), cd);
Files.writeString(dir.resolve("broken.llamalevel"), "no zip");
@@ -52,17 +55,20 @@ class LevelProjectModTest {
LevelProjectMod ma = (LevelProjectMod) mods.get(0);
LevelProjectMod mb = (LevelProjectMod) mods.get(1);
LevelProjectMod me = (LevelProjectMod) mods.get(2);
assertEquals("3", ma.options().getFirst().defaultValue());
assertEquals("2", mb.options().getFirst().defaultValue(), "chapter 3 is taken: next free");
assertEquals("40", ma.options().getFirst().defaultValue());
assertEquals("32", mb.options().getFirst().defaultValue(), "chapter 40 is taken: first free usable");
assertTrue(me.options().isEmpty());
assertEquals(Mod.LEVELS, me.category());
TestModContext ctx = new TestModContext(cdDir, ma, Map.of(), null);
ma.apply(ctx);
assertTrue(ctx.written.containsKey("wads/T1L9M003.wad"));
assertTrue(ctx.written.containsKey("T193.COR"));
Level lv = Level.parse(ctx.written.get("wads/T1L9M003.wad"));
assertTrue(ctx.written.containsKey("wads/T1L9M040.wad"));
assertTrue(ctx.written.containsKey("T1940.COR"));
Level lv = Level.parse(ctx.written.get("wads/T1L9M040.wad"));
assertEquals(0, lv.map().strats.size());
byte[] lnfo = lv.wad().get("LNFO");
assertEquals(0, Bytes.u32(lnfo, 0), "tally: no coins (not the base's 55)");
assertEquals(0, Bytes.u32(lnfo, 4), "tally: no secrets");
TestModContext ec = new TestModContext(cdDir, me, Map.of(), null);
me.apply(ec);
@@ -78,7 +84,7 @@ class LevelProjectModTest {
IOException e = assertThrows(IOException.class, () -> me.apply(twice));
assertTrue(e.getMessage().contains("already changed"), e.getMessage());
TestModContext both = new TestModContext(cdDir, ma, Map.of(), null);
both.written.put("wads/T1L9M003.wad", new byte[] {1});
both.written.put("wads/T1L9M040.wad", new byte[] {1});
assertThrows(IOException.class, () -> ma.apply(both));
}
@@ -100,4 +106,40 @@ class LevelProjectModTest {
assertTrue(!v1.equals(v2), "cache key must change with the project file");
assertEquals(new LevelProjectSource(dir).mods().getFirst().version(), v2);
}
@Test
void oldChaptersMoveToUsableOnes(@TempDir Path dir) throws IOException {
Bases cd = Bases.fromCd(GameData.cd());
for (String[] f : new String[][] {{"a", "5"}, {"b", "9"}, {"c", "1"}, {"d", "33"}}) {
Project p = Project.create(cd, "new", "t1l1m001");
p.scene().settings().prop("targetChapter").setRaw(p.scene().settings(), Integer.parseInt(f[1]));
ProjectIO.save(p, dir.resolve(f[0] + ".llamalevel"), cd);
}
List<Mod> mods = new LevelProjectSource(dir).mods();
assertEquals(
List.of("32", "34", "1", "33"),
mods.stream().map(m -> m.options().getFirst().defaultValue()).toList(),
"2..31 → first free of 32..49; 1 and 33 stay");
// a chapter stored in the launcher config by an older version falls back to the default
Config cfg = new Config();
cfg.modOptions.put("level-a", Map.of("chapter", "5"));
assertEquals("32", ModOptions.resolve(mods.getFirst(), cfg).get("chapter"));
cfg.modOptions.put("level-a", Map.of("chapter", "45"));
assertEquals("45", ModOptions.resolve(mods.getFirst(), cfg).get("chapter"));
}
@Test
void moreProjectsThanChaptersShareThem(@TempDir Path dir) throws IOException {
Bases cd = Bases.fromCd(GameData.cd());
Project p = Project.create(cd, "new", "t1l1m001");
p.scene().settings().prop("targetChapter").setRaw(p.scene().settings(), 40);
for (int i = 0; i < 20; i++) {
ProjectIO.save(p, dir.resolve(String.format("p%02d.llamalevel", i)), cd);
}
List<String> ch = new LevelProjectSource(dir)
.mods().stream().map(m -> m.options().getFirst().defaultValue()).toList();
assertEquals("40", ch.get(0));
assertEquals(18, ch.stream().distinct().count(), ch.toString());
assertTrue(ch.stream().allMatch(c -> Integer.parseInt(c) >= 32), ch.toString());
}
}
@@ -3,10 +3,13 @@ package com.eliasmueller.llamamod.mods.testworld;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.levelbuild.LevelScript;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.levels.LevelTally;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -135,7 +138,10 @@ class TestWorldGoldenTest {
public void progress(double fraction, String message) {}
});
assertEquals(List.of("T191.COR", "wads/T1L9M001.wad"), List.copyOf(written.keySet()));
assertEquals(TEST_WORLD_SHA, sha256(written.get("wads/T1L9M001.wad")));
// the mod only recounts the tally (LNFO: 20 coins, no secrets instead of the Village's 55, 2)
byte[] wad = written.get("wads/T1L9M001.wad");
assertEquals(20, Bytes.u32(Level.parse(wad).wad().require("LNFO"), 0));
assertEquals(TEST_WORLD_SHA, sha256(LevelTally.withTotals(wad, 55, 2)));
assertEquals(
"c55e3babf4855540f14daa60b979a2534e3732037854d9ad785f69f370d095d0", sha256(written.get("T191.COR")));
}
@@ -0,0 +1,35 @@
package com.eliasmueller.llamamod.mods.testworld;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Strats;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.TestModContext;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
/** The tally of the Test World counts its own coins, not the Village base's 55. */
class TestWorldTallyTest {
@Test
void lnfoCountsPlacedCoins() throws IOException {
TestWorldMod mod = new TestWorldMod();
TestModContext ctx = new TestModContext(GameData.cd(), mod, Map.of(), null);
mod.apply(ctx);
Level lv = Level.parse(ctx.written.get(TestWorldMod.OUT_WAD));
Map<String, List<MapChunk.Strat>> t = Strats.levelTypes(lv.wad().require("STPC"), lv.wfpc(), lv.map());
int coins = t.getOrDefault("Coin", List.of()).size()
+ 5 * t.getOrDefault("Coin5", List.of()).size();
byte[] lnfo = lv.wad().require("LNFO");
System.out.println(
"TALLY test world " + Bytes.u32(lnfo, 0) + "/" + Bytes.u32(lnfo, 4) + " types " + t.keySet());
assertTrue(coins > 0);
assertEquals(coins, Bytes.u32(lnfo, 0));
assertEquals(t.getOrDefault("Secret", List.of()).size(), Bytes.u32(lnfo, 4));
}
}
@@ -0,0 +1,57 @@
package com.eliasmueller.llamamod.save;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
class TestLevelChaptersTest {
/** Coin slot 0x584F1C + 4·(54 + c) and flag byte 0x584EC8 + 54 + c of world 9 (see the class comment). */
@Test
void usableChaptersAvoidTheGlobalsBehindTheArrays() {
for (int c = 0; c <= 50; c++) {
int coin = 0x584F1C + 4 * (9 * 6 + c);
int flag = 0x584EC8 + 9 * 6 + c;
boolean coinFree = c == 1
|| coin >= 0x58501C && coin < 0x585024
|| coin >= 0x585028 && coin < 0x58504C
|| coin >= 0x585074 && coin <= 0x5850BB;
boolean flagFree = flag < 0x584F04 || flag >= 0x584F14 && flag < 0x584F18 || flag >= 0x584F1C;
assertEquals(coinFree && flagFree && c >= 1 && c <= 49, TestLevelChapters.usable(c), "chapter " + c);
}
assertEquals(19, TestLevelChapters.all().size());
assertEquals(0x585018, 0x584F1C + 4 * (9 * 6 + 9), "chapter 9 = furthest progress");
}
@Test
void migrate() {
assertEquals(1, TestLevelChapters.migrate(1));
assertEquals(40, TestLevelChapters.migrate(40));
assertEquals(32, TestLevelChapters.migrate(2));
assertEquals(38, TestLevelChapters.migrate(8));
assertEquals(49, TestLevelChapters.migrate(19));
assertEquals(32, TestLevelChapters.migrate(20));
assertEquals(43, TestLevelChapters.migrate(31));
assertEquals(32, TestLevelChapters.migrate(0));
assertEquals(32, TestLevelChapters.migrate(60));
for (int c = -1; c < 60; c++) {
assertTrue(TestLevelChapters.usable(TestLevelChapters.migrate(c)));
}
}
@Test
void pick() {
assertEquals(40, TestLevelChapters.pick(40, Set.of()));
assertEquals(1, TestLevelChapters.pick(1, Set.of(32)));
assertEquals(33, TestLevelChapters.pick(40, Set.of(32, 40)));
assertEquals(32, TestLevelChapters.pick(5, Set.of()));
Set<Integer> full = new HashSet<>(TestLevelChapters.all());
assertEquals(38, TestLevelChapters.pick(8, full), "all taken: shared");
assertEquals(45, TestLevelChapters.pick(45, full));
assertFalse(List.of(1).contains(TestLevelChapters.pick(1, Set.of(1))));
}
}