diff --git a/CHANGELOG.md b/CHANGELOG.md index 2015a8b..9ea6bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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** diff --git a/docs/manual/18-mods.md b/docs/manual/18-mods.md index 9ddb0ff..41bfd18 100644 --- a/docs/manual/18-mods.md +++ b/docs/manual/18-mods.md @@ -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 diff --git a/notes/img/mods/testlevel_ch9_fixed.jpg b/notes/img/mods/testlevel_ch9_fixed.jpg new file mode 100644 index 0000000..46261cd Binary files /dev/null and b/notes/img/mods/testlevel_ch9_fixed.jpg differ diff --git a/notes/levelbau.md.orig b/notes/levelbau.md.orig deleted file mode 100644 index 6f7059d..0000000 --- a/notes/levelbau.md.orig +++ /dev/null @@ -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. diff --git a/notes/llamamod-app.md b/notes/llamamod-app.md index ab8e7ed..0f22e8d 100644 --- a/notes/llamamod-app.md +++ b/notes/llamamod-app.md @@ -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 : patch 0x… n bytes – label`). diff --git a/notes/testlevel-chapters.md b/notes/testlevel-chapters.md index 4865139..1a87828 100644 --- a/notes/testlevel-chapters.md +++ b/notes/testlevel-chapters.md @@ -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. diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/Templates.java b/src/main/java/com/eliasmueller/llamamod/editor/io/Templates.java index e635b0d..bb898e6 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/io/Templates.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/Templates.java @@ -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; diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java index 7e0d2e9..b789e72 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java @@ -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> NAMES = new WeakHashMap<>(); + /** Scenes whose texture/page edits clear their cache entries (listener added on first use). */ + private static final Set 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 names(Scene s) { if (s == null) { return LevelBuilder.TEX; } + watch(s); return NAMES.computeIfAbsent(s, k -> { List 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 m = CACHE.computeIfAbsent(s, k -> new LinkedHashMap<>()); if (m.containsKey(tex)) { return m.get(tex); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/help/ManualContent.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/help/ManualContent.java index 231fc84..46ac5b8 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/help/ManualContent.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/help/ManualContent.java @@ -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 c = new ArrayList<>(); - o.choices().forEach(ch -> c.add(ch.label())); - yield String.join(", ", c); - } - case MULTI -> { - List 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 labels = o.choices().stream().map(ModOption.Choice::label).toList(); + List 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"; 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 3e56d91..db8c93f 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/NewLevelDialog.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/NewLevelDialog.java @@ -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 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; } diff --git a/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java b/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java index cb14a37..1f9b499 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java @@ -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; diff --git a/src/main/java/com/eliasmueller/llamamod/mods/levels/EditorCli.java b/src/main/java/com/eliasmueller/llamamod/mods/levels/EditorCli.java index e70b90b..abdcd22 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/levels/EditorCli.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/levels/EditorCli.java @@ -83,6 +83,10 @@ public final class EditorCli { need(rest, 1, "--editor-new [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); } diff --git a/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjects.java b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjects.java index fa9c9e5..cde8fad 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjects.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjects.java @@ -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 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 list(Path dir) { List out = new ArrayList<>(); diff --git a/src/main/java/com/eliasmueller/llamamod/platform/ExePatcher.java b/src/main/java/com/eliasmueller/llamamod/platform/ExePatcher.java index f091fed..eb844d5 100644 --- a/src/main/java/com/eliasmueller/llamamod/platform/ExePatcher.java +++ b/src/main/java/com/eliasmueller/llamamod/platform/ExePatcher.java @@ -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). + * + *

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 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 changes(byte[] exe) throws IOException { + List 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 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 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 out, String pattern, long from, long to, int expected, String label) { + List 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); diff --git a/src/main/java/com/eliasmueller/llamamod/platform/Installer.java b/src/main/java/com/eliasmueller/llamamod/platform/Installer.java index 75f45dc..e0cb24b 100644 --- a/src/main/java/com/eliasmueller/llamamod/platform/Installer.java +++ b/src/main/java/com/eliasmueller/llamamod/platform/Installer.java @@ -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()); diff --git a/src/main/java/com/eliasmueller/llamamod/save/TestLevelChapters.java b/src/main/java/com/eliasmueller/llamamod/save/TestLevelChapters.java index 6d2585f..5d759ce 100644 --- a/src/main/java/com/eliasmueller/llamamod/save/TestLevelChapters.java +++ b/src/main/java/com/eliasmueller/llamamod/save/TestLevelChapters.java @@ -20,7 +20,10 @@ import java.util.List; * 30..49 coin slots of world 0 (never read). * * - * 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() {} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgetsCacheTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgetsCacheTest.java new file mode 100644 index 0000000..4e93c88 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgetsCacheTest.java @@ -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()); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/help/ManualTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/help/ManualTest.java index 5cfbef5..d029b52 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/help/ManualTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/help/ManualTest.java @@ -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 h = manual.search("exit floor"); diff --git a/src/test/java/com/eliasmueller/llamamod/exe/ExeImageTest.java b/src/test/java/com/eliasmueller/llamamod/exe/ExeImageTest.java index ebb6f16..713eadd 100644 --- a/src/test/java/com/eliasmueller/llamamod/exe/ExeImageTest.java +++ b/src/test/java/com/eliasmueller/llamamod/exe/ExeImageTest.java @@ -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 diff --git a/src/test/java/com/eliasmueller/llamamod/mods/ExeModCombinationsTest.java b/src/test/java/com/eliasmueller/llamamod/mods/ExeModCombinationsTest.java index f2fae63..3699cbe 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/ExeModCombinationsTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/ExeModCombinationsTest.java @@ -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 failures = new ArrayList<>(); for (boolean allOn : new boolean[] {false, true}) { diff --git a/src/test/java/com/eliasmueller/llamamod/mods/ModOptionsAndExeTest.java b/src/test/java/com/eliasmueller/llamamod/mods/ModOptionsAndExeTest.java index 52f0847..506b497 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/ModOptionsAndExeTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/ModOptionsAndExeTest.java @@ -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); 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 58dd55a..acc0f0c 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/levels/LevelProjectModTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/levels/LevelProjectModTest.java @@ -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(); diff --git a/src/test/java/com/eliasmueller/llamamod/platform/ExePatcherTest.java b/src/test/java/com/eliasmueller/llamamod/platform/ExePatcherTest.java index 2decd46..4caf85a 100644 --- a/src/test/java/com/eliasmueller/llamamod/platform/ExePatcherTest.java +++ b/src/test/java/com/eliasmueller/llamamod/platform/ExePatcherTest.java @@ -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 l = ExePatcher.relocateLevelRecords(orig); + // instruction VA of the address operand -> new address + Map 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 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)); + } } diff --git a/src/test/java/com/eliasmueller/llamamod/platform/IsoVariantsTest.java b/src/test/java/com/eliasmueller/llamamod/platform/IsoVariantsTest.java index c1eb863..31602f7 100644 --- a/src/test/java/com/eliasmueller/llamamod/platform/IsoVariantsTest.java +++ b/src/test/java/com/eliasmueller/llamamod/platform/IsoVariantsTest.java @@ -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");