base exe patch moves the per-chapter coin and flag arrays into free .data space (128 entries), so every testlevel chapter is safe (revision 2, caches rebuilt once); new projects start on a free chapter from 32, manual shows number ranges, texture swatch caches follow texture edits, stray levelbau.md.orig removed

This commit is contained in:
MineTec committed 2026-11-07 15:52:00 +01:00
1 parent d6b852a1b6
commit 00b2880d67
24 files changed
+376 -364

No files matched your search

+2
View File
@@ -36,6 +36,8 @@ There are two downloads: **full** (launcher + level editor) and **launcher** (mo
- **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 game itself is fixed too: picking an empty TestLevel chapter on the world map (or in
Boot to level) no longer damages your progress.
- 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**
+1 -1
View File
@@ -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 | 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). |
| TestLevel chapter | 32 – 49 | 49 | Chapter of world 9 the arena replaces (32–49; chapter 1 is the Test World's). |
#### Test World
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

-306
View File
@@ -1,306 +0,0 @@
# 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.
+6 -3
View File
@@ -121,7 +121,8 @@ Each contains `68 90 E0 56 00` (push offset subkey string, VA 0x56E090) followed
byte `02` of each `push 80000002h` becomes `01` (HKEY_CURRENT_USER), so no admin rights are needed
on Windows. German CD (`groove.exe` MD5 697d67a31c20079838c662d8187dbd86, 1,642,496 bytes),
file offsets of the changed bytes: **0x579B, 0x57D0, 0x5810, 0x5859, 0x5880** (original 02,
new 01; patched MD5 6199a42eb7582b2a70b1ac6e29f00f43). The patcher searches the pattern
new 01; MD5 with only this part 6199a42eb7582b2a70b1ac6e29f00f43; the base patch also relocates the
world-9 level records, see "groove.exe patches" below). The patcher searches the pattern
(push of the subkey string VA + push HKLM) instead of fixed offsets, so other language builds work
if their code is the same.
@@ -222,8 +223,10 @@ reads a CD folder, collects writes in `ctx.written`, resolves options (defaults
`game/groove.exe` is rebuilt on every apply: `original/hd/groove.exe` → HKCU base patch
(`platform/ExePatcher`, owner "base", always first) → exe changes of the enabled mods in apply
order. Without exe mods the result is byte-identical to the old ExePatcher output; "Restore
original" goes back to it. A mod gets the shared image with `ctx.exe()`; all changes are
order. Without exe mods the result is byte-identical to `ExePatcher.patch` (installer output);
"Restore original" goes back to it. The base patch (REVISION 2) = HKCU + `relocateLevelRecords`
(world-9 coin/flag arrays moved into .data's page slack, 4-byte address operands only, see
testlevel-chapters.md); `ExePatcher.changes` lists both as byte runs. A mod gets the shared image with `ctx.exe()`; all changes are
attributed to the mod id, journaled into the mod's cache manifest (`"exe": [...]`) and replayed
(with full verification) on a cache hit. Every patch/allocation is logged in apply.log
(`exe <mod>: patch 0x… n bytes – label`).
+49 -5
View File
@@ -64,12 +64,56 @@ secrets); the arena adds its dynamic coins (alligator hit/death coins + 5 exit b
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.
* Editor: New level / Level settings spinners only offer usable chapters. The empty template
(`Templates.makeEmpty`) defaults to 32; New level and CLI `--editor-new` take the first chapter
from 32 no project in the levels folder uses (`LevelProjects.nextFreeChapter`).
![chapter option](img/mods/testlevel_chapter_option.jpg)
## Not fixed
## Fix: base exe patch relocates both arrays (ExePatcher.REVISION 2, 2026-11-07)
* 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.
`ExePatcher.relocateLevelRecords` (part of the always-applied base patch: `ExePatcher.patch` for
the installer, `ExePatcher.changes` replayed as owner "base" in `ModManager.baseExe`) moves both
arrays to a 128-entry buffer (index ≤ 9·6 + 49 = 103) in the slack after .data's VirtualSize:
.data ends at 0x6DB935, the page at 0x6DC000 (.idata follows), so coins u32[128] at **0x6DB940**,
flags u8[128] at **0x6DBB40** (zero-filled and RW, mapped with .data's last page; no new section,
no code). Every absolute address of the arrays in .text is rewritten (whole-file scan of
0x584EC8–0x584EFE / 0x584F1C–0x584FF4: no other references, none in .data):
| Old | New | Sites (operand VA) |
|---|---|---|
| 0x584F1C coins | 0x6DB940 | tally 0x40DE67, KSV load 0x41C72A / save 0x41C8FB, new game 0x41CA6B, level start 0x41CBCC, HUD 0x426AC2 / 0x426D43 / 0x426E08, endLevel 0x54D6BA, levelRecord ptr 0x550BC5, 0x55223E |
| 0x584FF4 coin loop end (`cmp eax, imm; jl`) | 0x6DBA18 | 0x41C74B, 0x41C91A (0x584FF4 itself is also counter var 0xC0 – those uses stay) |
| 0x584EC8 flags | 0x6DBB40 | tally 0x40DEA1 / 0x40DEC9, KSV 0x41C65C / 0x41C832, new game 0x41CA64 (rep stosd 54 bytes), collectible 0x42706B, endLevel 0x54D6E3 / 0x54D708 / 0x54D70F |
| 0x584ECE flags from world 1 | 0x6DBB46 | map icons 0x41B724 / 0x41B79C / 0x41B7EE / 0x54A75E, gold count 0x41CD7B |
| 0x584EFE flag loop end | 0x6DBB76 | KSV 0x41C6D6 / 0x41C8AF, gold count 0x41CD9B |
Worlds 0–8 behave as before (KSV keeps the first 54 entries; the rest is never saved). World-9
flags are not cleared by "new game" (only the 54 saved bytes are) – they only affect the map's
collectible/gold icons of TestLevel chapters within one game session. If the counts of these
references differ (other exe build) the relocation is skipped as a whole. Test: `ExePatcherTest`.
**Effect:** every world-9 chapter is harmless in LlamaMod's groove.exe, so the remaining holes are
closed without restricting the map: picking an empty (linked) chapter like 9 on the world map,
chapters 2–31 that unlock together with 32+, and Boot to level on chapters 2–31 (its option keeps
1–49). Not linking empty chapters is no option: a missing WAD crashes the map ("Couldnt load wad",
`ModManager.world9Fixup`). Mod levels still only use 1 and 32–49 (no migration churn; also safe
with an exe that has not been rebuilt yet after the update).
Verified in game (apptest :24, 2026-11-07):
* A/B, Test World on, map cursor world 9 chapter 9 (the linked empty chapter), start, pause →
quit to the map: old jar (base REVISION 1) → map "°p / Kapitel 0 / 14384" (progress zeroed by the
level start alone); new → "TestLevel / Kapitel 9 / 237" (237 = garbage static coin total, cosmetic).
![chapter 9 before/after](img/mods/testlevel_ch9_fixed.jpg)
* Arena (test mod) on chapter 9, 2 coins collected (HUD 2 / 12), quit to the map → map fine.
* Save with world-1 chapter 1 flags = 3 and gold count 2: slot menu "Goldmünzen 2", the map shows
the collectible and gold icons of Village chapter 1 (KSV load loop + map reads relocated).
* Not verified in game: the KSV save loop after finishing a level (same patch shape as the load
loop, covered by the unit test).
## Before the fix
* The map let 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 clobbered progress. Unlocking
chapter 32+ unlocks 2–31 on the map too. Fixed by the relocation above.
@@ -6,6 +6,7 @@ import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
@@ -45,7 +46,7 @@ public final class Templates {
LevelSettings st = s.settings();
st.setRaw("kind", LevelSettings.NEW);
st.setRaw("name", "New level");
st.setRaw("targetChapter", 1);
st.setRaw("targetChapter", TestLevelChapters.FIRST);
st.setRaw("poolReserve", NEW_LEVEL_RESERVE);
int[] table = s.regions().table().clone();
table[0] = 0xffffffff;
@@ -1,6 +1,8 @@
package com.eliasmueller.llamamod.editor.tools.geometry;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.SceneEvent;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.render.Legend;
@@ -21,10 +23,12 @@ import java.awt.Image;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.function.Consumer;
import java.util.function.IntConsumer;
@@ -148,16 +152,32 @@ final class GeoWidgets {
private static final Map<Scene, Map<String, Integer>> NAMES = new WeakHashMap<>();
/** Scenes whose texture/page edits clear their cache entries (listener added on first use). */
private static final Set<Scene> WATCHED = Collections.newSetFromMap(new WeakHashMap<>());
static void clearCache(Scene s) {
CACHE.remove(s);
NAMES.remove(s);
}
private static void watch(Scene s) {
if (WATCHED.add(s)) {
s.addListener(e -> {
if (e.type() == SceneEvent.Type.RELOADED
|| e.node().kind() == NodeKind.TEXTURE
|| e.node().kind() == NodeKind.TEXTURE_PAGE) {
clearCache(s);
}
});
}
}
/** Village texture names the scene's textures show (name → index), see {@link BaseTextures}. */
static Map<String, Integer> names(Scene s) {
if (s == null) {
return LevelBuilder.TEX;
}
watch(s);
return NAMES.computeIfAbsent(s, k -> {
List<TextChunk.TextureEntry> e = new ArrayList<>();
for (TextureEntry t : k.textures()) {
@@ -203,6 +223,7 @@ final class GeoWidgets {
if (s == null || tex == null || tex.isEmpty()) {
return null;
}
watch(s);
Map<String, BufferedImage> m = CACHE.computeIfAbsent(s, k -> new LinkedHashMap<>());
if (m.containsKey(tex)) {
return m.get(tex);
@@ -337,21 +337,36 @@ public final class ManualContent {
return switch (o.type()) {
case BOOL -> "on / off";
case INT -> o.min() + " – " + o.max();
case CHOICE -> {
List<String> c = new ArrayList<>();
o.choices().forEach(ch -> c.add(ch.label()));
yield String.join(", ", c);
}
case MULTI -> {
List<String> c = new ArrayList<>();
o.choices().forEach(ch -> c.add(ch.label()));
yield "any of " + String.join(", ", c);
}
case CHOICE -> choiceList(o);
case MULTI -> "any of " + choiceList(o);
case SEED -> "any text";
case STRING -> "text";
};
}
/** Choice labels, comma-separated; runs of three or more consecutive numbers as "a – b". */
static String choiceList(ModOption o) {
List<String> labels = o.choices().stream().map(ModOption.Choice::label).toList();
List<String> out = new ArrayList<>();
for (int i = 0; i < labels.size(); ) {
int j = i;
while (j + 1 < labels.size() && consecutive(labels.get(j), labels.get(j + 1))) {
j++;
}
if (j - i >= 2) {
out.add(labels.get(i) + " – " + labels.get(j));
} else {
out.addAll(labels.subList(i, j + 1));
}
i = j + 1;
}
return String.join(", ", out);
}
private static boolean consecutive(String a, String b) {
return a.matches("-?\\d{1,9}") && b.matches("-?\\d{1,9}") && Integer.parseInt(b) == Integer.parseInt(a) + 1;
}
private static String defaultText(ModOption o) {
return switch (o.type()) {
case BOOL -> "true".equals(o.defaultValue()) ? "on" : "off";
@@ -1,7 +1,5 @@
package com.eliasmueller.llamamod.editor.ui;
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;
@@ -13,11 +11,8 @@ import java.awt.Font;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
@@ -53,7 +48,7 @@ final class NewLevelDialog extends JDialog {
}
}
JSpinner chapter = new JSpinner(new SpinnerListModel(TestLevelChapters.all()));
chapter.setValue(nextFreeChapter(w));
chapter.setValue(LevelProjects.nextFreeChapter(w.env().levelsDir()));
PicturePanel pic = new PicturePanel(300, 170);
Runnable updatePic = () -> {
OriginalLevels.Level l = (OriginalLevels.Level) base.getSelectedItem();
@@ -164,22 +159,6 @@ final class NewLevelDialog extends JDialog {
return row + 1;
}
/** First usable TestLevel chapter from 32 that no project in the levels folder uses. */
static int nextFreeChapter(EditorWindow w) {
Set<Integer> used = new HashSet<>();
for (Path p : LevelProjects.list(w.env().levelsDir())) {
try {
ProjectInfo i = ProjectIO.info(Files.readAllBytes(p));
if (i.isNewLevel()) {
used.add(i.targetChapter());
}
} catch (Exception e) {
// skip broken files
}
}
return TestLevelChapters.pick(TestLevelChapters.FIRST, used);
}
Result result() {
return result;
}
@@ -554,7 +554,7 @@ public final class ModManager {
}
}
/** original/hd/groove.exe with the HKCU patch, or null if there is no original exe (tests). */
/** original/hd/groove.exe with the base patch ({@link ExePatcher#changes}), or null if there is no original exe (tests). */
private ExeImage baseExe(ApplyLog log) throws IOException {
Path orig = Files.isDirectory(paths.originalHd()) ? Integrity.find(paths.originalHd(), "groove.exe") : null;
if (orig == null || !Files.isRegularFile(orig)) {
@@ -563,8 +563,8 @@ public final class ModManager {
byte[] b = Files.readAllBytes(orig);
ExeImage img = ExeImage.parse(b);
img.setOwner(BASE_EXE_OWNER);
for (int off : ExePatcher.patchHkcu(b).patchedOffsets()) {
img.patch(img.va(off), new byte[] {2}, new byte[] {1}, "registry HKLM -> HKCU");
for (ExePatcher.Change c : ExePatcher.changes(b)) {
img.patch(img.va(c.offset()), c.original(), c.replacement(), c.label());
}
logExe(log, img, BASE_EXE_OWNER);
return img;
@@ -83,6 +83,10 @@ public final class EditorCli {
need(rest, 1, "--editor-new <project.llamalevel> [base level]");
String base = rest.size() > 1 ? rest.get(1) : Templates.DEFAULT_BASE;
Project p = Project.create(cd, "new", base);
p.scene()
.settings()
.prop("targetChapter")
.setRaw(p.scene().settings(), LevelProjects.nextFreeChapter(LevelProjects.dir(paths)));
if (name != null) {
p.scene().settings().prop("name").setRaw(p.scene().settings(), name);
}
@@ -6,6 +6,7 @@ import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModRegistry;
import com.eliasmueller.llamamod.platform.AppPaths;
import com.eliasmueller.llamamod.platform.Config;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -13,8 +14,10 @@ import java.nio.file.StandardCopyOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Stream;
/**
@@ -138,6 +141,25 @@ public final class LevelProjects {
return new Removed(out, cfg.activeMods.contains(mod.id()));
}
/**
* First usable TestLevel chapter from 32 that no new-level project in {@code dir} uses (chapter 1
* belongs to the Test World mod).
*/
public static int nextFreeChapter(Path dir) {
Set<Integer> used = new HashSet<>();
for (Path p : list(dir)) {
try {
ProjectInfo i = ProjectIO.info(Files.readAllBytes(p));
if (i.isNewLevel()) {
used.add(i.targetChapter());
}
} catch (IOException | RuntimeException e) {
// skip broken files
}
}
return TestLevelChapters.pick(TestLevelChapters.FIRST, used);
}
/** Project files in the folder, sorted by name. */
public static List<Path> list(Path dir) {
List<Path> out = new ArrayList<>();
@@ -1,5 +1,6 @@
package com.eliasmueller.llamamod.platform;
import com.eliasmueller.llamamod.exe.ExeImage;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
@@ -16,17 +17,107 @@ import java.util.List;
* (HKLM to HKCU) in those five instructions. Located by pattern rather than fixed offsets so other
* language versions work too; German CD: file offsets 0x579b, 0x57d0, 0x5810, 0x5859, 0x5880
* (the byte after 68).
*
* <p>Second part ({@link #relocateLevelRecords}): the per-chapter coin counters (u32 0x584F1C) and
* flags (u8 0x584EC8), indexed world·6 + chapter, only have rows for worlds 0–8; TestLevel (world 9,
* chapters up to 49) wrote over progress, language and options. Every reference is moved to a
* 128-entry copy in the unused tail of .data's last page (notes/testlevel-chapters.md).
*/
public final class ExePatcher {
private ExePatcher() {}
/** Revision of the base patch; part of the mod cache key, bump when the patch changes. */
public static final int REVISION = 1;
public static final int REVISION = 2;
public static final String SUBKEY = "Software\\Disney Interactive\\Emperors New Groove\\1.0";
public record Result(byte[] data, List<Integer> patchedOffsets, int alreadyPatched) {}
/** One changed byte run of the base patch (file offset). */
public record Change(int offset, byte[] original, byte[] replacement, String label) {}
static final long COINS = 0x584F1C;
static final long FLAGS = 0x584EC8;
static final int ROWS = 54; // worlds 0-8 x 6
static final int SLOTS = 128; // world 9 chapter 49 = index 103
/** The complete base patch: HKCU registry plus the level record relocation. */
public static byte[] patch(byte[] exe) throws IOException {
byte[] out = patchHkcu(exe).data();
for (Change c : relocateLevelRecords(out)) {
System.arraycopy(c.replacement(), 0, out, c.offset(), c.replacement().length);
}
return out;
}
/** All byte changes of {@link #patch} relative to {@code exe}, in file order. */
public static List<Change> changes(byte[] exe) throws IOException {
List<Change> l = new ArrayList<>();
for (int off : patchHkcu(exe).patchedOffsets()) {
l.add(new Change(off, new byte[] {2}, new byte[] {1}, "registry HKLM -> HKCU"));
}
l.addAll(relocateLevelRecords(exe));
return l;
}
/**
* Moves the coin and flag arrays to a buffer in the slack after .data's VirtualSize (zeroed and
* mapped read/write by the loader as part of the section's last page). Every absolute address of
* both arrays in .text is rewritten: bases 0x584F1C / 0x584EC8, the world-1 flag base 0x584ECE
* (selection map, gold count), the flag loop end 0x584EFE and the coin loop end 0x584FF4 (KSV
* load/save; matched as {@code cmp eax, imm32; jl}, the address is also a separate counter).
* Returns nothing if the exe is already relocated or the references differ from the known build.
*/
public static List<Change> relocateLevelRecords(byte[] exe) throws IOException {
ExeImage img = ExeImage.parse(exe);
ExeImage.Section data;
try {
data = img.section(".data");
} catch (IllegalArgumentException e) {
return List.of();
}
long end = data.va() + data.virtualSize();
long coins = (end + 15) & ~15L;
long flags = coins + 4L * SLOTS;
if (data.virtualSize() == 0 || flags + SLOTS > ((end + 0xfff) & ~0xfffL)) {
return List.of();
}
List<Change> l = new ArrayList<>();
boolean ok = retarget(img, l, "%s", COINS, coins, 11, "coins")
&& retarget(img, l, "%s", FLAGS, flags, 9, "flags")
&& retarget(img, l, "%s", FLAGS + 6, flags + 6, 5, "flags from world 1")
&& retarget(img, l, "%s", FLAGS + ROWS, flags + ROWS, 3, "flags end")
&& retarget(img, l, "3D %s 7C", COINS + 4 * ROWS, coins + 4 * ROWS, 2, "coins end");
if (!ok) {
return List.of();
}
l.sort((x, y) -> Integer.compare(x.offset(), y.offset()));
return l;
}
/** {@code pattern}: hex with {@code %s} = the address operand; all hits must be {@code expected}. */
private static boolean retarget(
ExeImage img, List<Change> out, String pattern, long from, long to, int expected, String label) {
List<Long> hits = img.find(".text", pattern.formatted(hex32(from)));
if (hits.size() != expected) {
return false;
}
int skip = pattern.indexOf("%s") / 3;
for (long va : hits) {
out.add(new Change(img.fileOffset(va + skip), le32(from), le32(to), "level records: " + label));
}
return true;
}
private static String hex32(long v) {
byte[] b = le32(v);
return String.format("%02X %02X %02X %02X", b[0], b[1], b[2], b[3]);
}
private static byte[] le32(long v) {
return new byte[] {(byte) v, (byte) (v >> 8), (byte) (v >> 16), (byte) (v >> 24)};
}
public static Result patchHkcu(byte[] exe) throws IOException {
byte[] out = exe.clone();
long va = stringVa(exe, SUBKEY);
@@ -194,7 +194,7 @@ public final class Installer {
}
} else if (name.equals("groove.exe")) {
byte[] orig = Files.readAllBytes(f);
FileOps.writeAtomic(t, ExePatcher.patchHkcu(orig).data());
FileOps.writeAtomic(t, ExePatcher.patch(orig));
cfg.exeSha256 = FileOps.sha256(orig);
} else {
Files.createDirectories(t.getParent());
@@ -20,7 +20,10 @@ import java.util.List;
* 30..49 coin slots of world 0 (never read).
* </pre>
*
* Safe: chapter 1 (Test World) and 32–49.
* Safe: chapter 1 (Test World) and 32–49. Since base patch revision 2 ({@code
* ExePatcher.relocateLevelRecords}) LlamaMod's groove.exe keeps both arrays in a buffer big enough
* for world 9, so the other chapters (map, Boot to level) are harmless too; mod levels still stay on
* these chapters.
*/
public final class TestLevelChapters {
private TestLevelChapters() {}
@@ -0,0 +1,45 @@
package com.eliasmueller.llamamod.editor.tools.geometry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import java.awt.image.BufferedImage;
import org.junit.jupiter.api.Test;
/** Swatch and name caches follow texture and page edits of an open scene. */
class GeoWidgetsCacheTest {
@Test
void textureAndPageEditsRefreshTheSwatch() {
Scene s = new Scene();
TexturePage p = (TexturePage) s.create(NodeKind.TEXTURE_PAGE);
p.prop("width").setRaw(p, 4);
p.prop("height").setRaw(p, 4);
p.prop("pixels").setRaw(p, new int[16]);
s.add(p, -1);
TextureEntry t = (TextureEntry) s.create(NodeKind.TEXTURE);
t.prop("u1").setRaw(t, 3);
t.prop("v1").setRaw(t, 3);
s.add(t, -1);
BufferedImage a = GeoWidgets.image(s, "#0");
assertSame(a, GeoWidgets.image(s, "#0"));
int before = a.getRGB(0, 0);
int[] px = new int[16];
java.util.Arrays.fill(px, 0x801f); // opaque red in ABGR1555
s.set(p, "pixels", px);
BufferedImage b = GeoWidgets.image(s, "#0");
assertNotSame(a, b);
assertEquals(false, before == b.getRGB(0, 0));
s.set(t, "u1", 1);
BufferedImage c = GeoWidgets.image(s, "#0");
assertNotSame(b, c);
assertEquals(2, c.getWidth());
}
}
@@ -193,6 +193,21 @@ class ManualTest {
assertEquals("Ctrl+[", ManualContent.keyText(javax.swing.KeyStroke.getKeyStroke("ctrl OPEN_BRACKET")));
}
@Test
void consecutiveNumericChoicesBecomeRanges() {
var o = com.eliasmueller.llamamod.mods.ModOption.choice(
"c",
"C",
"",
"1",
Stream.of("1", "32", "33", "34", "36", "37", "x")
.map(v -> com.eliasmueller.llamamod.mods.ModOption.of(v, v))
.toArray(com.eliasmueller.llamamod.mods.ModOption.Choice[]::new));
assertEquals("1, 32 – 34, 36, 37, x", ManualContent.choiceList(o));
String mods = manual.chapter("18-mods.md").markdown();
assertTrue(mods.contains("| TestLevel chapter | 32 – 49 | 49 |"), "arena chapter range");
}
@Test
void searchFindsSections() {
List<Manual.Hit> h = manual.search("exit floor");
@@ -128,10 +128,10 @@ class ExeImageTest {
@Test
void withoutCaveTheLayoutIsUnchanged() throws Exception {
ExeImage img = image();
for (int off : ExePatcher.patchHkcu(orig).patchedOffsets()) {
img.patch(img.va(off), new byte[] {2}, new byte[] {1}, "hkcu");
for (ExePatcher.Change c : ExePatcher.changes(orig)) {
img.patch(img.va(c.offset()), c.original(), c.replacement(), c.label());
}
assertArrayEquals(ExePatcher.patchHkcu(orig).data(), img.build());
assertArrayEquals(ExePatcher.patch(orig), img.build());
}
@Test
@@ -68,8 +68,7 @@ class ExeModCombinationsTest {
assumeTrue(m != null, "mod " + id + " not built");
exeMods.add(m);
}
byte[] base = ExePatcher.patchHkcu(Files.readAllBytes(p.originalHd().resolve("groove.exe")))
.data();
byte[] base = ExePatcher.patch(Files.readAllBytes(p.originalHd().resolve("groove.exe")));
Path game = p.game().resolve("groove.exe");
List<String> failures = new ArrayList<>();
for (boolean allOn : new boolean[] {false, true}) {
@@ -200,7 +200,7 @@ class ModOptionsAndExeTest {
Config cfg = new Config();
ModManager mm = new ModManager(p, cfg, () -> false);
byte[] orig = Files.readAllBytes(p.originalHd().resolve("groove.exe"));
byte[] base = ExePatcher.patchHkcu(orig).data();
byte[] base = ExePatcher.patch(orig);
Path game = p.game().resolve("groove.exe");
mm.restoreOriginal(Progress.NONE);
@@ -27,6 +27,17 @@ import org.junit.jupiter.api.io.TempDir;
class LevelProjectModTest {
@Test
void newProjectsStartOnAFreeChapterFrom32(@TempDir Path dir) throws IOException {
Bases cd = Bases.fromCd(GameData.cd());
Project a = Project.create(cd, "new", "t1l1m001");
assertEquals(32, a.scene().settings().targetChapter(), "template default, not the Test World's 1");
assertEquals(32, LevelProjects.nextFreeChapter(dir));
ProjectIO.save(a, dir.resolve("a.llamalevel"), cd);
Files.writeString(dir.resolve("broken.llamalevel"), "no zip");
assertEquals(33, LevelProjects.nextFreeChapter(dir));
}
@Test
void projectsBecomeMods(@TempDir Path dir) throws IOException {
Path cdDir = GameData.cd();
@@ -1,21 +1,30 @@
package com.eliasmueller.llamamod.platform;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.eliasmueller.llamamod.exe.ExeImage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.junit.jupiter.api.Test;
class ExePatcherTest {
@Test
void patchesTheFiveRegistryWrappers() throws Exception {
private static byte[] original() throws Exception {
Path exe = Path.of(System.getProperty("llamamod.cd", "/nonexistent"))
.resolveSibling("Hd")
.resolve("groove.exe");
assumeTrue(Files.isRegularFile(exe), "groove.exe not available");
byte[] orig = Files.readAllBytes(exe);
return Files.readAllBytes(exe);
}
@Test
void patchesTheFiveRegistryWrappers() throws Exception {
byte[] orig = original();
ExePatcher.Result r = ExePatcher.patchHkcu(orig);
assertEquals(List.of(0x579b, 0x57d0, 0x5810, 0x5859, 0x5880), r.patchedOffsets());
for (int o : r.patchedOffsets()) {
@@ -25,4 +34,58 @@ class ExePatcherTest {
ExePatcher.Result again = ExePatcher.patchHkcu(r.data());
assertEquals(5, again.alreadyPatched());
}
@Test
void relocatesEveryLevelRecordReference() throws Exception {
byte[] orig = original();
List<ExePatcher.Change> l = ExePatcher.relocateLevelRecords(orig);
// instruction VA of the address operand -> new address
Map<Long, Long> got = new TreeMap<>();
ExeImage img = ExeImage.parse(orig);
for (ExePatcher.Change c : l) {
long to = (c.replacement()[0] & 0xffL)
| (c.replacement()[1] & 0xffL) << 8
| (c.replacement()[2] & 0xffL) << 16
| (c.replacement()[3] & 0xffL) << 24;
got.put(img.va(c.offset()), to);
}
long coins = 0x6db940;
long flags = coins + 4 * 128;
Map<Long, Long> want = new TreeMap<>();
for (long va : new long[] {
0x40de67, 0x41c72a, 0x41c8fb, 0x41ca6b, 0x41cbcc, 0x426ac2, 0x426d43, 0x426e08, 0x54d6ba, 0x550bc5, 0x55223e
}) {
want.put(va, coins);
}
want.put(0x41c74bL, coins + 216);
want.put(0x41c91aL, coins + 216);
for (long va :
new long[] {0x40dea1, 0x40dec9, 0x41c65c, 0x41c832, 0x41ca64, 0x42706b, 0x54d6e3, 0x54d708, 0x54d70f}) {
want.put(va, flags);
}
for (long va : new long[] {0x41b724, 0x41b79c, 0x41b7ee, 0x41cd7b, 0x54a75e}) {
want.put(va, flags + 6);
}
for (long va : new long[] {0x41c6d6, 0x41c8af, 0x41cd9b}) {
want.put(va, flags + 54);
}
assertEquals(want, got);
// the buffer lies in the zero-filled tail of .data's last page, past every variable
ExeImage.Section data = img.section(".data");
assertTrue(coins >= data.va() + data.virtualSize());
assertTrue(flags + 128 <= 0x6dc000 && 0x6dc000 <= img.section(".idata").va());
byte[] full = ExePatcher.patch(orig);
assertEquals(List.of(), ExePatcher.relocateLevelRecords(full));
assertArrayEquals(full, ExePatcher.patch(full));
assertEquals(5 + want.size(), ExePatcher.changes(orig).size());
}
@Test
void unknownBuildIsLeftAlone() throws Exception {
byte[] orig = original().clone();
int off = ExeImage.parse(orig).fileOffset(0x426d43);
orig[off] = 0x20; // one reference fewer: no relocation at all
assertEquals(List.of(), ExePatcher.relocateLevelRecords(orig));
}
}
@@ -79,7 +79,7 @@ class IsoVariantsTest {
Path exe = paths.game().resolve("groove.exe");
assertTrue(Files.isRegularFile(exe), image + ": game/groove.exe missing");
byte[] orig = Files.readAllBytes(Integrity.find(paths.originalHd(), "groove.exe"));
assertArrayEquals(ExePatcher.patchHkcu(orig).data(), Files.readAllBytes(exe), image + ": exe");
assertArrayEquals(ExePatcher.patch(orig), Files.readAllBytes(exe), image + ": exe");
assertEquals(ModManager.ExeStatus.OK, mm.checkGameExe());
assertTrue(new SaveStore(paths).load(1).used(), image + ": save game not found by the save editor");
Path movies = Integrity.find(paths.cdActive(), "movies");