diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2015a8b --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/docs/manual/01-introduction.md b/docs/manual/01-introduction.md index 83b4137..10b7296 100644 --- a/docs/manual/01-introduction.md +++ b/docs/manual/01-introduction.md @@ -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*". diff --git a/docs/manual/03-editor-window.md b/docs/manual/03-editor-window.md index dd4270c..ffeb280 100644 --- a/docs/manual/03-editor-window.md +++ b/docs/manual/03-editor-window.md @@ -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 diff --git a/docs/manual/14-level-settings.md b/docs/manual/14-level-settings.md index 7c4af4e..887bdf3 100644 --- a/docs/manual/14-level-settings.md +++ b/docs/manual/14-level-settings.md @@ -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 diff --git a/docs/manual/17-sharing.md b/docs/manual/17-sharing.md index a87058d..ea80b89 100644 --- a/docs/manual/17-sharing.md +++ b/docs/manual/17-sharing.md @@ -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 diff --git a/docs/manual/18-mods.md b/docs/manual/18-mods.md index 198c9ef..9ddb0ff 100644 --- a/docs/manual/18-mods.md +++ b/docs/manual/18-mods.md @@ -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 diff --git a/notes/img/mods/testlevel_ch49_tally.jpg b/notes/img/mods/testlevel_ch49_tally.jpg new file mode 100644 index 0000000..d1dcb9a Binary files /dev/null and b/notes/img/mods/testlevel_ch49_tally.jpg differ diff --git a/notes/img/mods/testlevel_ch9_map_broken.jpg b/notes/img/mods/testlevel_ch9_map_broken.jpg new file mode 100644 index 0000000..147eee3 Binary files /dev/null and b/notes/img/mods/testlevel_ch9_map_broken.jpg differ diff --git a/notes/img/mods/testlevel_chapter_option.jpg b/notes/img/mods/testlevel_chapter_option.jpg new file mode 100644 index 0000000..64fe74e Binary files /dev/null and b/notes/img/mods/testlevel_chapter_option.jpg differ diff --git a/notes/levelbau.md b/notes/levelbau.md index 2045b5c..6c60b79 100644 --- a/notes/levelbau.md +++ b/notes/levelbau.md @@ -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. diff --git a/notes/levelbau.md.orig b/notes/levelbau.md.orig new file mode 100644 index 0000000..6f7059d --- /dev/null +++ b/notes/levelbau.md.orig @@ -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. diff --git a/notes/llamamod-app.md b/notes/llamamod-app.md index 1d96ad8..ab8e7ed 100644 --- a/notes/llamamod-app.md +++ b/notes/llamamod-app.md @@ -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) diff --git a/notes/mod-arena.md b/notes/mod-arena.md index b074e49..cc5ca7b 100644 --- a/notes/mod-arena.md +++ b/notes/mod-arena.md @@ -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). diff --git a/notes/strat-opcodes.md b/notes/strat-opcodes.md index 0fd3d0e..939ffd7 100644 --- a/notes/strat-opcodes.md +++ b/notes/strat-opcodes.md @@ -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` (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). | diff --git a/notes/testlevel-chapters.md b/notes/testlevel-chapters.md new file mode 100644 index 0000000..4865139 --- /dev/null +++ b/notes/testlevel-chapters.md @@ -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. diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/LevelSettingsDialog.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/LevelSettingsDialog.java index 2988e8f..00612b2 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/LevelSettingsDialog.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/LevelSettingsDialog.java @@ -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()); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestRunner.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestRunner.java index 6584c26..c56d911 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestRunner.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestRunner.java @@ -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()) { diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/NewLevelDialog.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/NewLevelDialog.java index 9723cb6..3e56d91 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/NewLevelDialog.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/NewLevelDialog.java @@ -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 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() { diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/ProjectController.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/ProjectController.java index c737fec..44e454c 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/ProjectController.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/ProjectController.java @@ -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) { diff --git a/src/main/java/com/eliasmueller/llamamod/format/stpcmerge/TextMerge.java b/src/main/java/com/eliasmueller/llamamod/format/stpcmerge/TextMerge.java index 1842644..907bf99 100644 --- a/src/main/java/com/eliasmueller/llamamod/format/stpcmerge/TextMerge.java +++ b/src/main/java/com/eliasmueller/llamamod/format/stpcmerge/TextMerge.java @@ -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) {} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaImport.java b/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaImport.java index ec233e7..6014edd 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaImport.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaImport.java @@ -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 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); } diff --git a/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaMod.java b/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaMod.java index c11f3cb..7c5030c 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaMod.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaMod.java @@ -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"); diff --git a/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectMod.java b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectMod.java index fdd349d..ab3aed1 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectMod.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectMod.java @@ -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"); diff --git a/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectSource.java b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectSource.java index d46a51f..95f6d5b 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectSource.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectSource.java @@ -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 /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 mods() { - List out = new ArrayList<>(); - Set used = new HashSet<>(); List levels = LevelProjects.list(dir); List tests = LevelProjects.list(LevelProjects.playtestDir(dir)); Set bases = new HashSet<>(); levels.forEach(p -> bases.add(LevelProjectMod.baseId(p, false))); tests.forEach(p -> bases.add(LevelProjectMod.baseId(p, true))); - Set ids = new HashSet<>(); - for (Path p : levels) { - add(p, false, used, bases, ids, out); + List 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 chapters = new HashMap<>(); + Set 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 out = new ArrayList<>(); + Set 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 used, Set bases, Set ids, List 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 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)); } } diff --git a/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelTally.java b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelTally.java new file mode 100644 index 0000000..bf5be0c --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelTally.java @@ -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> 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> t, String type) { + List 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(); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/testworld/Gallery.java b/src/main/java/com/eliasmueller/llamamod/mods/testworld/Gallery.java index 44e5590..63cedeb 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/testworld/Gallery.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/testworld/Gallery.java @@ -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 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(); - } } diff --git a/src/main/java/com/eliasmueller/llamamod/mods/testworld/TestWorldMod.java b/src/main/java/com/eliasmueller/llamamod/mods/testworld/TestWorldMod.java index c900b22..9273c8a 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/testworld/TestWorldMod.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/testworld/TestWorldMod.java @@ -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"); diff --git a/src/main/java/com/eliasmueller/llamamod/save/TestLevelChapters.java b/src/main/java/com/eliasmueller/llamamod/save/TestLevelChapters.java new file mode 100644 index 0000000..6d2585f --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/save/TestLevelChapters.java @@ -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: + * + *
+ * 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).
+ * 
+ * + * 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 all() { + List 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 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); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestLifecycleTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestLifecycleTest.java index 4372807..6fb1a1d 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestLifecycleTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestLifecycleTest.java @@ -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")); diff --git a/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTest.java b/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTest.java index 1a68ae9..dca9139 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTest.java @@ -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> 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> 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")); + } } diff --git a/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTestMod.java b/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTestMod.java index 8ff7379..56ea9fd 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTestMod.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTestMod.java @@ -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=}, 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 options() { - List o = new ArrayList<>(new ArenaMod().options()); + List 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; } diff --git a/src/test/java/com/eliasmueller/llamamod/mods/levels/LevelProjectModTest.java b/src/test/java/com/eliasmueller/llamamod/mods/levels/LevelProjectModTest.java index c39c248..58dd55a 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/levels/LevelProjectModTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/levels/LevelProjectModTest.java @@ -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 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 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()); + } } diff --git a/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldGoldenTest.java b/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldGoldenTest.java index 2f995cc..e3a984a 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldGoldenTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldGoldenTest.java @@ -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"))); } diff --git a/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldTallyTest.java b/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldTallyTest.java new file mode 100644 index 0000000..0f439ae --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldTallyTest.java @@ -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> 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)); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/save/TestLevelChaptersTest.java b/src/test/java/com/eliasmueller/llamamod/save/TestLevelChaptersTest.java new file mode 100644 index 0000000..77ca3bd --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/save/TestLevelChaptersTest.java @@ -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 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)))); + } +}